comodor 0.2.2__tar.gz → 0.3.0__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 (115) hide show
  1. {comodor-0.2.2 → comodor-0.3.0}/PKG-INFO +49 -21
  2. {comodor-0.2.2 → comodor-0.3.0}/README.md +48 -20
  3. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/_version.py +2 -2
  4. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/cli.py +16 -0
  5. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/config.py +3 -0
  6. comodor-0.3.0/src/comodor/tools/browser.py +398 -0
  7. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/registry.py +19 -1
  8. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/app.py +1 -0
  9. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/uninstall.py +80 -18
  10. comodor-0.3.0/src/comodor/workspace.py +127 -0
  11. comodor-0.3.0/tests/test_browser.py +320 -0
  12. {comodor-0.2.2 → comodor-0.3.0}/tests/test_uninstall.py +39 -1
  13. {comodor-0.2.2 → comodor-0.3.0}/.gitignore +0 -0
  14. {comodor-0.2.2 → comodor-0.3.0}/LICENSE +0 -0
  15. {comodor-0.2.2 → comodor-0.3.0}/pyproject.toml +0 -0
  16. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/__init__.py +0 -0
  17. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/__main__.py +0 -0
  18. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/agent/__init__.py +0 -0
  19. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/agent/context.py +0 -0
  20. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/agent/loop.py +0 -0
  21. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/agent/prompts.py +0 -0
  22. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/agent/tokens.py +0 -0
  23. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/catalogue.py +0 -0
  24. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/doctor.py +0 -0
  25. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/events.py +0 -0
  26. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/__init__.py +0 -0
  27. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/bm25.py +0 -0
  28. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/hotindex.py +0 -0
  29. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/memory.py +0 -0
  30. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/progress.py +0 -0
  31. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/reflect.py +0 -0
  32. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/rules.py +0 -0
  33. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/signals.py +0 -0
  34. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/store.py +0 -0
  35. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/learning/writer.py +0 -0
  36. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/mcp/__init__.py +0 -0
  37. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/mcp/catalogue.py +0 -0
  38. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/mcp/commands.py +0 -0
  39. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/mcp/manager.py +0 -0
  40. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/mcp/protocol.py +0 -0
  41. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/net/__init__.py +0 -0
  42. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/net/http.py +0 -0
  43. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/net/sse.py +0 -0
  44. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/paths.py +0 -0
  45. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/__init__.py +0 -0
  46. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/anthropic.py +0 -0
  47. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/base.py +0 -0
  48. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/fake.py +0 -0
  49. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/gateway.py +0 -0
  50. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/openai_compat.py +0 -0
  51. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/providers/registry.py +0 -0
  52. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/safety/__init__.py +0 -0
  53. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/safety/checkpoints.py +0 -0
  54. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/safety/permissions.py +0 -0
  55. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/safety/redact.py +0 -0
  56. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/session/__init__.py +0 -0
  57. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/session/search.py +0 -0
  58. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/session/store.py +0 -0
  59. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/setup.py +0 -0
  60. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/skills/__init__.py +0 -0
  61. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/skills/examples.py +0 -0
  62. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/skills/loader.py +0 -0
  63. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/skills/propose.py +0 -0
  64. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/skills/registry.py +0 -0
  65. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/__init__.py +0 -0
  66. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/base.py +0 -0
  67. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/fs.py +0 -0
  68. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/history.py +0 -0
  69. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/mcp.py +0 -0
  70. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/search.py +0 -0
  71. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/shell.py +0 -0
  72. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/skills.py +0 -0
  73. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/todo.py +0 -0
  74. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/tools/web.py +0 -0
  75. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/__init__.py +0 -0
  76. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/chooser.py +0 -0
  77. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/console.py +0 -0
  78. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/input/__init__.py +0 -0
  79. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/input/keys.py +0 -0
  80. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/input/reader.py +0 -0
  81. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/layout.py +0 -0
  82. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/markdown.py +0 -0
  83. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/screen.py +0 -0
  84. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/theme.py +0 -0
  85. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/__init__.py +0 -0
  86. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/buttons.py +0 -0
  87. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/chat.py +0 -0
  88. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/history.py +0 -0
  89. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/overlay.py +0 -0
  90. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/panel.py +0 -0
  91. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/progress.py +0 -0
  92. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/prompt.py +0 -0
  93. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/statusbar.py +0 -0
  94. {comodor-0.2.2 → comodor-0.3.0}/src/comodor/ui/widgets/toast.py +0 -0
  95. {comodor-0.2.2 → comodor-0.3.0}/tests/conftest.py +0 -0
  96. {comodor-0.2.2 → comodor-0.3.0}/tests/support/fake_mcp_server.py +0 -0
  97. {comodor-0.2.2 → comodor-0.3.0}/tests/test_agent_loop.py +0 -0
  98. {comodor-0.2.2 → comodor-0.3.0}/tests/test_app.py +0 -0
  99. {comodor-0.2.2 → comodor-0.3.0}/tests/test_chooser.py +0 -0
  100. {comodor-0.2.2 → comodor-0.3.0}/tests/test_doctor.py +0 -0
  101. {comodor-0.2.2 → comodor-0.3.0}/tests/test_history.py +0 -0
  102. {comodor-0.2.2 → comodor-0.3.0}/tests/test_input.py +0 -0
  103. {comodor-0.2.2 → comodor-0.3.0}/tests/test_layout.py +0 -0
  104. {comodor-0.2.2 → comodor-0.3.0}/tests/test_learning.py +0 -0
  105. {comodor-0.2.2 → comodor-0.3.0}/tests/test_markdown.py +0 -0
  106. {comodor-0.2.2 → comodor-0.3.0}/tests/test_mcp.py +0 -0
  107. {comodor-0.2.2 → comodor-0.3.0}/tests/test_performance.py +0 -0
  108. {comodor-0.2.2 → comodor-0.3.0}/tests/test_progress.py +0 -0
  109. {comodor-0.2.2 → comodor-0.3.0}/tests/test_propose.py +0 -0
  110. {comodor-0.2.2 → comodor-0.3.0}/tests/test_providers.py +0 -0
  111. {comodor-0.2.2 → comodor-0.3.0}/tests/test_reflex.py +0 -0
  112. {comodor-0.2.2 → comodor-0.3.0}/tests/test_run_loop.py +0 -0
  113. {comodor-0.2.2 → comodor-0.3.0}/tests/test_setup.py +0 -0
  114. {comodor-0.2.2 → comodor-0.3.0}/tests/test_skills.py +0 -0
  115. {comodor-0.2.2 → comodor-0.3.0}/tests/test_tools.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: comodor
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Comodor — a self-improving terminal coding agent with a Rich TUI
5
5
  Project-URL: Homepage, https://comodor.ai
6
6
  Project-URL: Repository, https://github.com/ifekri/Comodor
@@ -113,12 +113,27 @@ Four questions, once. Nothing to create beforehand — no config file, no
113
113
  environment variable, no documentation to read first.
114
114
 
115
115
  ```
116
- 1/4 Which model provider? 18 to choose from, numbered
117
- 2/4 API key masked, with a link to the page that issues one
118
- 3/4 Which model? read live from the provider you just chose
119
- 4/4 How much should it ask? ask first · writes allowed · full autonomy
116
+ ✓ provider Ollama (local)
117
+ ✓ api key not needed
118
+
119
+ 3/4 Which model?
120
+ ┌─ Models ──────────────────────────────────────────────┐
121
+ │ › qwen2.5-coder:14b recommended │
122
+ │ llama3.3 │
123
+ │ deepseek-r1:14b │
124
+ └─────────────────────────────────────────────────────────┘
125
+ ↑↓ move enter choose type filter esc cancel
120
126
  ```
121
127
 
128
+ One question per screen, answered with the arrow keys. Where a provider offers
129
+ sixty models, typing filters them. Piped or scripted, the same questions arrive
130
+ as a numbered list, so it can still be automated.
131
+
132
+ Then it shows you the directory it is about to work in and asks once — the
133
+ project root is found by walking upwards, and the answer is occasionally a
134
+ surprise worth seeing before anything reads it. Approved folders are
135
+ remembered.
136
+
122
137
  You are not asked again. Change your mind later with `comodor setup`.
123
138
 
124
139
  **No API key?** `comodor --demo` runs the whole interface offline — every
@@ -191,6 +206,35 @@ Everything you have ever asked is searchable.
191
206
  The agent searches it too, on its own, when you refer to earlier work — *"like
192
207
  we did last time"*, *"that bug from last week"*.
193
208
 
209
+ ### It can browse, not just fetch
210
+
211
+ Most agents get one page at a time: download a URL, strip the markup, and the
212
+ links go with it — so the only way onward is guessing another URL. Comodor
213
+ browses.
214
+
215
+ ```
216
+ › find out how the GitHub MCP server handles rate limits
217
+
218
+ ⚙ search: github mcp server rate limit 1.2s
219
+ ⚙ browse https://github.com/modelcontextprotocol/servers 0.8s
220
+ Links on this page:
221
+ 1. src/github → …/tree/main/src/github
222
+ ⚙ follow link 1 0.6s
223
+ ⚙ find "rate limit" on the page 0.0s
224
+ ```
225
+
226
+ The links come back numbered and resolved, so the next move is `follow 4`
227
+ rather than a guess — and links inside the content rank above the navigation
228
+ bar that every page of a documentation site repeats. It is one session, so
229
+ cookies, redirects and consent pages survive the hop. Long pages are handed
230
+ over a screenful at a time with `find` to jump, instead of being cut off at
231
+ 40,000 characters. Moving around a page it has already fetched touches no
232
+ network and asks no permission; a new host does.
233
+
234
+ There is no JavaScript engine, and there is not going to be one — that means a
235
+ real browser, which means a real dependency. A page that draws itself in the
236
+ client says so and points at the Puppeteer server below rather than pretending.
237
+
194
238
  ### It connects to other tools
195
239
 
196
240
  Comodor speaks the [Model Context Protocol](https://modelcontextprotocol.io),
@@ -271,22 +315,6 @@ not by searching your disk. Three things it will not do: touch a source
271
315
  checkout, take a directory off your PATH that other programs are still using,
272
316
  or claim to have deleted a file the operating system would not let go of.
273
317
 
274
- ### It can prove it is improving
275
-
276
- Every tool claims to get better over time. Comodor shows the numbers.
277
-
278
- ```
279
- ◈ Steps per task down 40% since the first tasks in this project.
280
-
281
- metric trend now vs first
282
- Steps per task ▇██▇▇▆▇▅▅▅▅▆▄▅▄▅▄▄▂▄▂▁▂▂▂▁▁▁▁▁ 5.3 ↓40%
283
- Corrections per task ████▆▇▆█▆▆▆▇▆▆▅▃▃▅▆▆▅▃▆▃▆▃▂▁▁▃ 0.9 ↓65%
284
- Approvals asked █▇▇▇▇▇▇▇▇▇▅▅▅▅▅▅▅▅▅▅▃▃▃▃▃▃▃▃▃▁ 0.8 ↓73%
285
- ```
286
-
287
- The panel is built to under-claim: with too little history it says so, and a
288
- fall from 0.4 to 0 is never allowed to headline as "down 100%".
289
-
290
318
  ---
291
319
 
292
320
  ## You stay in control
@@ -87,12 +87,27 @@ Four questions, once. Nothing to create beforehand — no config file, no
87
87
  environment variable, no documentation to read first.
88
88
 
89
89
  ```
90
- 1/4 Which model provider? 18 to choose from, numbered
91
- 2/4 API key masked, with a link to the page that issues one
92
- 3/4 Which model? read live from the provider you just chose
93
- 4/4 How much should it ask? ask first · writes allowed · full autonomy
90
+ ✓ provider Ollama (local)
91
+ ✓ api key not needed
92
+
93
+ 3/4 Which model?
94
+ ┌─ Models ──────────────────────────────────────────────┐
95
+ │ › qwen2.5-coder:14b recommended │
96
+ │ llama3.3 │
97
+ │ deepseek-r1:14b │
98
+ └─────────────────────────────────────────────────────────┘
99
+ ↑↓ move enter choose type filter esc cancel
94
100
  ```
95
101
 
102
+ One question per screen, answered with the arrow keys. Where a provider offers
103
+ sixty models, typing filters them. Piped or scripted, the same questions arrive
104
+ as a numbered list, so it can still be automated.
105
+
106
+ Then it shows you the directory it is about to work in and asks once — the
107
+ project root is found by walking upwards, and the answer is occasionally a
108
+ surprise worth seeing before anything reads it. Approved folders are
109
+ remembered.
110
+
96
111
  You are not asked again. Change your mind later with `comodor setup`.
97
112
 
98
113
  **No API key?** `comodor --demo` runs the whole interface offline — every
@@ -165,6 +180,35 @@ Everything you have ever asked is searchable.
165
180
  The agent searches it too, on its own, when you refer to earlier work — *"like
166
181
  we did last time"*, *"that bug from last week"*.
167
182
 
183
+ ### It can browse, not just fetch
184
+
185
+ Most agents get one page at a time: download a URL, strip the markup, and the
186
+ links go with it — so the only way onward is guessing another URL. Comodor
187
+ browses.
188
+
189
+ ```
190
+ › find out how the GitHub MCP server handles rate limits
191
+
192
+ ⚙ search: github mcp server rate limit 1.2s
193
+ ⚙ browse https://github.com/modelcontextprotocol/servers 0.8s
194
+ Links on this page:
195
+ 1. src/github → …/tree/main/src/github
196
+ ⚙ follow link 1 0.6s
197
+ ⚙ find "rate limit" on the page 0.0s
198
+ ```
199
+
200
+ The links come back numbered and resolved, so the next move is `follow 4`
201
+ rather than a guess — and links inside the content rank above the navigation
202
+ bar that every page of a documentation site repeats. It is one session, so
203
+ cookies, redirects and consent pages survive the hop. Long pages are handed
204
+ over a screenful at a time with `find` to jump, instead of being cut off at
205
+ 40,000 characters. Moving around a page it has already fetched touches no
206
+ network and asks no permission; a new host does.
207
+
208
+ There is no JavaScript engine, and there is not going to be one — that means a
209
+ real browser, which means a real dependency. A page that draws itself in the
210
+ client says so and points at the Puppeteer server below rather than pretending.
211
+
168
212
  ### It connects to other tools
169
213
 
170
214
  Comodor speaks the [Model Context Protocol](https://modelcontextprotocol.io),
@@ -245,22 +289,6 @@ not by searching your disk. Three things it will not do: touch a source
245
289
  checkout, take a directory off your PATH that other programs are still using,
246
290
  or claim to have deleted a file the operating system would not let go of.
247
291
 
248
- ### It can prove it is improving
249
-
250
- Every tool claims to get better over time. Comodor shows the numbers.
251
-
252
- ```
253
- ◈ Steps per task down 40% since the first tasks in this project.
254
-
255
- metric trend now vs first
256
- Steps per task ▇██▇▇▆▇▅▅▅▅▆▄▅▄▅▄▄▂▄▂▁▂▂▂▁▁▁▁▁ 5.3 ↓40%
257
- Corrections per task ████▆▇▆█▆▆▆▇▆▆▅▃▃▅▆▆▅▃▆▃▆▃▂▁▁▃ 0.9 ↓65%
258
- Approvals asked █▇▇▇▇▇▇▇▇▇▅▅▅▅▅▅▅▅▅▅▃▃▃▃▃▃▃▃▃▁ 0.8 ↓73%
259
- ```
260
-
261
- The panel is built to under-claim: with too little history it says so, and a
262
- fall from 0.4 to 0 is never allowed to headline as "down 100%".
263
-
264
292
  ---
265
293
 
266
294
  ## You stay in control
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.2'
22
- __version_tuple__ = version_tuple = (0, 2, 2)
21
+ __version__ = version = '0.3.0'
22
+ __version_tuple__ = version_tuple = (0, 3, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -484,6 +484,22 @@ def main(argv: list[str] | None = None) -> int:
484
484
  if config.needs_setup:
485
485
  return 1
486
486
 
487
+ # Which directory is this about? Asked once per folder, before the agent
488
+ # exists — the project root is worked out by walking upwards, and the
489
+ # answer is occasionally a surprise worth seeing before anything reads it.
490
+ # `--cwd` is the user naming it themselves, so it does not ask again.
491
+ if not args.cwd:
492
+ from .ui import console as console_module
493
+ from .workspace import confirm
494
+
495
+ theme = console_module.prepare_theme(config.ui.theme,
496
+ config.ui.ascii_borders, no_color=False)
497
+ chosen = confirm(config, console_module.build(theme), theme)
498
+ if chosen is None:
499
+ return 0
500
+ if chosen != config.paths.project:
501
+ config = apply_overrides(load_config(str(chosen)), args)
502
+
487
503
  from .ui.app import App
488
504
 
489
505
  resume = None
@@ -227,6 +227,9 @@ class SafetyConfig:
227
227
  deny_commands: list[str] = field(default_factory=lambda: list(DEFAULT_DENY))
228
228
  workspace_only: bool = True
229
229
  max_file_read_bytes: int = 512_000
230
+ #: Directories the user has confirmed as a workspace. Exact paths, never
231
+ #: prefixes: approving ~/work/api must not quietly approve ~/work.
232
+ trusted_folders: list[str] = field(default_factory=list)
230
233
 
231
234
 
232
235
  # Patterns that are never worth running from an agent, however it is prompted.
@@ -0,0 +1,398 @@
1
+ """Browsing, rather than fetching.
2
+
3
+ `web_fetch` downloads one page and strips it to text. That is the right tool
4
+ for "read this URL" and useless for anything past it, because stripping the
5
+ markup takes the links with it: the agent gets a wall of prose describing a
6
+ navigation it has no way to perform, and the only route onward is guessing a
7
+ URL. Which it does.
8
+
9
+ A browser is the same reduction plus the three things that make a page part of
10
+ a site:
11
+
12
+ * **The links come back.** Numbered, resolved to absolute URLs, de-duplicated,
13
+ and cut off at a sensible count — so the next move is `follow 4` rather than
14
+ a guess. Navigation bars repeat on every page of a documentation site, so
15
+ they are ranked below links that appear inside the content.
16
+ * **It is a session.** One cookie jar and one connection pool for the whole
17
+ visit, so a redirect chain, a consent banner or a login survives the hop to
18
+ the next page. `web_fetch` starts from nothing every time.
19
+ * **It remembers where it has been.** `back` exists, and a page already visited
20
+ comes out of the history rather than off the network.
21
+
22
+ Long pages are paged rather than truncated. `web_fetch` cuts at forty thousand
23
+ characters and says so, which for a specification is the interesting half
24
+ thrown away; here the page is kept whole in the session and handed over a
25
+ screenful at a time, with `find` to jump straight to the part that matters.
26
+
27
+ **What it is not.** There is no JavaScript engine here and there is not going
28
+ to be one — that means a real browser, which means a real dependency, and this
29
+ package has one. A page that renders itself in the client comes back as whatever
30
+ its HTML actually contains, and says so. For those, the Puppeteer server in the
31
+ MCP catalogue drives a real Chrome, and this tool tells the model to reach for
32
+ it rather than pretending.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import html as html_module
38
+ import re
39
+ from dataclasses import dataclass, field
40
+ from typing import Any
41
+ from urllib.parse import urljoin, urlsplit
42
+
43
+ from ..net import http
44
+ from ..safety import Risk
45
+ from .base import Tool, ToolContext, ToolResult
46
+ from .web import USER_AGENT, html_to_text
47
+
48
+ #: Characters handed over per view. Roughly a long screenful.
49
+ PAGE_CHARS = 12_000
50
+ #: Links listed per page. Beyond this it is a sitemap, not a page.
51
+ MAX_LINKS = 40
52
+ #: Pages kept in the session. Each one is text, so this is cheap.
53
+ MAX_HISTORY = 20
54
+
55
+ _LINK = re.compile(
56
+ r"<a\b[^>]*?href\s*=\s*[\"']([^\"'#>]+)[^>]*>(.*?)</a>",
57
+ re.IGNORECASE | re.DOTALL,
58
+ )
59
+ _TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
60
+ _TAG = re.compile(r"<[^>]+>")
61
+ _SPACE = re.compile(r"\s+")
62
+ #: Where a site's own furniture lives. Links inside these are ranked last.
63
+ _CHROME = re.compile(r"<(nav|header|footer|aside)\b.*?</\1>",
64
+ re.IGNORECASE | re.DOTALL)
65
+ #: A page that renders itself in the browser leaves almost nothing behind.
66
+ _THIN = 400
67
+
68
+
69
+ @dataclass
70
+ class Link:
71
+ url: str
72
+ text: str
73
+
74
+
75
+ @dataclass
76
+ class Page:
77
+ url: str
78
+ title: str
79
+ text: str
80
+ links: list[Link] = field(default_factory=list)
81
+ #: Where the last view stopped, so `more` continues rather than repeats.
82
+ cursor: int = 0
83
+
84
+ @property
85
+ def pages(self) -> int:
86
+ return max(1, -(-len(self.text) // PAGE_CHARS))
87
+
88
+
89
+ @dataclass
90
+ class Visit:
91
+ """One browsing session: cookies, history, and where we are."""
92
+
93
+ session: Any = None
94
+ history: list[Page] = field(default_factory=list)
95
+ index: int = -1
96
+
97
+ @property
98
+ def current(self) -> Page | None:
99
+ return self.history[self.index] if 0 <= self.index < len(self.history) else None
100
+
101
+ def open(self) -> Any:
102
+ if self.session is None:
103
+ self.session = http.Session(
104
+ headers={"User-Agent": USER_AGENT,
105
+ "Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
106
+ "Accept-Language": "en"},
107
+ timeout=(10.0, 30.0),
108
+ )
109
+ return self.session
110
+
111
+ def visited(self, url: str) -> Page | None:
112
+ for page in self.history:
113
+ if page.url == url:
114
+ return page
115
+ return None
116
+
117
+ def push(self, page: Page) -> None:
118
+ # Anything forward of here is a branch nobody took.
119
+ del self.history[self.index + 1:]
120
+ self.history.append(page)
121
+ del self.history[:-MAX_HISTORY]
122
+ self.index = len(self.history) - 1
123
+
124
+ def close(self) -> None:
125
+ if self.session is not None:
126
+ try:
127
+ self.session.close()
128
+ except Exception:
129
+ pass
130
+ self.session = None
131
+
132
+
133
+ def _clean(markup: str) -> str:
134
+ return _SPACE.sub(" ", html_module.unescape(_TAG.sub(" ", markup))).strip()
135
+
136
+
137
+ def extract_links(markup: str, base: str) -> list[Link]:
138
+ """Every link worth offering, in the order they are worth offering."""
139
+ chrome_spans = [match.span() for match in _CHROME.finditer(markup)]
140
+
141
+ def in_chrome(position: int) -> bool:
142
+ return any(start <= position < end for start, end in chrome_spans)
143
+
144
+ seen: set[str] = set()
145
+ content: list[Link] = []
146
+ furniture: list[Link] = []
147
+
148
+ for match in _LINK.finditer(markup):
149
+ href = html_module.unescape(match.group(1).strip())
150
+ if not href or href.lower().startswith(("javascript:", "mailto:", "tel:",
151
+ "data:")):
152
+ continue
153
+ url = urljoin(base, href)
154
+ if urlsplit(url).scheme not in ("http", "https") or url in seen:
155
+ continue
156
+ text = _clean(match.group(2))
157
+ if not text:
158
+ continue
159
+ seen.add(url)
160
+ (furniture if in_chrome(match.start()) else content).append(
161
+ Link(url, text[:90]))
162
+
163
+ return (content + furniture)[:MAX_LINKS]
164
+
165
+
166
+ def render(page: Page, offset: int, note: str = "") -> str:
167
+ """One view of a page: a slice of its text, then where it can go next."""
168
+ body = page.text[offset:offset + PAGE_CHARS]
169
+ end = offset + len(body)
170
+
171
+ header = f"{page.title or page.url}\n{page.url}"
172
+ if page.pages > 1:
173
+ showing = offset // PAGE_CHARS + 1
174
+ header += f"\n[part {showing} of {page.pages}]"
175
+
176
+ parts = [header, "", body]
177
+ if end < len(page.text):
178
+ parts.append(f"\n[{len(page.text) - end:,} more characters — "
179
+ f"browser(action='more')]")
180
+
181
+ if page.links:
182
+ listed = "\n".join(f" {index:>2}. {link.text} → {link.url}"
183
+ for index, link in enumerate(page.links, start=1))
184
+ parts.append(f"\nLinks on this page:\n{listed}")
185
+ parts.append("\nFollow one with browser(action='follow', link=<number>).")
186
+
187
+ if note:
188
+ parts.append(f"\n{note}")
189
+ return "\n".join(parts)
190
+
191
+
192
+ class Browser(Tool):
193
+ name = "browser"
194
+ description = (
195
+ "Browse the web across pages, not just one at a time. Actions: "
196
+ "'open' a url · 'follow' a numbered link from the current page · "
197
+ "'back' · 'more' of a long page · 'find' text within it · 'links' to "
198
+ "list them again. Cookies and the connection are kept for the whole "
199
+ "session, so redirects and consent pages work. Prefer this over "
200
+ "web_fetch whenever the answer might be a page or two away."
201
+ )
202
+ risk = Risk.DANGEROUS # it leaves the machine, so it asks first
203
+ parameters = {
204
+ "type": "object",
205
+ "properties": {
206
+ "action": {
207
+ "type": "string",
208
+ "enum": ["open", "follow", "back", "more", "find", "links"],
209
+ "description": "What to do. Defaults to 'open' when a url is given.",
210
+ },
211
+ "url": {"type": "string", "description": "For 'open'."},
212
+ "link": {"type": "integer",
213
+ "description": "For 'follow': the number beside the link."},
214
+ "text": {"type": "string", "description": "For 'find'."},
215
+ },
216
+ "required": [],
217
+ }
218
+
219
+ def __init__(self) -> None:
220
+ self.visit = Visit()
221
+
222
+ def summary(self, args: dict[str, Any]) -> str:
223
+ action = str(args.get("action") or ("open" if args.get("url") else "?"))
224
+ if action == "open":
225
+ return f"browse {args.get('url', '?')}"
226
+ if action == "follow":
227
+ return f"follow link {args.get('link', '?')}"
228
+ if action == "find":
229
+ return f"find {str(args.get('text', ''))[:40]!r} on the page"
230
+ return f"browser: {action}"
231
+
232
+ def permission_key(self, args: dict[str, Any]) -> str:
233
+ """One approval per host, and none at all for moving within a page.
234
+
235
+ `more`, `find`, `links` and `back` do not touch the network — they read
236
+ what is already in the session. Asking for them trains people to
237
+ approve without reading.
238
+ """
239
+ action = str(args.get("action") or ("open" if args.get("url") else ""))
240
+ if action in ("more", "find", "links", "back"):
241
+ return f"{self.name}:local"
242
+ if action == "follow":
243
+ page = self.visit.current
244
+ number = _as_int(args.get("link"))
245
+ if page and number and 1 <= number <= len(page.links):
246
+ return f"{self.name}:{urlsplit(page.links[number - 1].url).hostname}"
247
+ return f"{self.name}:?"
248
+ host = urlsplit(str(args.get("url", ""))).hostname or "?"
249
+ return f"{self.name}:{host}"
250
+
251
+ # -- the actions ------------------------------------------------------- #
252
+
253
+ def run(self, ctx: ToolContext, action: str = "", url: str = "",
254
+ link: Any = None, text: str = "", **_: Any) -> ToolResult:
255
+ action = (action or ("open" if url else "")).strip().lower()
256
+ if not action:
257
+ return ToolResult.failure(
258
+ "say what to do: open a url, or follow / back / more / find / links")
259
+
260
+ handler = {
261
+ "open": lambda: self._open(url),
262
+ "follow": lambda: self._follow(_as_int(link)),
263
+ "back": self._back,
264
+ "more": self._more,
265
+ "find": lambda: self._find(text),
266
+ "links": self._links,
267
+ }.get(action)
268
+
269
+ if handler is None:
270
+ return ToolResult.failure(f"unknown action {action!r}")
271
+ return handler()
272
+
273
+ def _open(self, url: str) -> ToolResult:
274
+ if not url:
275
+ return ToolResult.failure("open needs a url")
276
+ if not url.lower().startswith(("http://", "https://")):
277
+ url = "https://" + url
278
+
279
+ seen = self.visit.visited(url)
280
+ if seen is not None:
281
+ self.visit.push(seen)
282
+ seen.cursor = min(PAGE_CHARS, len(seen.text))
283
+ return self._result(seen, 0, note="(already visited this session)")
284
+
285
+ try:
286
+ response = self.visit.open().get(url)
287
+ except http.RequestError as exc:
288
+ return ToolResult.failure(f"could not open {url}: {exc}")
289
+
290
+ with response:
291
+ if not response.ok:
292
+ return ToolResult.failure(
293
+ f"{url} returned {response.status_code} {response.reason}")
294
+ content_type = response.headers.get("Content-Type", "")
295
+ landed = getattr(response, "url", url) or url
296
+ body = response.text
297
+
298
+ page = self._page(landed, body, content_type)
299
+ self.visit.push(page)
300
+ page.cursor = min(PAGE_CHARS, len(page.text))
301
+
302
+ note = ""
303
+ if landed != url:
304
+ note = f"(redirected from {url})"
305
+ if "html" in content_type.lower() and len(page.text) < _THIN:
306
+ note = (note + " " if note else "") + (
307
+ "This page carries almost no text, which usually means it draws "
308
+ "itself with JavaScript. There is no JavaScript engine here — "
309
+ "for a page like this, use the Puppeteer server from "
310
+ "`comodor mcp catalogue`.")
311
+ return self._result(page, 0, note=note.strip())
312
+
313
+ def _follow(self, number: int | None) -> ToolResult:
314
+ page = self.visit.current
315
+ if page is None:
316
+ return ToolResult.failure("nothing is open yet")
317
+ if not number or not 1 <= number <= len(page.links):
318
+ return ToolResult.failure(
319
+ f"pick a link between 1 and {len(page.links)}")
320
+ return self._open(page.links[number - 1].url)
321
+
322
+ def _back(self) -> ToolResult:
323
+ if self.visit.index <= 0:
324
+ return ToolResult.failure("nothing to go back to")
325
+ self.visit.index -= 1
326
+ page = self.visit.current
327
+ assert page is not None
328
+ page.cursor = min(PAGE_CHARS, len(page.text))
329
+ return self._result(page, 0)
330
+
331
+ def _more(self) -> ToolResult:
332
+ page = self.visit.current
333
+ if page is None:
334
+ return ToolResult.failure("nothing is open yet")
335
+ if page.cursor >= len(page.text):
336
+ return ToolResult.success(content=f"{page.url}: that was the end.")
337
+ offset = page.cursor
338
+ page.cursor = min(page.cursor + PAGE_CHARS, len(page.text))
339
+ return self._result(page, offset)
340
+
341
+ def _find(self, needle: str) -> ToolResult:
342
+ page = self.visit.current
343
+ if page is None:
344
+ return ToolResult.failure("nothing is open yet")
345
+ if not needle:
346
+ return ToolResult.failure("find needs something to look for")
347
+
348
+ position = page.text.lower().find(needle.lower())
349
+ if position < 0:
350
+ return ToolResult.failure(f"{needle!r} is not on this page")
351
+
352
+ # Start a little before the match, so it arrives with its context.
353
+ offset = max(0, position - 400)
354
+ page.cursor = min(offset + PAGE_CHARS, len(page.text))
355
+ return self._result(page, offset, note=f"(found {needle!r})")
356
+
357
+ def _links(self) -> ToolResult:
358
+ page = self.visit.current
359
+ if page is None:
360
+ return ToolResult.failure("nothing is open yet")
361
+ if not page.links:
362
+ return ToolResult.success(content=f"{page.url} has no links.")
363
+ listed = "\n".join(f" {index:>2}. {link.text} → {link.url}"
364
+ for index, link in enumerate(page.links, start=1))
365
+ return ToolResult.success(
366
+ content=f"Links on {page.url}:\n{listed}", display=listed, url=page.url)
367
+
368
+ # -- helpers ------------------------------------------------------------ #
369
+
370
+ def _page(self, url: str, body: str, content_type: str) -> Page:
371
+ is_html = "html" in content_type.lower() or body.lstrip().startswith("<")
372
+ if not is_html:
373
+ return Page(url=url, title=url.rsplit("/", 1)[-1], text=body)
374
+
375
+ match = _TITLE.search(body)
376
+ return Page(
377
+ url=url,
378
+ title=_clean(match.group(1)) if match else "",
379
+ text=html_to_text(body),
380
+ links=extract_links(body, url),
381
+ )
382
+
383
+ def _result(self, page: Page, offset: int, note: str = "") -> ToolResult:
384
+ content = render(page, offset, note)
385
+ return ToolResult.success(
386
+ content=content, display=content, url=page.url,
387
+ title=page.title, links=len(page.links), parts=page.pages,
388
+ )
389
+
390
+ def close(self) -> None:
391
+ self.visit.close()
392
+
393
+
394
+ def _as_int(value: Any) -> int | None:
395
+ try:
396
+ return int(value)
397
+ except (TypeError, ValueError):
398
+ return None
@@ -14,6 +14,7 @@ from typing import Any, Iterable
14
14
  from ..providers.base import ToolSpec
15
15
  from ..safety import Risk
16
16
  from .base import Tool, ToolContext, ToolResult
17
+ from .browser import Browser
17
18
  from .fs import EditFile, ListDir, ReadFile, WriteFile
18
19
  from .history import SearchHistory
19
20
  from .search import Glob, Grep
@@ -26,7 +27,7 @@ DEFAULT_TOOLS: tuple[type[Tool], ...] = (
26
27
  ReadFile, WriteFile, EditFile, ListDir,
27
28
  Glob, Grep,
28
29
  RunShell, RunPython,
29
- WebFetch, WebSearch,
30
+ Browser, WebFetch, WebSearch,
30
31
  TodoWrite,
31
32
  )
32
33
 
@@ -59,6 +60,23 @@ class ToolRegistry:
59
60
  def add(self, tool: Tool) -> None:
60
61
  self._tools[tool.name] = tool
61
62
 
63
+ def close(self) -> None:
64
+ """Let go of anything a tool is holding open.
65
+
66
+ The browser keeps a connection pool and a cookie jar for the length of
67
+ a session, which is the point of it; leaving them to the garbage
68
+ collector means a socket that outlives the program's own shutdown
69
+ message. A tool with nothing to release simply has no `close`.
70
+ """
71
+ for tool in self._tools.values():
72
+ closer = getattr(tool, "close", None)
73
+ if closer is None:
74
+ continue
75
+ try:
76
+ closer()
77
+ except Exception:
78
+ pass
79
+
62
80
  def remove(self, name: str) -> None:
63
81
  self._tools.pop(name, None)
64
82
 
@@ -189,6 +189,7 @@ class App:
189
189
 
190
190
  def _shutdown(self) -> None:
191
191
  self.agent.interrupt()
192
+ self.tools.close()
192
193
  self.history.close()
193
194
  if self.mcp is not None:
194
195
  self.mcp.close()