drydock-cli 3.0.49__tar.gz → 3.0.50__tar.gz

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 (67) hide show
  1. {drydock_cli-3.0.49/drydock_cli.egg-info → drydock_cli-3.0.50}/PKG-INFO +1 -1
  2. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/tools/__init__.py +77 -2
  3. drydock_cli-3.0.50/drydock/web.py +98 -0
  4. {drydock_cli-3.0.49 → drydock_cli-3.0.50/drydock_cli.egg-info}/PKG-INFO +1 -1
  5. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock_cli.egg-info/SOURCES.txt +3 -1
  6. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/pyproject.toml +1 -1
  7. drydock_cli-3.0.50/tests/test_web_tools.py +72 -0
  8. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/LICENSE +0 -0
  9. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/NOTICE +0 -0
  10. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/README.md +0 -0
  11. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/__init__.py +0 -0
  12. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/__main__.py +0 -0
  13. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/agent.py +0 -0
  14. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/bash_safety.py +0 -0
  15. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/cli.py +0 -0
  16. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/compaction.py +0 -0
  17. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/config.py +0 -0
  18. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/detect.py +0 -0
  19. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/graphrag.py +0 -0
  20. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/guards.py +0 -0
  21. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/loop_detect.py +0 -0
  22. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/providers.py +0 -0
  23. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/tool_registry.py +0 -0
  24. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/tui/__init__.py +0 -0
  25. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/tui/app.py +0 -0
  26. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/tui/approval.py +0 -0
  27. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/tui/messages.py +0 -0
  28. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/tui/widgets.py +0 -0
  29. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock/tuning.py +0 -0
  30. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock_cli.egg-info/dependency_links.txt +0 -0
  31. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock_cli.egg-info/entry_points.txt +0 -0
  32. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock_cli.egg-info/requires.txt +0 -0
  33. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/drydock_cli.egg-info/top_level.txt +0 -0
  34. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/setup.cfg +0 -0
  35. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_approval.py +0 -0
  36. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_back_command.py +0 -0
  37. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_bash_process_group.py +0 -0
  38. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_bash_safety.py +0 -0
  39. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_bash_timeout_network.py +0 -0
  40. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_cli_agents.py +0 -0
  41. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_compact_command.py +0 -0
  42. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_compaction.py +0 -0
  43. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_config.py +0 -0
  44. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_config_migration.py +0 -0
  45. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_context_limit_config.py +0 -0
  46. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_detect.py +0 -0
  47. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_empty_response.py +0 -0
  48. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_failure_loop.py +0 -0
  49. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_first_run_setup.py +0 -0
  50. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_graphrag.py +0 -0
  51. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_guards_and_tools.py +0 -0
  52. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_hallucinated_tools.py +0 -0
  53. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_leaked_tool_call.py +0 -0
  54. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_loop_detect.py +0 -0
  55. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_oneshot_unreachable.py +0 -0
  56. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_plan_autocontinue.py +0 -0
  57. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_providers_unreachable.py +0 -0
  58. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_runaway_repetition.py +0 -0
  59. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_stop.py +0 -0
  60. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_streaming_newlines.py +0 -0
  61. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_subagent.py +0 -0
  62. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_todo.py +0 -0
  63. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_tool_arg_parsing.py +0 -0
  64. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_tools_undo.py +0 -0
  65. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_tui.py +0 -0
  66. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_tuning.py +0 -0
  67. {drydock_cli-3.0.49 → drydock_cli-3.0.50}/tests/test_vision_input.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.49
3
+ Version: 3.0.50
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -161,6 +161,40 @@ SCHEMAS = [
161
161
  "required": ["prompt"],
162
162
  },
163
163
  },
164
+ {
165
+ "name": "WebSearch",
166
+ "description": (
167
+ "Search the internet and get back the top results (title, URL, "
168
+ "snippet). Use it for current events, docs, library/API details, "
169
+ "error messages, or anything you're unsure about or that may have "
170
+ "changed since training. Follow up with WebFetch to read a result in "
171
+ "full. Returns a clean message if the machine is offline."
172
+ ),
173
+ "input_schema": {
174
+ "type": "object",
175
+ "properties": {
176
+ "query": {"type": "string", "description": "The search query."},
177
+ "k": {"type": "integer", "description": "Max results (default 5)."},
178
+ },
179
+ "required": ["query"],
180
+ },
181
+ },
182
+ {
183
+ "name": "WebFetch",
184
+ "description": (
185
+ "Fetch a web page (or any URL) and return its readable text content, "
186
+ "HTML stripped. Use it to read a page found via WebSearch or a URL the "
187
+ "user gave you. Returns a clean message if the machine is offline."
188
+ ),
189
+ "input_schema": {
190
+ "type": "object",
191
+ "properties": {
192
+ "url": {"type": "string", "description": "The URL to fetch."},
193
+ "max_chars": {"type": "integer", "description": "Max chars to return (default 6000)."},
194
+ },
195
+ "required": ["url"],
196
+ },
197
+ },
164
198
  {
165
199
  "name": "Knowledge",
166
200
  "description": (
@@ -735,6 +769,42 @@ def tool_task(params: dict, config: dict) -> str:
735
769
  return f"[sub-agent finished {steps} step(s) with no summary]"
736
770
 
737
771
 
772
+ def tool_websearch(params: dict, config: dict) -> str:
773
+ """Search the internet (DuckDuckGo). Read-only; clean message when offline."""
774
+ from drydock import web
775
+
776
+ query = (params.get("query") or "").strip()
777
+ if not query:
778
+ return "Error: `WebSearch` needs a `query`."
779
+ try:
780
+ k = int(params.get("k") or 5)
781
+ except (TypeError, ValueError):
782
+ k = 5
783
+ try:
784
+ results = web.search(query, k=max(1, min(k, 10)))
785
+ except web.WebError as e:
786
+ return f"Web search unavailable: {e}. You appear to be offline — answer from your own knowledge."
787
+ return web.format_search(query, results)
788
+
789
+
790
+ def tool_webfetch(params: dict, config: dict) -> str:
791
+ """Fetch a URL and return readable text. Read-only; clean message offline."""
792
+ from drydock import web
793
+
794
+ url = (params.get("url") or "").strip()
795
+ if not url:
796
+ return "Error: `WebFetch` needs a `url`."
797
+ try:
798
+ mc = int(params.get("max_chars") or 6000)
799
+ except (TypeError, ValueError):
800
+ mc = 6000
801
+ try:
802
+ text = web.fetch(url, max_chars=max(500, min(mc, 30000)))
803
+ except web.WebError as e:
804
+ return f"Could not fetch the page: {e}."
805
+ return text or f"(no readable text extracted from {url})"
806
+
807
+
738
808
  def tool_knowledge(params: dict, config: dict) -> str:
739
809
  """Query the project's GraphRAG knowledge base (built with /graphrag build).
740
810
  Read-only; returns the most relevant passages plus related graph entities.
@@ -773,6 +843,8 @@ _TOOLS = [
773
843
  ("todo", tool_todo, False),
774
844
  ("task", tool_task, True),
775
845
  ("Knowledge", tool_knowledge, True),
846
+ ("WebSearch", tool_websearch, True),
847
+ ("WebFetch", tool_webfetch, True),
776
848
  ]
777
849
 
778
850
  def register_all():
@@ -782,9 +854,12 @@ def register_all():
782
854
  "Read": tool_read, "Write": tool_write, "Edit": tool_edit,
783
855
  "Bash": tool_bash, "Glob": tool_glob, "Grep": tool_grep,
784
856
  "todo": tool_todo, "task": tool_task, "Knowledge": tool_knowledge,
857
+ "WebSearch": tool_websearch, "WebFetch": tool_webfetch,
785
858
  }[name]
786
- # task + Knowledge are read-only w.r.t. the parent's files.
787
- read_only = name in ("Read", "Glob", "Grep", "task", "Knowledge")
859
+ # These are read-only w.r.t. the parent's files.
860
+ read_only = name in (
861
+ "Read", "Glob", "Grep", "task", "Knowledge", "WebSearch", "WebFetch",
862
+ )
788
863
  register(ToolDef(name=name, schema=schema, func=func, read_only=read_only))
789
864
 
790
865
  register_all()
@@ -0,0 +1,98 @@
1
+ """Internet access — web search + page fetch, stdlib only (no deps, no API key).
2
+
3
+ Search goes through DuckDuckGo's HTML endpoint (a form POST; the GET form is
4
+ bot-challenged). Fetch pulls a page and reduces it to readable text. Both are
5
+ best-effort and degrade cleanly when offline — drydock targets local LLMs that
6
+ may run air-gapped, so a network failure returns a plain message, never raises.
7
+
8
+ All logic original to Drydock.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import html
13
+ import re
14
+ import urllib.error
15
+ import urllib.parse
16
+ import urllib.request
17
+
18
+ _UA = "Mozilla/5.0 (X11; Linux x86_64; rv:124.0) Gecko/20100101 Firefox/124.0"
19
+ _DDG = "https://html.duckduckgo.com/html/"
20
+
21
+ _RE_RESULT = re.compile(r'result__a"[^>]*href="([^"]+)".*?>(.*?)</a>', re.S)
22
+ _RE_SNIPPET = re.compile(r'result__snippet"[^>]*>(.*?)</a>', re.S)
23
+ _RE_TAG = re.compile(r"<[^>]+>")
24
+ _RE_SCRIPT = re.compile(r"<(script|style|noscript|svg)\b.*?</\1>", re.S | re.I)
25
+ _RE_WS = re.compile(r"[ \t]+")
26
+ _RE_BLANKS = re.compile(r"\n\s*\n\s*\n+")
27
+
28
+
29
+ def _strip_html(s: str) -> str:
30
+ return html.unescape(_RE_TAG.sub("", s)).strip()
31
+
32
+
33
+ class WebError(RuntimeError):
34
+ """Network/HTTP failure reaching the internet (carries a user-facing msg)."""
35
+
36
+
37
+ def search(query: str, k: int = 5, *, timeout: float = 12.0) -> list[dict]:
38
+ """Return up to k web results: [{title, url, snippet}]. Raises WebError on a
39
+ network failure so the caller can show a clean offline message."""
40
+ data = urllib.parse.urlencode({"q": query}).encode()
41
+ req = urllib.request.Request(_DDG, data=data, headers={"User-Agent": _UA})
42
+ try:
43
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (trusted)
44
+ body = r.read().decode("utf-8", "ignore")
45
+ except (urllib.error.URLError, OSError) as e:
46
+ raise WebError(f"could not reach the internet ({e})") from e
47
+
48
+ links = _RE_RESULT.findall(body)
49
+ snippets = _RE_SNIPPET.findall(body)
50
+ out: list[dict] = []
51
+ for (url, title), snip in zip(links, snippets + [""] * len(links)):
52
+ url = html.unescape(url)
53
+ if url.startswith("//"):
54
+ url = "https:" + url
55
+ # DDG sometimes wraps the target in a redirect with ?uddg=<real-url>.
56
+ m = re.search(r"[?&]uddg=([^&]+)", url)
57
+ if m:
58
+ url = urllib.parse.unquote(m.group(1))
59
+ out.append({"title": _strip_html(title), "url": url, "snippet": _strip_html(snip)})
60
+ if len(out) >= k:
61
+ break
62
+ return out
63
+
64
+
65
+ def fetch(url: str, *, max_chars: int = 6000, timeout: float = 12.0) -> str:
66
+ """Fetch a URL and return its readable text (HTML stripped). Raises WebError
67
+ on a network/HTTP failure."""
68
+ if not url.startswith(("http://", "https://")):
69
+ url = "https://" + url
70
+ req = urllib.request.Request(url, headers={"User-Agent": _UA})
71
+ try:
72
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (user-supplied URL)
73
+ ctype = r.headers.get("Content-Type", "")
74
+ raw = r.read(4_000_000) # cap the download
75
+ except (urllib.error.URLError, OSError) as e:
76
+ raise WebError(f"could not fetch {url} ({e})") from e
77
+
78
+ text = raw.decode("utf-8", "ignore")
79
+ if "html" in ctype.lower() or "<html" in text[:2000].lower():
80
+ text = _RE_SCRIPT.sub(" ", text)
81
+ text = _RE_TAG.sub(" ", text)
82
+ text = html.unescape(text)
83
+ text = _RE_WS.sub(" ", text)
84
+ text = _RE_BLANKS.sub("\n\n", "\n".join(ln.strip() for ln in text.splitlines()))
85
+ text = text.strip()
86
+ if len(text) > max_chars:
87
+ text = text[:max_chars] + f"\n[... truncated, {len(text) - max_chars} more chars ...]"
88
+ return text
89
+
90
+
91
+ def format_search(query: str, results: list[dict]) -> str:
92
+ if not results:
93
+ return f"No web results for {query!r}."
94
+ parts = [f"Web results for {query!r}:"]
95
+ for i, r in enumerate(results, 1):
96
+ parts.append(f"\n[{i}] {r['title']}\n {r['url']}\n {r['snippet']}")
97
+ parts.append("\nTo read one in full, use WebFetch with its URL.")
98
+ return "\n".join(parts)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: drydock-cli
3
- Version: 3.0.49
3
+ Version: 3.0.50
4
4
  Summary: Drydock — a local, provider-agnostic terminal coding agent for local LLMs
5
5
  Author: Frank Bobe III
6
6
  License-Expression: Apache-2.0
@@ -16,6 +16,7 @@ drydock/loop_detect.py
16
16
  drydock/providers.py
17
17
  drydock/tool_registry.py
18
18
  drydock/tuning.py
19
+ drydock/web.py
19
20
  drydock/tools/__init__.py
20
21
  drydock/tui/__init__.py
21
22
  drydock/tui/app.py
@@ -60,4 +61,5 @@ tests/test_tool_arg_parsing.py
60
61
  tests/test_tools_undo.py
61
62
  tests/test_tui.py
62
63
  tests/test_tuning.py
63
- tests/test_vision_input.py
64
+ tests/test_vision_input.py
65
+ tests/test_web_tools.py
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
  # PyPI distribution name is drydock-cli (the established install name, continued
8
8
  # from the retired v2 fork); the import package + CLI command stay `drydock`.
9
9
  name = "drydock-cli"
10
- version = "3.0.49"
10
+ version = "3.0.50"
11
11
  description = "Drydock — a local, provider-agnostic terminal coding agent for local LLMs"
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -0,0 +1,72 @@
1
+ """Internet access: WebSearch + WebFetch tools (drydock/web.py). Network is
2
+ mocked so tests are deterministic and run offline; the offline path is tested
3
+ explicitly (drydock targets local LLMs that may be air-gapped — must degrade,
4
+ never raise)."""
5
+ from __future__ import annotations
6
+
7
+
8
+ from drydock import web
9
+ from drydock.tools import tool_websearch, tool_webfetch
10
+
11
+
12
+ def test_websearch_formats_results(monkeypatch):
13
+ monkeypatch.setattr(web, "search", lambda q, k=5, **kw: [
14
+ {"title": "Python 3.13", "url": "https://docs.python.org/3/whatsnew/3.13.html",
15
+ "snippet": "new features"},
16
+ ])
17
+ out = tool_websearch({"query": "python 3.13 features"}, {})
18
+ assert "Python 3.13" in out and "docs.python.org" in out and "WebFetch" in out
19
+
20
+
21
+ def test_websearch_offline_is_graceful(monkeypatch):
22
+ def boom(*a, **k):
23
+ raise web.WebError("could not reach the internet (offline)")
24
+ monkeypatch.setattr(web, "search", boom)
25
+ out = tool_websearch({"query": "anything"}, {})
26
+ assert "unavailable" in out.lower() and "offline" in out.lower()
27
+
28
+
29
+ def test_websearch_needs_query():
30
+ assert "needs a `query`" in tool_websearch({}, {})
31
+
32
+
33
+ def test_webfetch_returns_text(monkeypatch):
34
+ monkeypatch.setattr(web, "fetch", lambda url, **kw: "Readable page text here.")
35
+ out = tool_webfetch({"url": "example.com"}, {})
36
+ assert out == "Readable page text here."
37
+
38
+
39
+ def test_webfetch_offline_is_graceful(monkeypatch):
40
+ def boom(*a, **k):
41
+ raise web.WebError("could not fetch (offline)")
42
+ monkeypatch.setattr(web, "fetch", boom)
43
+ out = tool_webfetch({"url": "https://x.com"}, {})
44
+ assert "could not fetch" in out.lower()
45
+
46
+
47
+ def test_webfetch_needs_url():
48
+ assert "needs a `url`" in tool_webfetch({}, {})
49
+
50
+
51
+ def test_strip_html_and_parsing_helpers():
52
+ # The HTML→text reducer drops tags + unescapes entities.
53
+ assert web._strip_html("<b>Hello &amp; bye</b>") == "Hello & bye"
54
+
55
+
56
+ def test_search_parses_ddg_html(monkeypatch):
57
+ sample = (
58
+ '<a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fa">'
59
+ 'Example A</a>'
60
+ '<a class="result__snippet" href="x">snippet A text</a>'
61
+ )
62
+
63
+ class _Resp:
64
+ def __enter__(self): return self
65
+ def __exit__(self, *a): return False
66
+ def read(self): return sample.encode()
67
+
68
+ monkeypatch.setattr(web.urllib.request, "urlopen", lambda *a, **k: _Resp())
69
+ res = web.search("q", k=5)
70
+ assert res and res[0]["title"] == "Example A"
71
+ assert res[0]["url"] == "https://example.com/a" # uddg redirect unwrapped
72
+ assert res[0]["snippet"] == "snippet A text"
File without changes
File without changes
File without changes
File without changes