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.
Files changed (62) hide show
  1. agents/editor.py +455 -0
  2. agents/researcher.py +405 -0
  3. agents/reviewer.py +374 -0
  4. cli/model_cli.py +430 -0
  5. config.py +557 -0
  6. differix-0.1.0a1.dist-info/METADATA +123 -0
  7. differix-0.1.0a1.dist-info/RECORD +62 -0
  8. differix-0.1.0a1.dist-info/WHEEL +5 -0
  9. differix-0.1.0a1.dist-info/entry_points.txt +2 -0
  10. differix-0.1.0a1.dist-info/top_level.txt +6 -0
  11. main.py +2796 -0
  12. memory/__init__.py +59 -0
  13. memory/embeddings/__init__.py +29 -0
  14. memory/embeddings/factory.py +178 -0
  15. memory/embeddings/providers.py +259 -0
  16. memory/manager.py +186 -0
  17. memory/migrator.py +253 -0
  18. memory/ranker.py +203 -0
  19. memory/schemas.py +127 -0
  20. memory/storage.py +552 -0
  21. tools/agents_chat.py +152 -0
  22. tools/asset_extractor.py +545 -0
  23. tools/asset_manager.py +115 -0
  24. tools/atomic_changeset.py +264 -0
  25. tools/backup_manager.py +122 -0
  26. tools/changelog_engine.py +261 -0
  27. tools/coherence_checker.py +771 -0
  28. tools/compiler.py +531 -0
  29. tools/component_normalizer.py +130 -0
  30. tools/context_assembler.py +631 -0
  31. tools/contract_validator.py +478 -0
  32. tools/cost_predictor.py +141 -0
  33. tools/drift_guard.py +196 -0
  34. tools/file_manager.py +119 -0
  35. tools/handshake.py +351 -0
  36. tools/lesson_manager.py +570 -0
  37. tools/lesson_memory_bridge.py +398 -0
  38. tools/llm_client.py +103 -0
  39. tools/llm_selector.py +362 -0
  40. tools/model_ranker.py +535 -0
  41. tools/palette_extractor.py +1043 -0
  42. tools/plugin_manager.py +204 -0
  43. tools/project_context_gatherer.py +404 -0
  44. tools/project_scanner.py +41 -0
  45. tools/project_structure.py +244 -0
  46. tools/reasoning_logger.py +78 -0
  47. tools/reflection.py +452 -0
  48. tools/sandbox_utils.py +56 -0
  49. tools/security/__init__.py +0 -0
  50. tools/security/behavior_guard.py +83 -0
  51. tools/security/verification_firewall.py +113 -0
  52. tools/security_filter.py +213 -0
  53. tools/session_adr.py +311 -0
  54. tools/skill_manager.py +129 -0
  55. tools/state_manager.py +383 -0
  56. tools/structure_index.py +570 -0
  57. tools/test_schema_consistency.py +19 -0
  58. tools/verification_firewall.py +113 -0
  59. tools/visual_capture.py +65 -0
  60. tools/visual_diff.py +220 -0
  61. tools/visual_reviewer.py +79 -0
  62. 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))