differix 0.1.0a1__py3-none-any.whl
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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
tools/web_search.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
"""LLM-independent web search engine and content retrieval module.
|
|
2
|
+
|
|
3
|
+
Provides zero-dependency web search snippet retrieval, full URL content extraction
|
|
4
|
+
with HTML sanitization, and SVG/media asset discovery using standard library utilities.
|
|
5
|
+
|
|
6
|
+
SSRF hardening: this module fetches arbitrary URLs (search results can point anywhere,
|
|
7
|
+
including URLs an attacker controls via a poisoned page or prompt injection). Before
|
|
8
|
+
opening any connection, `_is_url_safe` rejects non-http(s) schemes and any hostname
|
|
9
|
+
that resolves to a private/loopback/link-local/reserved IP (127.0.0.1, internal LAN
|
|
10
|
+
ranges, cloud metadata endpoints like 169.254.169.254, etc.). Redirects are re-validated
|
|
11
|
+
per hop through a custom opener, and response bodies are capped in size.
|
|
12
|
+
|
|
13
|
+
Residual limitation: validation happens at request time using the IP(s) the hostname
|
|
14
|
+
resolves to *then*; a DNS-rebinding attacker who controls their own domain could in
|
|
15
|
+
theory change the DNS answer between our check and the actual connect. Full protection
|
|
16
|
+
against that requires pinning the validated IP for the actual socket connect (bypassing
|
|
17
|
+
urllib's built-in re-resolution), which is a larger change - flagging this here as a
|
|
18
|
+
known gap rather than silently leaving it undocumented.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import html
|
|
24
|
+
import ipaddress
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
import socket
|
|
29
|
+
import urllib.error
|
|
30
|
+
import urllib.parse
|
|
31
|
+
import urllib.request
|
|
32
|
+
from html.parser import HTMLParser
|
|
33
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
DEFAULT_USER_AGENT = (
|
|
38
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
39
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
40
|
+
"Chrome/115.0.0.0 Safari/537.36"
|
|
41
|
+
)
|
|
42
|
+
DEFAULT_TIMEOUT = 10.0
|
|
43
|
+
|
|
44
|
+
ALLOWED_SCHEMES = {"http", "https"}
|
|
45
|
+
MAX_CONTENT_BYTES = 2_000_000 # 2 MB cap per fetched page - avoids unbounded memory use
|
|
46
|
+
MAX_REDIRECTS = 3
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_ip_blocked(ip_str: str) -> bool:
|
|
50
|
+
try:
|
|
51
|
+
ip = ipaddress.ip_address(ip_str)
|
|
52
|
+
except ValueError:
|
|
53
|
+
# Can't parse it - fail closed rather than let something unrecognized through.
|
|
54
|
+
return True
|
|
55
|
+
return (
|
|
56
|
+
ip.is_private
|
|
57
|
+
or ip.is_loopback
|
|
58
|
+
or ip.is_link_local
|
|
59
|
+
or ip.is_reserved
|
|
60
|
+
or ip.is_multicast
|
|
61
|
+
or ip.is_unspecified
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _is_url_safe(url: str) -> Tuple[bool, str]:
|
|
66
|
+
"""Blocks SSRF: only http/https schemes, and only hosts that resolve
|
|
67
|
+
exclusively to public IPs (no localhost, no internal LAN, no cloud
|
|
68
|
+
metadata endpoints)."""
|
|
69
|
+
try:
|
|
70
|
+
parsed = urllib.parse.urlparse(url)
|
|
71
|
+
except Exception as e:
|
|
72
|
+
return False, f"Could not parse URL: {e}"
|
|
73
|
+
|
|
74
|
+
if parsed.scheme.lower() not in ALLOWED_SCHEMES:
|
|
75
|
+
return False, f"Blocked scheme '{parsed.scheme}' (only http/https allowed)"
|
|
76
|
+
|
|
77
|
+
hostname = parsed.hostname
|
|
78
|
+
if not hostname:
|
|
79
|
+
return False, "URL has no hostname"
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
addr_infos = socket.getaddrinfo(hostname, None)
|
|
83
|
+
except socket.gaierror as e:
|
|
84
|
+
return False, f"DNS resolution failed: {e}"
|
|
85
|
+
except Exception as e:
|
|
86
|
+
return False, f"DNS resolution error: {e}"
|
|
87
|
+
|
|
88
|
+
resolved_ips = {info[4][0] for info in addr_infos}
|
|
89
|
+
if not resolved_ips:
|
|
90
|
+
return False, "No IP addresses resolved for host"
|
|
91
|
+
|
|
92
|
+
for ip_str in resolved_ips:
|
|
93
|
+
if _is_ip_blocked(ip_str):
|
|
94
|
+
return False, f"Host resolves to a blocked/internal address ({ip_str})"
|
|
95
|
+
|
|
96
|
+
return True, "ok"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class _SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
100
|
+
"""Re-validates every redirect hop instead of blindly following it.
|
|
101
|
+
Without this, an initial URL can pass validation and then 302 to an
|
|
102
|
+
internal address, bypassing the check entirely."""
|
|
103
|
+
|
|
104
|
+
max_redirections = MAX_REDIRECTS
|
|
105
|
+
|
|
106
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
|
107
|
+
safe, reason = _is_url_safe(newurl)
|
|
108
|
+
if not safe:
|
|
109
|
+
raise urllib.error.URLError(f"Blocked unsafe redirect to {newurl!r}: {reason}")
|
|
110
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _build_safe_opener() -> urllib.request.OpenerDirector:
|
|
114
|
+
return urllib.request.build_opener(_SafeRedirectHandler)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _read_capped(response, max_bytes: int = MAX_CONTENT_BYTES) -> bytes:
|
|
118
|
+
"""Reads a response body up to max_bytes, ignoring whatever Content-Length claims."""
|
|
119
|
+
chunks = []
|
|
120
|
+
total = 0
|
|
121
|
+
while total < max_bytes:
|
|
122
|
+
chunk = response.read(65536)
|
|
123
|
+
if not chunk:
|
|
124
|
+
break
|
|
125
|
+
chunks.append(chunk)
|
|
126
|
+
total += len(chunk)
|
|
127
|
+
return b"".join(chunks)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class _HTMLTextExtractor(HTMLParser):
|
|
131
|
+
"""HTML Parser that strips script/style tags and extracts clean text and links."""
|
|
132
|
+
|
|
133
|
+
def __init__(self) -> None:
|
|
134
|
+
super().__init__()
|
|
135
|
+
self.result: List[str] = []
|
|
136
|
+
self.title: str = ""
|
|
137
|
+
self._in_title = False
|
|
138
|
+
self._skip_depth = 0
|
|
139
|
+
self.links: List[Dict[str, str]] = []
|
|
140
|
+
self.svgs: List[str] = []
|
|
141
|
+
self.images: List[str] = []
|
|
142
|
+
self._current_svg: Optional[List[str]] = None
|
|
143
|
+
|
|
144
|
+
def handle_starttag(self, tag: str, attrs: List[tuple[str, Optional[str]]]) -> None:
|
|
145
|
+
tag_lower = tag.lower()
|
|
146
|
+
attr_dict = {k.lower(): (v or "") for k, v in attrs}
|
|
147
|
+
|
|
148
|
+
if tag_lower in ("script", "style", "noscript", "svg"):
|
|
149
|
+
if tag_lower == "svg":
|
|
150
|
+
self._current_svg = [f"<{tag}"]
|
|
151
|
+
for k, v in attrs:
|
|
152
|
+
self._current_svg.append(f' {k}="{html.escape(v or "")}"')
|
|
153
|
+
self._current_svg.append(">")
|
|
154
|
+
self._skip_depth += 1
|
|
155
|
+
|
|
156
|
+
if tag_lower == "title":
|
|
157
|
+
self._in_title = True
|
|
158
|
+
|
|
159
|
+
if tag_lower == "a" and "href" in attr_dict:
|
|
160
|
+
href = attr_dict["href"]
|
|
161
|
+
self.links.append({"href": href, "text": ""})
|
|
162
|
+
|
|
163
|
+
if tag_lower == "img" and "src" in attr_dict:
|
|
164
|
+
self.images.append(attr_dict["src"])
|
|
165
|
+
|
|
166
|
+
def handle_endtag(self, tag: str) -> None:
|
|
167
|
+
tag_lower = tag.lower()
|
|
168
|
+
if tag_lower == "title":
|
|
169
|
+
self._in_title = False
|
|
170
|
+
|
|
171
|
+
if tag_lower == "svg" and self._current_svg is not None:
|
|
172
|
+
self._current_svg.append("</svg>")
|
|
173
|
+
self.svgs.append("".join(self._current_svg))
|
|
174
|
+
self._current_svg = None
|
|
175
|
+
|
|
176
|
+
if tag_lower in ("script", "style", "noscript", "svg"):
|
|
177
|
+
if self._skip_depth > 0:
|
|
178
|
+
self._skip_depth -= 1
|
|
179
|
+
|
|
180
|
+
def handle_data(self, data: str) -> None:
|
|
181
|
+
if self._in_title:
|
|
182
|
+
self.title += data
|
|
183
|
+
|
|
184
|
+
if self._current_svg is not None:
|
|
185
|
+
self._current_svg.append(data)
|
|
186
|
+
|
|
187
|
+
if self._skip_depth == 0:
|
|
188
|
+
cleaned = data.strip()
|
|
189
|
+
if cleaned:
|
|
190
|
+
self.result.append(cleaned)
|
|
191
|
+
if self.links:
|
|
192
|
+
self.links[-1]["text"] = (self.links[-1]["text"] + " " + cleaned).strip()
|
|
193
|
+
|
|
194
|
+
def get_text(self) -> str:
|
|
195
|
+
return " ".join(self.result)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class WebSearchEngine:
|
|
199
|
+
"""Zero-dependency search engine and content extractor."""
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
user_agent: str = DEFAULT_USER_AGENT,
|
|
204
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
205
|
+
) -> None:
|
|
206
|
+
self.user_agent = user_agent
|
|
207
|
+
self.timeout = timeout
|
|
208
|
+
self._opener = _build_safe_opener()
|
|
209
|
+
|
|
210
|
+
def _build_request(self, url: str) -> urllib.request.Request:
|
|
211
|
+
headers = {
|
|
212
|
+
"User-Agent": self.user_agent,
|
|
213
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
214
|
+
"Accept-Language": "en-US,en;q=0.9",
|
|
215
|
+
}
|
|
216
|
+
return urllib.request.Request(url, headers=headers)
|
|
217
|
+
|
|
218
|
+
def search(self, query: str, num_results: int = 5) -> List[Dict[str, str]]:
|
|
219
|
+
"""Search the web for snippets and URLs matching the query."""
|
|
220
|
+
results: List[Dict[str, str]] = []
|
|
221
|
+
encoded_query = urllib.parse.quote_plus(query)
|
|
222
|
+
search_url = f"https://html.duckduckgo.com/html/?q={encoded_query}"
|
|
223
|
+
|
|
224
|
+
safe, reason = _is_url_safe(search_url)
|
|
225
|
+
if not safe:
|
|
226
|
+
logger.error(f"Search URL blocked ({reason}): {search_url}")
|
|
227
|
+
return []
|
|
228
|
+
|
|
229
|
+
try:
|
|
230
|
+
req = self._build_request(search_url)
|
|
231
|
+
with self._opener.open(req, timeout=self.timeout) as response:
|
|
232
|
+
content = _read_capped(response).decode("utf-8", errors="replace")
|
|
233
|
+
|
|
234
|
+
matches = re.findall(
|
|
235
|
+
r'<a[^>]+class="result__url"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)</a>[\s\S]*?'
|
|
236
|
+
r'<a[^>]+class="result__snippet"[^>]*>([\s\S]*?)</a>',
|
|
237
|
+
content,
|
|
238
|
+
flags=re.IGNORECASE,
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
for href, title, snippet in matches[:num_results]:
|
|
242
|
+
clean_title = re.sub(r"<[^>]+>", "", title).strip()
|
|
243
|
+
clean_snippet = re.sub(r"<[^>]+>", "", snippet).strip()
|
|
244
|
+
parsed_href = href
|
|
245
|
+
if "uddg=" in href:
|
|
246
|
+
parsed_qs = urllib.parse.parse_qs(urllib.parse.urlparse(href).query)
|
|
247
|
+
if "uddg" in parsed_qs:
|
|
248
|
+
parsed_href = parsed_qs["uddg"][0]
|
|
249
|
+
|
|
250
|
+
results.append(
|
|
251
|
+
{
|
|
252
|
+
"title": html.unescape(clean_title),
|
|
253
|
+
"url": parsed_href,
|
|
254
|
+
"snippet": html.unescape(clean_snippet),
|
|
255
|
+
}
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
except Exception as e:
|
|
259
|
+
logger.warning(f"Web search for '{query}' encountered error: {e}")
|
|
260
|
+
return []
|
|
261
|
+
|
|
262
|
+
if not results:
|
|
263
|
+
logger.info(f"Web search for '{query}' returned zero parsed results.")
|
|
264
|
+
|
|
265
|
+
return results[:num_results]
|
|
266
|
+
|
|
267
|
+
def search_snippets(self, query: str, num_results: int = 5) -> List[Dict[str, str]]:
|
|
268
|
+
"""Alias for standard search returning snippet items."""
|
|
269
|
+
return self.search(query, num_results=num_results)
|
|
270
|
+
|
|
271
|
+
def fetch_content(self, url: str) -> Dict[str, Any]:
|
|
272
|
+
"""Fetch full URL content and sanitize HTML tags into plain text."""
|
|
273
|
+
safe, reason = _is_url_safe(url)
|
|
274
|
+
if not safe:
|
|
275
|
+
logger.warning(f"Blocked fetch of unsafe URL {url!r}: {reason}")
|
|
276
|
+
return {
|
|
277
|
+
"url": url,
|
|
278
|
+
"status": 403,
|
|
279
|
+
"title": "Blocked",
|
|
280
|
+
"content": "",
|
|
281
|
+
"error": f"URL blocked by SSRF guard: {reason}",
|
|
282
|
+
"raw_html": "",
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
req = self._build_request(url)
|
|
287
|
+
with self._opener.open(req, timeout=self.timeout) as response:
|
|
288
|
+
status_code = response.getcode()
|
|
289
|
+
raw_bytes = _read_capped(response)
|
|
290
|
+
raw_html = raw_bytes.decode("utf-8", errors="replace")
|
|
291
|
+
|
|
292
|
+
extractor = _HTMLTextExtractor()
|
|
293
|
+
extractor.feed(raw_html)
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
"url": url,
|
|
297
|
+
"status": status_code,
|
|
298
|
+
"title": html.unescape(extractor.title.strip()),
|
|
299
|
+
"content": extractor.get_text(),
|
|
300
|
+
"raw_html": raw_html,
|
|
301
|
+
}
|
|
302
|
+
except Exception as e:
|
|
303
|
+
logger.error(f"Failed to fetch content from {url}: {e}")
|
|
304
|
+
return {
|
|
305
|
+
"url": url,
|
|
306
|
+
"status": 500,
|
|
307
|
+
"title": "Error",
|
|
308
|
+
"content": "",
|
|
309
|
+
"error": str(e),
|
|
310
|
+
"raw_html": "",
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
def extract_media(self, url: str) -> Dict[str, Any]:
|
|
314
|
+
"""Extract SVG assets, image links, and hyperlinks from a webpage URL."""
|
|
315
|
+
content_res = self.fetch_content(url)
|
|
316
|
+
raw_html = content_res.get("raw_html", "")
|
|
317
|
+
|
|
318
|
+
extractor = _HTMLTextExtractor()
|
|
319
|
+
if raw_html:
|
|
320
|
+
extractor.feed(raw_html)
|
|
321
|
+
|
|
322
|
+
inline_svgs = re.findall(r"(<svg[\s\S]*?</svg>)", raw_html, flags=re.IGNORECASE)
|
|
323
|
+
all_svgs = list(dict.fromkeys(extractor.svgs + inline_svgs))
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
"url": url,
|
|
327
|
+
"title": content_res.get("title", ""),
|
|
328
|
+
"svgs": all_svgs,
|
|
329
|
+
"images": list(dict.fromkeys(extractor.images)),
|
|
330
|
+
"links": extractor.links,
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
def search_media(
|
|
334
|
+
self, query: str, media_type: str = "svg", num_results: int = 5
|
|
335
|
+
) -> List[Dict[str, Any]]:
|
|
336
|
+
"""Search query and extract media assets from resulting top web pages."""
|
|
337
|
+
search_results = self.search(f"{query} {media_type}", num_results=num_results)
|
|
338
|
+
media_results = []
|
|
339
|
+
|
|
340
|
+
for item in search_results:
|
|
341
|
+
page_url = item.get("url", "")
|
|
342
|
+
if not page_url or not page_url.startswith("http"):
|
|
343
|
+
continue
|
|
344
|
+
media_data = self.extract_media(page_url)
|
|
345
|
+
media_results.append(
|
|
346
|
+
{
|
|
347
|
+
"query": query,
|
|
348
|
+
"url": page_url,
|
|
349
|
+
"title": item.get("title", ""),
|
|
350
|
+
"snippet": item.get("snippet", ""),
|
|
351
|
+
"svgs": media_data.get("svgs", []),
|
|
352
|
+
"images": media_data.get("images", []),
|
|
353
|
+
}
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
return media_results
|
|
357
|
+
|
|
358
|
+
def search_web(query: str, num_results: int = 5) -> List[Dict[str, str]]:
|
|
359
|
+
engine = WebSearchEngine()
|
|
360
|
+
return engine.search(query, num_results=num_results)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def fetch_page(url: str) -> Dict[str, Any]:
|
|
364
|
+
engine = WebSearchEngine()
|
|
365
|
+
return engine.fetch_content(url)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def extract_svg_assets(html_content: str) -> List[str]:
|
|
369
|
+
"""Extract all inline SVG tags from raw HTML content."""
|
|
370
|
+
inline_svgs = re.findall(r"(<svg[\s\S]*?</svg>)", html_content, flags=re.IGNORECASE)
|
|
371
|
+
return list(dict.fromkeys(inline_svgs))
|