@melaya/runner 1.1.48 → 1.1.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.
@@ -170,6 +170,8 @@ def _config_hash() -> str:
170
170
  pinned_tools = ",".join(sorted(t.lower() for t in raw_pinned.split(",") if t.strip()))
171
171
  if pinned_tools:
172
172
  fields.append(pinned_tools)
173
+ if os.environ.get("MEL_ASSISTANT_TELEMETRY_SURFACE") == "marketing":
174
+ fields.append("marketing-scoped-tools-v1")
173
175
  canon = "|".join(fields)
174
176
  return hashlib.sha256(canon.encode("utf-8")).hexdigest()[:32]
175
177
 
@@ -607,6 +609,7 @@ def _build_agent():
607
609
  # names are case-sensitive Python identifiers (unlike connector_services,
608
610
  # which are lowercased service ids). Registered ACTIVE at boot below so the
609
611
  # model calls them directly instead of search_tools/activate_tool.
612
+ marketing_tool_scope = os.environ.get("MEL_ASSISTANT_TELEMETRY_SURFACE") == "marketing"
610
613
  pinned_tool_names = [t.strip() for t in os.environ.get("MEL_ASSISTANT_PINNED_TOOLS", "").split(",") if t.strip()]
611
614
  # Self-arm the write-approval gate whenever connectors are active, so it can't
612
615
  # be silently OFF on an older runner build that didn't set this env. Writes
@@ -659,11 +662,12 @@ def _build_agent():
659
662
  include_categories=categories + core_categories + connector_services,
660
663
  budget=_budget,
661
664
  pinned_names=pinned_tool_names or None,
665
+ allowed_names=pinned_tool_names if marketing_tool_scope else None,
662
666
  )
663
667
  except Exception as exc:
664
668
  _log(f"toolkit build failed (connectors={connector_services}, core): {exc}; retrying melaya_agent only")
665
669
  try:
666
- toolkit = build_toolkit(categories=["melaya_agent"])
670
+ toolkit = build_toolkit(names=[]) if marketing_tool_scope else build_toolkit(categories=["melaya_agent"])
667
671
  except Exception:
668
672
  toolkit = build_toolkit(names=[])
669
673
 
@@ -1302,6 +1306,7 @@ def main() -> int:
1302
1306
  except Exception:
1303
1307
  _log("restore failed:\n" + traceback.format_exc())
1304
1308
  continue
1309
+ os.environ["MEL_APPROVAL_CONVERSATION_ID"] = str(req.get("conversation_id") or "")
1305
1310
  turn_id = str(req.get("turnId") or "")
1306
1311
  message = str(req.get("message") or "")
1307
1312
  # Vision: base64 image blocks for THIS turn (validated server-side). Used
@@ -77,6 +77,11 @@ let warmSpareSeed = null;
77
77
  // SYNCHRONOUSLY here closes that window.
78
78
  const bootingAssistants = new Set();
79
79
  const ASSISTANT_IDLE_MS = 15 * 60 * 1000;
80
+ // OOM backstop for the assistant stdout line-framer: if a single unterminated
81
+ // line (no newline) grows past this, flush + reset rather than let `+=` build an
82
+ // unbounded string. Generous — legitimate frames (tool results, transcripts) are
83
+ // far smaller; a line this large would exceed the socket buffer anyway.
84
+ const ASSISTANT_STDOUT_MAX_CARRY = 64 * 1024 * 1024;
80
85
  export async function connect(opts) {
81
86
  const spinner = ora("Connecting to Melaya...").start();
82
87
  // Connect to the /api/v1/runner NAMESPACE on the existing Socket.IO server.
@@ -1324,11 +1329,25 @@ export async function connect(opts) {
1324
1329
  console.log(chalk.hex("#7c6ff0")(` ◆ Assistant ${a.forward ? "session" : "spare"} ${a.sid.slice(0, 12)}… (${a.providerLabel})`));
1325
1330
  proc.stdout?.on("data", (data) => {
1326
1331
  session.lastActivity = Date.now();
1332
+ // O(n) line framing: accumulate, split ONCE, carry the trailing partial.
1333
+ // The old `buf += …; while(indexOf/slice)` loop re-sliced the WHOLE buffer
1334
+ // per line — O(n²) on a big burst (a large tool result / browser tree in
1335
+ // the transcript). That blocked the Node event loop long enough that
1336
+ // Socket.IO couldn't service the connection (send the engine.io pong), so
1337
+ // the server's pingTimeout dropped the socket MID-TURN — the "heartbeat
1338
+ // stops while working" failure. Keeping this O(n) means the heartbeat and
1339
+ // pong keep flowing no matter how much the host streams.
1327
1340
  session.stdoutBuf += data.toString();
1328
- let idx;
1329
- while ((idx = session.stdoutBuf.indexOf("\n")) >= 0) {
1330
- const line = session.stdoutBuf.slice(0, idx);
1331
- session.stdoutBuf = session.stdoutBuf.slice(idx + 1);
1341
+ const parts = session.stdoutBuf.split("\n");
1342
+ session.stdoutBuf = parts.pop() ?? "";
1343
+ // OOM backstop: an unterminated line that keeps growing across chunks would
1344
+ // make the `+=` above quadratic again. Flush it as its own line and reset.
1345
+ if (session.stdoutBuf.length > ASSISTANT_STDOUT_MAX_CARRY) {
1346
+ parts.push(session.stdoutBuf);
1347
+ session.stdoutBuf = "";
1348
+ console.log(chalk.yellow(` [assistant-stdout] flushed oversized line (${(ASSISTANT_STDOUT_MAX_CARRY / 1048576) | 0}MB+) to protect the event loop`));
1349
+ }
1350
+ for (const line of parts) {
1332
1351
  const t = line.trim();
1333
1352
  // A protocol frame can arrive GLUED to a preceding raw print that had no
1334
1353
  // trailing newline (agentscope prints the agent transcript to the same
@@ -1865,6 +1884,7 @@ export async function connect(opts) {
1865
1884
  message: payload.message,
1866
1885
  hitl_mode: hitlMode,
1867
1886
  static_context: typeof payload.staticContext === "string" ? payload.staticContext : "",
1887
+ conversation_id: String(payload.sessionId || "").split(":").slice(1).join(":"),
1868
1888
  telemetry_surface: typeof payload.telemetrySurface === "string" ? payload.telemetrySurface : "assistant",
1869
1889
  // Per-turn browser grant: forwarded to the host as the raw compact
1870
1890
  // JWS string so the Python browser toolkit can verify it again
@@ -2454,6 +2474,124 @@ export async function connect(opts) {
2454
2474
  reply(false, undefined, e?.message || String(e));
2455
2475
  }
2456
2476
  });
2477
+ // ── Residential-IP page fetch (Melaya backlink presence check) ──────────────
2478
+ // The Melaya cloud runs from a datacenter IP that Cloudflare "managed" Turnstile
2479
+ // forces into an interactive challenge no headless engine clears. THIS machine
2480
+ // is on a residential IP, where the same real-browser fetch passes in seconds.
2481
+ // The server hands us ONE public https url; we fetch it with the local Scrapling
2482
+ // ladder (localBacklinkFetch.py) and return the HTML for the server's presence
2483
+ // scan. We receive only the url — no secrets, no internal hosts, no catalog data.
2484
+ socket.on("runner:backlink-fetch", async (payload, ack) => {
2485
+ const rid = payload?.requestId;
2486
+ if (!rid)
2487
+ return;
2488
+ ack?.({ ok: true });
2489
+ const reply = (r) => socket.emit("runner:backlink-fetch-result", {
2490
+ requestId: rid,
2491
+ ok: !r.error && !!r.html && !r.challenged,
2492
+ status: r.status ?? 0,
2493
+ engine: r.engine ?? null,
2494
+ challenged: !!r.challenged,
2495
+ html: r.html ?? "",
2496
+ finalUrl: r.finalUrl ?? payload.url,
2497
+ error: r.error,
2498
+ });
2499
+ // HTTPS-only, public url — defence in depth (the server already validated it).
2500
+ let u = null;
2501
+ try {
2502
+ u = new URL(payload.url);
2503
+ }
2504
+ catch {
2505
+ u = null;
2506
+ }
2507
+ if (!u || u.protocol !== "https:") {
2508
+ reply({ error: "invalid_url" });
2509
+ return;
2510
+ }
2511
+ try {
2512
+ const { ensurePythonEnv } = await import("./pythonEnv.js");
2513
+ const { getLocalSharedVersion } = await import("./sharedVendor.js");
2514
+ const localVersion = getLocalSharedVersion();
2515
+ if (!localVersion) {
2516
+ reply({ error: "runner python runtime still setting up" });
2517
+ return;
2518
+ }
2519
+ const env = await ensurePythonEnv(opts.pythonPath, localVersion, (m) => { if (opts.verbose)
2520
+ console.log(chalk.gray(` [backlink-fetch venv] ${m}`)); });
2521
+ if (!env.ok) {
2522
+ reply({ error: `venv bootstrap failed: ${env.reason}` });
2523
+ return;
2524
+ }
2525
+ const { existsSync, copyFileSync, mkdirSync } = await import("fs");
2526
+ const workDir = join(tmpdir(), `melaya-blf-${Date.now()}`);
2527
+ mkdirSync(workDir, { recursive: true });
2528
+ const candidates = [
2529
+ join(__dirname, "localBacklinkFetch.py"),
2530
+ join(__dirname, "..", "localBacklinkFetch.py"),
2531
+ ];
2532
+ let found = "";
2533
+ for (const c of candidates) {
2534
+ if (existsSync(c)) {
2535
+ found = c;
2536
+ break;
2537
+ }
2538
+ }
2539
+ if (!found) {
2540
+ reply({ error: "localBacklinkFetch.py not found — update @melaya/runner" });
2541
+ return;
2542
+ }
2543
+ const staged = join(workDir, "localBacklinkFetch.py");
2544
+ copyFileSync(found, staged);
2545
+ const certBundle = (await import("./pythonEnv.js")).getCertBundlePath();
2546
+ const sslEnv = certBundle
2547
+ ? { SSL_CERT_FILE: certBundle, REQUESTS_CA_BUNDLE: certBundle } : {};
2548
+ const budget = Math.max(6, Math.min(75, Math.floor(payload.budgetSeconds || 40)));
2549
+ const proc = spawn(env.pythonPath, ["-u", staged], {
2550
+ env: { ...process.env, ...sslEnv },
2551
+ cwd: workDir,
2552
+ });
2553
+ let stdout = "";
2554
+ let stderr = "";
2555
+ let done = false;
2556
+ const finish = (r) => { if (done)
2557
+ return; done = true; reply(r); };
2558
+ // Hard wall-clock cap slightly above the fetch budget so a wedged browser
2559
+ // can't hold the session; the server has its own (budget+8s) timeout too.
2560
+ const killTimer = setTimeout(() => {
2561
+ try {
2562
+ proc.kill();
2563
+ }
2564
+ catch { /* already gone */ }
2565
+ finish({ error: "fetch timed out" });
2566
+ }, (budget + 10) * 1000);
2567
+ proc.stdout.on("data", (d) => { stdout += d.toString("utf-8"); });
2568
+ proc.stderr.on("data", (d) => { stderr += d.toString("utf-8"); });
2569
+ proc.on("error", (err) => { clearTimeout(killTimer); finish({ error: err?.message || String(err) }); });
2570
+ proc.on("close", (code) => {
2571
+ clearTimeout(killTimer);
2572
+ const jsonLine = stdout.split(/\r?\n/).map(s => s.trim()).filter(Boolean).reverse()[0] || "";
2573
+ let parsed = null;
2574
+ try {
2575
+ parsed = JSON.parse(jsonLine);
2576
+ }
2577
+ catch { /* handled below */ }
2578
+ if (parsed && typeof parsed === "object") {
2579
+ finish({ status: parsed.status, engine: parsed.engine, challenged: !!parsed.challenged, html: parsed.html || "", finalUrl: parsed.finalUrl });
2580
+ }
2581
+ else {
2582
+ finish({ error: `fetch exited ${code ?? "?"}${stderr ? `: ${stderr.slice(-200)}` : ""}` });
2583
+ }
2584
+ });
2585
+ try {
2586
+ proc.stdin.write(JSON.stringify({ url: u.href, budget_seconds: budget }));
2587
+ proc.stdin.end();
2588
+ }
2589
+ catch { /* proc.on('error') handles it */ }
2590
+ }
2591
+ catch (e) {
2592
+ reply({ error: e?.message || String(e) });
2593
+ }
2594
+ });
2457
2595
  socket.on("linkedin:start-login", async (req) => {
2458
2596
  const sid = req?.session_id;
2459
2597
  if (!sid)
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """Residential-IP page fetch for the Melaya backlink presence check.
3
+
4
+ The Melaya cloud runs from a datacenter IP that Cloudflare "managed" Turnstile
5
+ forces into an interactive challenge no headless engine clears. The user's
6
+ runner is on a RESIDENTIAL IP, where the same real-browser fetch passes in
7
+ seconds. So the server hands us ONE public https url; we fetch it with the
8
+ local Scrapling stack and print the result as a single JSON line on stdout:
9
+
10
+ {"status": int, "engine": str|null, "challenged": bool,
11
+ "finalUrl": str, "html": str} # html capped
12
+
13
+ Input (stdin, JSON): {"url": "...", "budget_seconds": 40}
14
+
15
+ Self-contained on purpose (mirrors localDbProbe.py): it imports only Scrapling
16
+ from the runner venv, never the shared tool bundle. HTTPS-only; the server
17
+ already validated the url, this is defence in depth. Never fetches anything but
18
+ the url it is given — no internal hosts, no secrets, no catalog data.
19
+ """
20
+ import asyncio
21
+ import json
22
+ import sys
23
+
24
+ _HTML_CAP = 600_000
25
+ _CHALLENGE_MARKERS = (
26
+ "just a moment", "cf_chl", "cf-challenge", "checking your browser",
27
+ "enable javascript and cookies", "attention required",
28
+ )
29
+
30
+
31
+ def _html_of(page) -> str:
32
+ for attr in ("html_content", "body", "content"):
33
+ try:
34
+ v = getattr(page, attr)
35
+ if callable(v):
36
+ v = v()
37
+ if v:
38
+ return v if isinstance(v, str) else str(v)
39
+ except Exception:
40
+ pass
41
+ try:
42
+ return str(page)
43
+ except Exception:
44
+ return ""
45
+
46
+
47
+ def _challenged(h: str) -> bool:
48
+ h = (h or "").lower()
49
+ return any(m in h for m in _CHALLENGE_MARKERS)
50
+
51
+
52
+ async def _fetch(url: str, budget: float) -> dict:
53
+ ms = int(max(6.0, min(75.0, budget)) * 1000)
54
+ # ── Tier 1: TLS-impersonating async GET (no browser) ──
55
+ try:
56
+ from scrapling.fetchers import AsyncFetcher
57
+ p = await AsyncFetcher.get(url, timeout=min(ms, 15000), impersonate="chrome",
58
+ stealthy_headers=True, follow_redirects=True)
59
+ st = int(getattr(p, "status", 0) or 0)
60
+ h = _html_of(p)
61
+ final = str(getattr(p, "url", "") or url)
62
+ if 200 <= st < 300 and h and not _challenged(h):
63
+ return {"status": st, "engine": "fast", "challenged": False,
64
+ "finalUrl": final, "html": h[:_HTML_CAP]}
65
+ except Exception:
66
+ pass
67
+
68
+ # ── Tier 2/3: stealth browser (real Chrome), plain then solve_cloudflare ──
69
+ try:
70
+ from scrapling.fetchers import StealthyFetcher
71
+ except Exception:
72
+ return {"status": 0, "engine": None, "challenged": False, "finalUrl": url, "html": ""}
73
+ fn = getattr(StealthyFetcher, "async_fetch", None)
74
+ if fn is None:
75
+ return {"status": 0, "engine": None, "challenged": False, "finalUrl": url, "html": ""}
76
+
77
+ last = {"status": 0, "engine": "stealth", "challenged": True, "finalUrl": url, "html": ""}
78
+ for solve in (False, True):
79
+ base = {"headless": True, "timeout": 60000 if solve else min(ms, 25000)}
80
+ if solve:
81
+ base.update(solve_cloudflare=True, block_webrtc=True, disable_resources=False)
82
+ page = None
83
+ engine = ("real_chrome_cf" if solve else "real_chrome")
84
+ for use_real in (True, False):
85
+ kw = dict(base)
86
+ if use_real:
87
+ kw["real_chrome"] = True
88
+ else:
89
+ engine = ("chromium_cf" if solve else "chromium")
90
+ try:
91
+ page = await fn(url, **kw)
92
+ break
93
+ except Exception:
94
+ page = None
95
+ continue
96
+ if page is None:
97
+ continue
98
+ h = _html_of(page)
99
+ st = int(getattr(page, "status", 0) or 0)
100
+ final = str(getattr(page, "url", "") or url)
101
+ last = {"status": st, "engine": engine, "challenged": _challenged(h),
102
+ "finalUrl": final, "html": (h or "")[:_HTML_CAP]}
103
+ if h and not _challenged(h):
104
+ return last
105
+ if not solve and not _challenged(h):
106
+ break # plain stealth returned something non-challenge-y but empty
107
+ return last
108
+
109
+
110
+ async def _main() -> None:
111
+ try:
112
+ data = json.loads(sys.stdin.read() or "{}")
113
+ except Exception:
114
+ data = {}
115
+ url = str(data.get("url") or "").strip()
116
+ try:
117
+ budget = float(data.get("budget_seconds") or 40)
118
+ except Exception:
119
+ budget = 40.0
120
+ from urllib.parse import urlsplit
121
+ if not url or urlsplit(url).scheme != "https":
122
+ print(json.dumps({"status": 0, "engine": None, "challenged": False,
123
+ "finalUrl": url, "html": "", "error": "https_only"}))
124
+ return
125
+ try:
126
+ out = await _fetch(url, budget)
127
+ except Exception as e:
128
+ out = {"status": 0, "engine": None, "challenged": False,
129
+ "finalUrl": url, "html": "", "error": str(e)[:200]}
130
+ print(json.dumps(out))
131
+
132
+
133
+ if sys.platform.startswith("win"):
134
+ try:
135
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
136
+ except Exception:
137
+ pass
138
+ asyncio.run(_main())
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """Residential-IP page fetch for the Melaya backlink presence check.
3
+
4
+ The Melaya cloud runs from a datacenter IP that Cloudflare "managed" Turnstile
5
+ forces into an interactive challenge no headless engine clears. The user's
6
+ runner is on a RESIDENTIAL IP, where the same real-browser fetch passes in
7
+ seconds. So the server hands us ONE public https url; we fetch it with the
8
+ local Scrapling stack and print the result as a single JSON line on stdout:
9
+
10
+ {"status": int, "engine": str|null, "challenged": bool,
11
+ "finalUrl": str, "html": str} # html capped
12
+
13
+ Input (stdin, JSON): {"url": "...", "budget_seconds": 40}
14
+
15
+ Self-contained on purpose (mirrors localDbProbe.py): it imports only Scrapling
16
+ from the runner venv, never the shared tool bundle. HTTPS-only; the server
17
+ already validated the url, this is defence in depth. Never fetches anything but
18
+ the url it is given — no internal hosts, no secrets, no catalog data.
19
+ """
20
+ import asyncio
21
+ import json
22
+ import sys
23
+
24
+ _HTML_CAP = 600_000
25
+ _CHALLENGE_MARKERS = (
26
+ "just a moment", "cf_chl", "cf-challenge", "checking your browser",
27
+ "enable javascript and cookies", "attention required",
28
+ )
29
+
30
+
31
+ def _html_of(page) -> str:
32
+ for attr in ("html_content", "body", "content"):
33
+ try:
34
+ v = getattr(page, attr)
35
+ if callable(v):
36
+ v = v()
37
+ if v:
38
+ return v if isinstance(v, str) else str(v)
39
+ except Exception:
40
+ pass
41
+ try:
42
+ return str(page)
43
+ except Exception:
44
+ return ""
45
+
46
+
47
+ def _challenged(h: str) -> bool:
48
+ h = (h or "").lower()
49
+ return any(m in h for m in _CHALLENGE_MARKERS)
50
+
51
+
52
+ async def _fetch(url: str, budget: float) -> dict:
53
+ ms = int(max(6.0, min(75.0, budget)) * 1000)
54
+ # ── Tier 1: TLS-impersonating async GET (no browser) ──
55
+ try:
56
+ from scrapling.fetchers import AsyncFetcher
57
+ p = await AsyncFetcher.get(url, timeout=min(ms, 15000), impersonate="chrome",
58
+ stealthy_headers=True, follow_redirects=True)
59
+ st = int(getattr(p, "status", 0) or 0)
60
+ h = _html_of(p)
61
+ final = str(getattr(p, "url", "") or url)
62
+ if 200 <= st < 300 and h and not _challenged(h):
63
+ return {"status": st, "engine": "fast", "challenged": False,
64
+ "finalUrl": final, "html": h[:_HTML_CAP]}
65
+ except Exception:
66
+ pass
67
+
68
+ # ── Tier 2/3: stealth browser (real Chrome), plain then solve_cloudflare ──
69
+ try:
70
+ from scrapling.fetchers import StealthyFetcher
71
+ except Exception:
72
+ return {"status": 0, "engine": None, "challenged": False, "finalUrl": url, "html": ""}
73
+ fn = getattr(StealthyFetcher, "async_fetch", None)
74
+ if fn is None:
75
+ return {"status": 0, "engine": None, "challenged": False, "finalUrl": url, "html": ""}
76
+
77
+ last = {"status": 0, "engine": "stealth", "challenged": True, "finalUrl": url, "html": ""}
78
+ for solve in (False, True):
79
+ base = {"headless": True, "timeout": 60000 if solve else min(ms, 25000)}
80
+ if solve:
81
+ base.update(solve_cloudflare=True, block_webrtc=True, disable_resources=False)
82
+ page = None
83
+ engine = ("real_chrome_cf" if solve else "real_chrome")
84
+ for use_real in (True, False):
85
+ kw = dict(base)
86
+ if use_real:
87
+ kw["real_chrome"] = True
88
+ else:
89
+ engine = ("chromium_cf" if solve else "chromium")
90
+ try:
91
+ page = await fn(url, **kw)
92
+ break
93
+ except Exception:
94
+ page = None
95
+ continue
96
+ if page is None:
97
+ continue
98
+ h = _html_of(page)
99
+ st = int(getattr(page, "status", 0) or 0)
100
+ final = str(getattr(page, "url", "") or url)
101
+ last = {"status": st, "engine": engine, "challenged": _challenged(h),
102
+ "finalUrl": final, "html": (h or "")[:_HTML_CAP]}
103
+ if h and not _challenged(h):
104
+ return last
105
+ if not solve and not _challenged(h):
106
+ break # plain stealth returned something non-challenge-y but empty
107
+ return last
108
+
109
+
110
+ async def _main() -> None:
111
+ try:
112
+ data = json.loads(sys.stdin.read() or "{}")
113
+ except Exception:
114
+ data = {}
115
+ url = str(data.get("url") or "").strip()
116
+ try:
117
+ budget = float(data.get("budget_seconds") or 40)
118
+ except Exception:
119
+ budget = 40.0
120
+ from urllib.parse import urlsplit
121
+ if not url or urlsplit(url).scheme != "https":
122
+ print(json.dumps({"status": 0, "engine": None, "challenged": False,
123
+ "finalUrl": url, "html": "", "error": "https_only"}))
124
+ return
125
+ try:
126
+ out = await _fetch(url, budget)
127
+ except Exception as e:
128
+ out = {"status": 0, "engine": None, "challenged": False,
129
+ "finalUrl": url, "html": "", "error": str(e)[:200]}
130
+ print(json.dumps(out))
131
+
132
+
133
+ if sys.platform.startswith("win"):
134
+ try:
135
+ asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
136
+ except Exception:
137
+ pass
138
+ asyncio.run(_main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@melaya/runner",
3
- "version": "1.1.48",
3
+ "version": "1.1.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,
@@ -16,11 +16,12 @@
16
16
  "localRagIngest.py",
17
17
  "localRagRetrieve.py",
18
18
  "localDbProbe.py",
19
+ "localBacklinkFetch.py",
19
20
  "nltk_data/**",
20
21
  "README.md"
21
22
  ],
22
23
  "scripts": {
23
- "build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('localDbProbe.py','dist/localDbProbe.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
24
+ "build": "tsc && node -e \"const fs=require('fs'); fs.copyFileSync('localRagIngest.py','dist/localRagIngest.py'); fs.copyFileSync('localRagRetrieve.py','dist/localRagRetrieve.py'); fs.copyFileSync('localDbProbe.py','dist/localDbProbe.py'); fs.copyFileSync('localBacklinkFetch.py','dist/localBacklinkFetch.py'); fs.copyFileSync('src/assistantHost.py','dist/assistantHost.py')\"",
24
25
  "test": "node --test --import tsx \"src/**/*.test.ts\"",
25
26
  "prepublishOnly": "npm run build"
26
27
  },