@tiens.nguyen/gonext-local-worker 1.0.155 → 1.0.158

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.
@@ -1601,6 +1601,17 @@ async function runAgentChatJob(job) {
1601
1601
  emailBodyTemplate: payload?.emailBodyTemplate ?? "",
1602
1602
  emailFrom: payload?.emailFrom ?? "",
1603
1603
  emailAllowList: payload?.emailAllowList ?? "",
1604
+ // RAG tools (download/unzip/index/search over a user's ZIP-at-URL). Only enabled
1605
+ // + registered when ragEnabled + AWS creds present. The worker talks to the user's
1606
+ // OWN S3 bucket directly with these creds (boto3), NOT via presigned URLs.
1607
+ ragEnabled: payload?.ragEnabled ?? false,
1608
+ ragS3Location: payload?.ragS3Location ?? "",
1609
+ ragAwsRegion: payload?.ragAwsRegion ?? "",
1610
+ ragAwsAccessKeyId: payload?.ragAwsAccessKeyId ?? "",
1611
+ ragAwsSecretAccessKey: payload?.ragAwsSecretAccessKey ?? "",
1612
+ ragEmbedModel: payload?.ragEmbedModel ?? "",
1613
+ ragEmbedUrl: payload?.ragEmbedUrl ?? "",
1614
+ ragTopK: payload?.ragTopK ?? 6,
1604
1615
  });
1605
1616
  // 30 min max for an agent run: multi-step ReAct on a 14B/31B with a cold prompt
1606
1617
  // cache — or a remote Ollama box on slow GPU cold-loading a big model — can run
@@ -518,7 +518,7 @@ _AGENT_KEYWORDS = re.compile(
518
518
  r"|api|endpoint|url|http|https"
519
519
  r"|external\s+source|external\s+api|external\s+service"
520
520
  r"|web\s+service|rest\s+api|rest\s+call"
521
- r"|download|scrape|crawl"
521
+ r"|download|scrape|crawl|zip|unzip|\.zip|rag|index\s+the|knowledge\s+base|summari[sz]e"
522
522
  r"|search|find|look\s*up|lookup|weather|news|latest|current|today|tonight"
523
523
  r"|date|time|what\s+day|what\s+time"
524
524
  r"|read\s+(?:this\s+|that\s+|the\s+)?(?:page|article|url|link)|open\s+(?:this\s+|that\s+|the\s+)?(?:url|link|page)|contents?\s+of"
@@ -1080,6 +1080,283 @@ def _derive_pdf_title(doc_text: str) -> str:
1080
1080
  return "Document"
1081
1081
 
1082
1082
 
1083
+ # ============================ RAG (knowledge base) ============================
1084
+ # The agent can download a ZIP-at-URL, unzip it locally, index the text files into a
1085
+ # flat JSON vector store on the USER'S OWN S3 bucket (explicit AWS creds — NOT presigned),
1086
+ # then retrieve relevant chunks to answer questions. Embeddings run on the Ollama coding
1087
+ # server (/api/embed). Everything below is best-effort with hard safety caps.
1088
+
1089
+ _RAG_TEXT_EXTS = {
1090
+ ".cs", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte",
1091
+ ".json", ".jsonc", ".md", ".mdx", ".txt", ".rst", ".py", ".rb", ".go",
1092
+ ".rs", ".java", ".kt", ".swift", ".php", ".c", ".h", ".cpp", ".hpp", ".cc",
1093
+ ".html", ".htm", ".css", ".scss", ".sass", ".less", ".xml", ".yaml", ".yml",
1094
+ ".toml", ".ini", ".cfg", ".conf", ".env", ".sh", ".bash", ".zsh", ".sql",
1095
+ ".graphql", ".proto", ".gradle", ".dockerfile", ".tsv", ".csv",
1096
+ }
1097
+ _RAG_SKIP_DIRS = {
1098
+ "node_modules", ".git", ".svn", ".hg", "__pycache__", ".venv", "venv",
1099
+ "dist", "build", ".next", ".nuxt", "out", ".cache", "bin", "obj", ".idea",
1100
+ ".gradle", "vendor", "target",
1101
+ }
1102
+ _RAG_MAX_DOWNLOAD_BYTES = 500 * 1024 * 1024 # 500 MB zip cap
1103
+ _RAG_MAX_UNZIP_BYTES = 2 * 1024 * 1024 * 1024 # 2 GB uncompressed (zip-bomb guard)
1104
+ _RAG_MAX_FILES = 20000
1105
+ _RAG_MAX_FILE_BYTES = 2 * 1024 * 1024 # skip individual text files > 2 MB
1106
+ _RAG_CHUNK_CHARS = 3200 # ~800 tokens
1107
+ _RAG_CHUNK_OVERLAP = 400
1108
+
1109
+
1110
+ def _rag_source_key(url: str) -> str:
1111
+ import hashlib
1112
+ return hashlib.sha256((url or "").strip().encode("utf-8")).hexdigest()[:32]
1113
+
1114
+
1115
+ def _rag_gdrive_direct(url: str) -> str:
1116
+ """Rewrite a Google Drive share link to its direct-download form."""
1117
+ m = re.search(r"drive\.google\.com/file/d/([A-Za-z0-9_-]+)", url or "")
1118
+ if m:
1119
+ return f"https://drive.google.com/uc?export=download&id={m.group(1)}"
1120
+ m = re.search(r"drive\.google\.com/open\?id=([A-Za-z0-9_-]+)", url or "")
1121
+ if m:
1122
+ return f"https://drive.google.com/uc?export=download&id={m.group(1)}"
1123
+ return url
1124
+
1125
+
1126
+ def _rag_assert_safe_url(url: str) -> None:
1127
+ """SSRF guard: only http/https, and refuse private/link-local/loopback hosts."""
1128
+ import ipaddress
1129
+ import socket
1130
+ from urllib.parse import urlparse
1131
+ p = urlparse(url or "")
1132
+ if p.scheme not in ("http", "https"):
1133
+ raise ValueError("Only http/https URLs are allowed.")
1134
+ host = p.hostname or ""
1135
+ if not host:
1136
+ raise ValueError("URL has no host.")
1137
+ try:
1138
+ infos = socket.getaddrinfo(host, None)
1139
+ except Exception as e: # noqa: BLE001
1140
+ raise ValueError(f"Cannot resolve host {host}: {e}")
1141
+ for info in infos:
1142
+ ip = info[4][0]
1143
+ try:
1144
+ addr = ipaddress.ip_address(ip)
1145
+ except ValueError:
1146
+ continue
1147
+ if (addr.is_private or addr.is_loopback or addr.is_link_local
1148
+ or addr.is_reserved or addr.is_multicast):
1149
+ raise ValueError(f"Refusing to fetch a private/internal address ({ip}).")
1150
+
1151
+
1152
+ def _rag_workdir(source_key: str) -> str:
1153
+ import os
1154
+ base = os.path.join(os.path.expanduser("~"), ".gonext", "rag-work", source_key)
1155
+ os.makedirs(base, exist_ok=True)
1156
+ return base
1157
+
1158
+
1159
+ def _rag_download(url: str, dest_path: str = "") -> str:
1160
+ """Download a public URL to a local path with SSRF + size guards. Returns the path."""
1161
+ import os
1162
+ src = _rag_gdrive_direct((url or "").strip())
1163
+ _rag_assert_safe_url(src)
1164
+ key = _rag_source_key(url)
1165
+ if not dest_path:
1166
+ name = os.path.basename(src.split("?")[0]) or "download.bin"
1167
+ if not name.lower().endswith(".zip") and "zip" in src.lower():
1168
+ name += ".zip"
1169
+ dest_path = os.path.join(_rag_workdir(key), name)
1170
+ os.makedirs(os.path.dirname(dest_path) or ".", exist_ok=True)
1171
+ req = urllib.request.Request(src, headers={"User-Agent": "gonext-rag/1.0"})
1172
+ total = 0
1173
+ with urllib.request.urlopen(req, timeout=120, context=_ssl_context()) as resp, \
1174
+ open(dest_path, "wb") as out:
1175
+ while True:
1176
+ block = resp.read(1024 * 256)
1177
+ if not block:
1178
+ break
1179
+ total += len(block)
1180
+ if total > _RAG_MAX_DOWNLOAD_BYTES:
1181
+ out.close()
1182
+ os.remove(dest_path)
1183
+ raise ValueError(
1184
+ f"Download exceeds the {_RAG_MAX_DOWNLOAD_BYTES // (1024*1024)} MB cap."
1185
+ )
1186
+ out.write(block)
1187
+ return dest_path
1188
+
1189
+
1190
+ def _rag_unzip(zip_path: str, dest_dir: str = "") -> tuple:
1191
+ """Extract a local zip with Zip-Slip + zip-bomb guards. Returns (dir, file_list)."""
1192
+ import os
1193
+ import zipfile
1194
+ if not dest_dir:
1195
+ dest_dir = os.path.splitext(zip_path)[0] + "_unzipped"
1196
+ os.makedirs(dest_dir, exist_ok=True)
1197
+ dest_root = os.path.realpath(dest_dir)
1198
+ written = 0
1199
+ names = []
1200
+ with zipfile.ZipFile(zip_path) as zf:
1201
+ infos = zf.infolist()
1202
+ if len(infos) > _RAG_MAX_FILES:
1203
+ raise ValueError(f"Zip has too many entries (> {_RAG_MAX_FILES}).")
1204
+ total_uncompressed = sum(i.file_size for i in infos)
1205
+ if total_uncompressed > _RAG_MAX_UNZIP_BYTES:
1206
+ raise ValueError("Zip uncompressed size exceeds the safety cap (possible zip bomb).")
1207
+ for info in infos:
1208
+ if info.is_dir():
1209
+ continue
1210
+ target = os.path.realpath(os.path.join(dest_dir, info.filename))
1211
+ # Zip Slip: extracted path must stay within dest_dir.
1212
+ if not (target == dest_root or target.startswith(dest_root + os.sep)):
1213
+ continue
1214
+ os.makedirs(os.path.dirname(target), exist_ok=True)
1215
+ with zf.open(info) as src, open(target, "wb") as dst:
1216
+ dst.write(src.read())
1217
+ written += 1
1218
+ names.append(info.filename)
1219
+ return dest_dir, names
1220
+
1221
+
1222
+ def _rag_iter_text_files(root: str):
1223
+ """Yield (abs_path, rel_path, ext) for indexable text files under root."""
1224
+ import os
1225
+ if os.path.isfile(root):
1226
+ yield root, os.path.basename(root), os.path.splitext(root)[1].lower()
1227
+ return
1228
+ for dirpath, dirnames, filenames in os.walk(root):
1229
+ dirnames[:] = [d for d in dirnames if d not in _RAG_SKIP_DIRS]
1230
+ for fn in filenames:
1231
+ ext = os.path.splitext(fn)[1].lower()
1232
+ base = fn.lower()
1233
+ if ext not in _RAG_TEXT_EXTS and base not in ("dockerfile", "makefile", ".gitignore"):
1234
+ continue
1235
+ abs_path = os.path.join(dirpath, fn)
1236
+ try:
1237
+ if os.path.getsize(abs_path) > _RAG_MAX_FILE_BYTES:
1238
+ continue
1239
+ except OSError:
1240
+ continue
1241
+ yield abs_path, os.path.relpath(abs_path, root), ext
1242
+
1243
+
1244
+ def _rag_chunk_text(text: str, rel_path: str, ext: str):
1245
+ """Line-aware windowed chunks with overlap. Returns list of {file,ext,start,end,text}."""
1246
+ if not text.strip():
1247
+ return []
1248
+ chunks = []
1249
+ n = len(text)
1250
+ step = max(1, _RAG_CHUNK_CHARS - _RAG_CHUNK_OVERLAP)
1251
+ i = 0
1252
+ while i < n:
1253
+ end = min(n, i + _RAG_CHUNK_CHARS)
1254
+ # Prefer to cut on a newline near the window end.
1255
+ if end < n:
1256
+ nl = text.rfind("\n", i + step, end)
1257
+ if nl > i:
1258
+ end = nl + 1
1259
+ piece = text[i:end].strip()
1260
+ if piece:
1261
+ chunks.append({"file": rel_path, "ext": ext, "start": i, "end": end, "text": piece})
1262
+ if end >= n:
1263
+ break
1264
+ i = end - _RAG_CHUNK_OVERLAP if end - _RAG_CHUNK_OVERLAP > i else end
1265
+ return chunks
1266
+
1267
+
1268
+ def _embed(base: str, model: str, texts: list) -> list:
1269
+ """Embed a batch of texts. Auto-detects the endpoint from `base` (a bare host:port,
1270
+ an .../v1, or an Ollama host): tries OpenAI-compatible /v1/embeddings first (MLX
1271
+ servers, modern Ollama), then Ollama /api/embed, then legacy /api/embeddings."""
1272
+ # Normalize to a bare root (strip any /v1, /api, /api/embed(dings) suffix).
1273
+ root = re.sub(r"/(v1|api/embeddings|api/embed|api)/?$", "", (base or "").rstrip("/")).rstrip("/")
1274
+ if not root:
1275
+ raise RuntimeError("No embedding endpoint configured (set the RAG embedding server URL).")
1276
+ ctx = _ssl_context()
1277
+ last_err = None
1278
+
1279
+ def _post(url, payload, timeout):
1280
+ req = urllib.request.Request(
1281
+ url, data=json.dumps(payload).encode("utf-8"),
1282
+ headers={"Content-Type": "application/json"}, method="POST",
1283
+ )
1284
+ with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
1285
+ return json.loads(resp.read().decode("utf-8", "replace"))
1286
+
1287
+ # 1) OpenAI-compatible /v1/embeddings — MLX servers on a port, and modern Ollama.
1288
+ try:
1289
+ data = _post(f"{root}/v1/embeddings", {"model": model, "input": texts}, 300)
1290
+ items = data.get("data")
1291
+ if isinstance(items, list) and items and isinstance(items[0], dict) and "embedding" in items[0]:
1292
+ return [it["embedding"] for it in items]
1293
+ except urllib.error.HTTPError as e:
1294
+ if e.code not in (400, 404, 405, 501):
1295
+ raise RuntimeError(f"Embeddings failed (HTTP {e.code}): {e.read().decode('utf-8', 'replace')[:200]}")
1296
+ last_err = f"/v1/embeddings HTTP {e.code}"
1297
+ except urllib.error.URLError as e:
1298
+ raise RuntimeError(f"Embedding server unreachable at {root}: {getattr(e, 'reason', e)}")
1299
+
1300
+ # 2) Ollama batch /api/embed.
1301
+ try:
1302
+ data = _post(f"{root}/api/embed", {"model": model, "input": texts}, 300)
1303
+ embs = data.get("embeddings")
1304
+ if isinstance(embs, list) and embs and isinstance(embs[0], list):
1305
+ return embs
1306
+ except urllib.error.HTTPError as e:
1307
+ if e.code not in (400, 404, 405, 501):
1308
+ raise RuntimeError(f"Ollama embed failed (HTTP {e.code}): {e.read().decode('utf-8', 'replace')[:200]}")
1309
+ last_err = f"/api/embed HTTP {e.code}"
1310
+
1311
+ # 3) Legacy Ollama /api/embeddings (per text).
1312
+ out = []
1313
+ for t in texts:
1314
+ data = _post(f"{root}/api/embeddings", {"model": model, "prompt": t}, 120)
1315
+ out.append(data.get("embedding") or [])
1316
+ if any(out):
1317
+ return out
1318
+ raise RuntimeError(f"No embedding endpoint responded at {root} ({last_err or 'unknown'}).")
1319
+
1320
+
1321
+ def _cosine(a: list, b: list) -> float:
1322
+ import math
1323
+ if not a or not b or len(a) != len(b):
1324
+ return -1.0
1325
+ dot = sum(x * y for x, y in zip(a, b))
1326
+ na = math.sqrt(sum(x * x for x in a))
1327
+ nb = math.sqrt(sum(y * y for y in b))
1328
+ if na == 0 or nb == 0:
1329
+ return -1.0
1330
+ return dot / (na * nb)
1331
+
1332
+
1333
+ def _rag_parse_location(loc: str):
1334
+ """Parse an ARN / s3:// URI / bucket[/prefix] into (bucket, prefix)."""
1335
+ s = (loc or "").strip() or "arn:aws:s3:::gonext-rag"
1336
+ rest = s
1337
+ m = re.match(r"^arn:aws:s3:::(.+)$", s, re.I)
1338
+ if m:
1339
+ rest = m.group(1)
1340
+ else:
1341
+ m = re.match(r"^s3://(.+)$", s, re.I)
1342
+ if m:
1343
+ rest = m.group(1)
1344
+ rest = rest.strip("/")
1345
+ if "/" in rest:
1346
+ bucket, prefix = rest.split("/", 1)
1347
+ else:
1348
+ bucket, prefix = rest, ""
1349
+ return bucket, prefix.strip("/")
1350
+
1351
+
1352
+ def _rag_s3_client(region: str, akid: str, secret: str):
1353
+ import boto3 # noqa: PLC0415
1354
+ return boto3.client(
1355
+ "s3", region_name=region or "ap-southeast-1",
1356
+ aws_access_key_id=akid, aws_secret_access_key=secret,
1357
+ )
1358
+
1359
+
1083
1360
  def run_agent_chat(cfg):
1084
1361
  try:
1085
1362
  from smolagents import CodeAgent, OpenAIServerModel, tool
@@ -1238,6 +1515,68 @@ def run_agent_chat(cfg):
1238
1515
  # action must never be silently available (same gating as the PDF reader).
1239
1516
  _EMAIL_AVAILABLE = bool(email_enabled and email_api_url and email_body_template)
1240
1517
 
1518
+ # ---- RAG config (download/unzip/index/search over a user's ZIP-at-URL) ----
1519
+ rag_enabled = bool(cfg.get("ragEnabled"))
1520
+ rag_location = (cfg.get("ragS3Location") or "arn:aws:s3:::gonext-rag").strip()
1521
+ rag_region = (cfg.get("ragAwsRegion") or "ap-southeast-1").strip()
1522
+ rag_akid = (cfg.get("ragAwsAccessKeyId") or "").strip()
1523
+ rag_secret = (cfg.get("ragAwsSecretAccessKey") or "").strip()
1524
+ rag_embed_model = (cfg.get("ragEmbedModel") or "nomic-embed-text").strip()
1525
+ rag_embed_url = (cfg.get("ragEmbedUrl") or "").strip()
1526
+ try:
1527
+ rag_top_k = int(cfg.get("ragTopK") or 6)
1528
+ except (TypeError, ValueError):
1529
+ rag_top_k = 6
1530
+ rag_top_k = max(1, min(50, rag_top_k))
1531
+ # Embedding server: an explicit MLX/Ollama endpoint from Settings if given, else the
1532
+ # agent's Ollama coding server. _embed() auto-detects OpenAI-compat (/v1/embeddings,
1533
+ # e.g. an MLX server on a port) vs Ollama (/api/embed) — so a bare host:port works.
1534
+ rag_embed_base = rag_embed_url or (coding_base_url or agent_base_url or "")
1535
+ try:
1536
+ import boto3 as _boto3_probe # noqa: F401,PLC0415
1537
+ _boto3_ok = True
1538
+ except Exception: # noqa: BLE001
1539
+ _boto3_ok = False
1540
+ _RAG_AVAILABLE = bool(rag_enabled and rag_akid and rag_secret and rag_embed_base and _boto3_ok)
1541
+ if rag_enabled and not _boto3_ok:
1542
+ _log("RAG requested but boto3 is not installed in the worker python — RAG tools disabled")
1543
+
1544
+ def _rag_s3_and_loc():
1545
+ client = _rag_s3_client(rag_region, rag_akid, rag_secret)
1546
+ bucket, prefix = _rag_parse_location(rag_location)
1547
+ return client, bucket, prefix
1548
+
1549
+ def _rag_prefix_for(source_url: str, prefix: str) -> str:
1550
+ key = _rag_source_key(source_url)
1551
+ base = (prefix + "/") if prefix else ""
1552
+ return f"{base}agent-rag/{key}"
1553
+
1554
+ def _rag_load_chunks(client, bucket: str, base_prefix: str) -> list:
1555
+ """Load all chunk records (across shards) for a source. Returns [] if none."""
1556
+ out = []
1557
+ token = None
1558
+ while True:
1559
+ kw = {"Bucket": bucket, "Prefix": f"{base_prefix}/chunks"}
1560
+ if token:
1561
+ kw["ContinuationToken"] = token
1562
+ resp = client.list_objects_v2(**kw)
1563
+ for obj in resp.get("Contents", []) or []:
1564
+ if not obj["Key"].endswith(".jsonl"):
1565
+ continue
1566
+ body = client.get_object(Bucket=bucket, Key=obj["Key"])["Body"].read()
1567
+ for line in body.decode("utf-8", "replace").splitlines():
1568
+ line = line.strip()
1569
+ if line:
1570
+ try:
1571
+ out.append(json.loads(line))
1572
+ except json.JSONDecodeError:
1573
+ pass
1574
+ if resp.get("IsTruncated"):
1575
+ token = resp.get("NextContinuationToken")
1576
+ else:
1577
+ break
1578
+ return out
1579
+
1241
1580
  def _prior_email_preview() -> bool:
1242
1581
  """True if an earlier assistant turn showed a send_email preview awaiting confirm."""
1243
1582
  for m in reversed(messages[:last_user_idx]):
@@ -1384,7 +1723,25 @@ def run_agent_chat(cfg):
1384
1723
  if _EMAIL_AVAILABLE:
1385
1724
  _tool_num += 1
1386
1725
  _email_num = _tool_num
1726
+ if _RAG_AVAILABLE:
1727
+ _tool_num += 5 # download_file, unzip_file, rag_index, rag_add, rag_search
1387
1728
  _tool_count_word = {6: "SIX", 7: "SEVEN", 8: "EIGHT"}.get(_tool_num, str(_tool_num))
1729
+ _rag_tool_block = (
1730
+ " KNOWLEDGE BASE (RAG) — to SUMMARIZE or ANSWER questions about a ZIP of files at a URL:\n"
1731
+ " - download_file(url) — download a file (e.g. a .zip) to this machine.\n"
1732
+ " - unzip_file(zip_path) — extract a downloaded .zip locally.\n"
1733
+ " - rag_index(path, source_url) — index the unzipped text/code files into the knowledge "
1734
+ "base (embeds to S3). source_url MUST be the ORIGINAL url the user gave.\n"
1735
+ " - rag_add(source_url, text) — add extra info the user provides to that knowledge base.\n"
1736
+ " - rag_search(source_url, query) — retrieve the most relevant chunks to answer a question.\n"
1737
+ ) if _RAG_AVAILABLE else ""
1738
+ _rag_choose_line = (
1739
+ "- user gives a URL to a ZIP of files and asks to summarize/analyze/RAG them -> "
1740
+ "download_file(url) THEN unzip_file(zip) THEN rag_index(dir, source_url=url) THEN "
1741
+ "rag_search(source_url=url, query=<the user's question>), THEN answer from the results. "
1742
+ "For FOLLOW-UP questions about the SAME url, just rag_search (do NOT re-download). "
1743
+ "If the user adds information, rag_add(source_url=url, text=...).\n"
1744
+ ) if _RAG_AVAILABLE else ""
1388
1745
  _pdf_read_tool_line = (
1389
1746
  f" {_pdf_num}. extract_text_from_pdf(url) — READ/summarize an EXISTING PDF file "
1390
1747
  "at a URL. This does NOT create a PDF (use create_pdf for that).\n"
@@ -1421,7 +1778,7 @@ def run_agent_chat(cfg):
1421
1778
  "Use this ONLY when the task explicitly asks for the date or time.\n"
1422
1779
  " 6. create_pdf(text, title='') — make a PDF document from text/data and return a "
1423
1780
  "download link. Use ONLY when the task asks to create/make/generate/export a PDF.\n"
1424
- + _pdf_read_tool_line + _email_tool_line +
1781
+ + _pdf_read_tool_line + _email_tool_line + _rag_tool_block +
1425
1782
  "\n"
1426
1783
  "http_request RETURN FORMAT: 'HTTP 200\\n{body}' — first line is 'HTTP <code>', body follows.\n"
1427
1784
  "\n"
@@ -1445,7 +1802,7 @@ def run_agent_chat(cfg):
1445
1802
  "- a PDF of info you must LOOK UP first (e.g. 'get the world cup schedule then make a "
1446
1803
  "pdf') -> FIRST web_search/fetch_url to gather the real facts, THEN in a later step "
1447
1804
  "create_pdf(text=<the gathered facts>, title=...). Never PDF the request wording itself.\n"
1448
- + _pdf_read_choose_line + _email_choose_line +
1805
+ + _pdf_read_choose_line + _email_choose_line + _rag_choose_line +
1449
1806
  "- A specific known API/URL was given -> http_request().\n"
1450
1807
  "\n"
1451
1808
  "RULES:\n"
@@ -2275,14 +2632,160 @@ def run_agent_chat(cfg):
2275
2632
  client_kwargs={"timeout": 600.0, "max_retries": 0},
2276
2633
  **extra_model_kwargs,
2277
2634
  )
2635
+ @tool
2636
+ def download_file(url: str, dest_path: str = "") -> str:
2637
+ """Download a file (e.g. a .zip) from a public http(s) URL to a local path on this machine. Use this FIRST when the user gives a URL to a zip of files; then unzip_file, then rag_index.
2638
+
2639
+ Args:
2640
+ url: public http(s) URL of the file to download.
2641
+ dest_path: optional local save path; leave empty for an automatic work-dir path.
2642
+ """
2643
+ _emit({"type": "step", "text": f"Downloading → {url[:80]}"})
2644
+ try:
2645
+ import os
2646
+ path = _rag_download(url, (dest_path or "").strip())
2647
+ return f"Downloaded to {path} ({os.path.getsize(path)} bytes). Next: unzip_file(zip_path='{path}') if it is a zip."
2648
+ except Exception as e: # noqa: BLE001
2649
+ return f"Error: download failed: {type(e).__name__}: {e}"
2650
+
2651
+ @tool
2652
+ def unzip_file(zip_path: str, dest_dir: str = "") -> str:
2653
+ """Extract a local .zip file into a local directory. Use after download_file. Returns the directory and a short file listing.
2654
+
2655
+ Args:
2656
+ zip_path: local path to the .zip (from download_file).
2657
+ dest_dir: optional target directory; leave empty for an automatic one.
2658
+ """
2659
+ _emit({"type": "step", "text": "Unzipping…"})
2660
+ try:
2661
+ d, names = _rag_unzip((zip_path or "").strip(), (dest_dir or "").strip())
2662
+ preview = ", ".join(names[:15])
2663
+ more = f" …(+{len(names) - 15} more)" if len(names) > 15 else ""
2664
+ return f"Unzipped {len(names)} files to {d}. Files: {preview}{more}. Next: rag_index(path='{d}', source_url=<the original url>)."
2665
+ except Exception as e: # noqa: BLE001
2666
+ return f"Error: unzip failed: {type(e).__name__}: {e}"
2667
+
2668
+ @tool
2669
+ def rag_index(path: str, source_url: str) -> str:
2670
+ """Index a local file or directory of TEXT/code files into the knowledge base for source_url (stores embeddings on S3). Use after unzip_file so the files become searchable. source_url MUST be the original URL the user provided.
2671
+
2672
+ Args:
2673
+ path: local file or directory to index (from unzip_file).
2674
+ source_url: the original URL the user gave — used as the knowledge-base key.
2675
+ """
2676
+ if not _RAG_AVAILABLE:
2677
+ return "Error: RAG is not configured (enable it + add AWS credentials in Settings)."
2678
+ _emit({"type": "step", "text": f"RAG source → {source_url}"})
2679
+ _emit({"type": "step", "text": "Indexing files for RAG…"})
2680
+ try:
2681
+ import time as _t
2682
+ records = []
2683
+ files = 0
2684
+ for abs_path, rel, ext in _rag_iter_text_files((path or "").strip()):
2685
+ try:
2686
+ with open(abs_path, "r", encoding="utf-8", errors="replace") as fh:
2687
+ text = fh.read()
2688
+ except OSError:
2689
+ continue
2690
+ files += 1
2691
+ records.extend(_rag_chunk_text(text, rel, ext))
2692
+ if not records:
2693
+ return "No indexable text files found at that path."
2694
+ for i in range(0, len(records), 64):
2695
+ batch = records[i:i + 64]
2696
+ vecs = _embed(rag_embed_base, rag_embed_model, [r["text"] for r in batch])
2697
+ for r, v in zip(batch, vecs):
2698
+ r["embedding"] = v
2699
+ _emit({"type": "step", "text": f"Embedded {min(i + 64, len(records))}/{len(records)} chunks…"})
2700
+ client, bucket, prefix = _rag_s3_and_loc()
2701
+ base = _rag_prefix_for(source_url, prefix)
2702
+ shard = f"{base}/chunks-{int(_t.time())}.jsonl"
2703
+ body = "\n".join(json.dumps(r, ensure_ascii=False) for r in records).encode("utf-8")
2704
+ client.put_object(Bucket=bucket, Key=shard, Body=body, ContentType="application/x-ndjson")
2705
+ manifest = {
2706
+ "sourceUrl": source_url, "embedModel": rag_embed_model,
2707
+ "dims": len(records[0].get("embedding") or []),
2708
+ "files": files, "chunks": len(records),
2709
+ "updatedAt": _t.strftime("%Y-%m-%dT%H:%M:%SZ", _t.gmtime()),
2710
+ }
2711
+ client.put_object(Bucket=bucket, Key=f"{base}/manifest.json",
2712
+ Body=json.dumps(manifest).encode("utf-8"),
2713
+ ContentType="application/json")
2714
+ return (f"Indexed {files} files into {len(records)} chunks for {source_url}. "
2715
+ f"Use rag_search(source_url='{source_url}', query=...) to retrieve, then answer the user.")
2716
+ except Exception as e: # noqa: BLE001
2717
+ return f"Error: indexing failed: {type(e).__name__}: {e}"
2718
+
2719
+ @tool
2720
+ def rag_add(source_url: str, text: str) -> str:
2721
+ """Add extra text/notes to an EXISTING knowledge base (keyed by source_url). Use when the user gives more information to remember for later questions.
2722
+
2723
+ Args:
2724
+ source_url: the knowledge-base key (the original URL).
2725
+ text: the new information to embed and store.
2726
+ """
2727
+ if not _RAG_AVAILABLE:
2728
+ return "Error: RAG is not configured."
2729
+ if not (text or "").strip():
2730
+ return "Error: no text to add."
2731
+ _emit({"type": "step", "text": f"RAG source → {source_url}"})
2732
+ _emit({"type": "step", "text": "Adding info to RAG…"})
2733
+ try:
2734
+ import time as _t
2735
+ records = _rag_chunk_text(text, "user-note", ".txt")
2736
+ vecs = _embed(rag_embed_base, rag_embed_model, [r["text"] for r in records])
2737
+ for r, v in zip(records, vecs):
2738
+ r["embedding"] = v
2739
+ client, bucket, prefix = _rag_s3_and_loc()
2740
+ base = _rag_prefix_for(source_url, prefix)
2741
+ shard = f"{base}/chunks-add-{int(_t.time())}.jsonl"
2742
+ body = "\n".join(json.dumps(r, ensure_ascii=False) for r in records).encode("utf-8")
2743
+ client.put_object(Bucket=bucket, Key=shard, Body=body, ContentType="application/x-ndjson")
2744
+ return f"Added {len(records)} chunk(s) to the knowledge base for {source_url}."
2745
+ except Exception as e: # noqa: BLE001
2746
+ return f"Error: rag_add failed: {type(e).__name__}: {e}"
2747
+
2748
+ @tool
2749
+ def rag_search(source_url: str, query: str, k: int = 0) -> str:
2750
+ """Retrieve the most relevant chunks from the knowledge base for source_url. Use to ANSWER questions about the indexed files; write your answer from the returned chunks.
2751
+
2752
+ Args:
2753
+ source_url: the knowledge-base key (the original URL).
2754
+ query: what to look for.
2755
+ k: number of chunks to return (0 = configured default).
2756
+ """
2757
+ if not _RAG_AVAILABLE:
2758
+ return "Error: RAG is not configured."
2759
+ _emit({"type": "step", "text": f"RAG source → {source_url}"})
2760
+ _emit({"type": "step", "text": f"Searching RAG → {query[:60]}"})
2761
+ try:
2762
+ client, bucket, prefix = _rag_s3_and_loc()
2763
+ base = _rag_prefix_for(source_url, prefix)
2764
+ chunks = _rag_load_chunks(client, bucket, base)
2765
+ if not chunks:
2766
+ return f"No knowledge base found for {source_url}. Run rag_index first."
2767
+ qvec = _embed(rag_embed_base, rag_embed_model, [query])[0]
2768
+ scored = sorted(
2769
+ ((_cosine(qvec, c.get("embedding") or []), c) for c in chunks),
2770
+ key=lambda x: x[0], reverse=True,
2771
+ )
2772
+ topk = scored[: (k if k and k > 0 else rag_top_k)]
2773
+ parts = [f"[{c.get('file', '?')}] (score {score:.2f})\n{c.get('text', '')}"
2774
+ for score, c in topk]
2775
+ return "\n\n---\n\n".join(parts) if parts else "No matches."
2776
+ except Exception as e: # noqa: BLE001
2777
+ return f"Error: rag_search failed: {type(e).__name__}: {e}"
2778
+
2278
2779
  agent_tools = [http_request, web_search, fetch_url, calculate,
2279
2780
  get_current_datetime, create_pdf]
2280
- # Only register the PDF reader / email tools when available (kept in sync with
2281
- # the gated hint entries above so the model never sees a tool it can't run).
2781
+ # Only register the PDF reader / email / RAG tools when available (kept in sync
2782
+ # with the gated hint entries above so the model never sees a tool it can't run).
2282
2783
  if _PDF_READ_AVAILABLE:
2283
2784
  agent_tools.append(extract_text_from_pdf)
2284
2785
  if _EMAIL_AVAILABLE:
2285
2786
  agent_tools.append(send_email)
2787
+ if _RAG_AVAILABLE:
2788
+ agent_tools += [download_file, unzip_file, rag_index, rag_add, rag_search]
2286
2789
  agent_kwargs = dict(
2287
2790
  tools=agent_tools,
2288
2791
  model=model,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tiens.nguyen/gonext-local-worker",
3
- "version": "1.0.155",
3
+ "version": "1.0.158",
4
4
  "description": "Polls GoNext cloud API for async local LLM jobs and runs them against Ollama/OpenAI-compatible servers on this Mac",
5
5
  "type": "module",
6
6
  "license": "MIT",