kiwimatecoder 0.1.0__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 (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/web.py ADDED
@@ -0,0 +1,499 @@
1
+ """Web helpers: HTML-to-text, local-address detection, fetching, and search.
2
+
3
+ The pure helpers (:func:`html_to_text`, :func:`is_local_address`,
4
+ :func:`parse_ddg_results`) are kept free of network access so they can be
5
+ unit-tested from fixture strings. Network failures surface as
6
+ :class:`WebError` with a message that is safe to show the model; raw
7
+ ``httpx`` errors are never leaked.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import ipaddress
13
+ import re
14
+ from dataclasses import dataclass
15
+ from html.parser import HTMLParser
16
+ from urllib.parse import parse_qs, unquote, urlsplit
17
+
18
+ import httpx
19
+
20
+ from kiwimatecoder import network
21
+
22
+ USER_AGENT = (
23
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
24
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
25
+ "Chrome/124.0 Safari/537.36 KiwiMateCoder/0.1"
26
+ )
27
+
28
+ #: A UTF-8 character can take up to four bytes, so a byte cap of
29
+ #: ``max_chars * 4`` guarantees at least ``max_chars`` characters are readable.
30
+ MAX_BYTES_PER_CHAR = 4
31
+
32
+ DDG_HTML_URL = "https://html.duckduckgo.com/html/"
33
+
34
+ DEFAULT_SEARCH_COUNT = 5
35
+ MAX_SEARCH_COUNT = 10
36
+
37
+
38
+ class WebError(Exception):
39
+ """A user-facing web failure with a ready-to-show message."""
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # HTML to text
44
+ # ---------------------------------------------------------------------------
45
+
46
+ _SKIP_TAGS = frozenset({"script", "style", "noscript", "svg", "template"})
47
+ _BLOCK_TAGS = frozenset(
48
+ {
49
+ "address",
50
+ "article",
51
+ "aside",
52
+ "blockquote",
53
+ "dd",
54
+ "details",
55
+ "div",
56
+ "dl",
57
+ "dt",
58
+ "fieldset",
59
+ "figcaption",
60
+ "figure",
61
+ "footer",
62
+ "form",
63
+ "h1",
64
+ "h2",
65
+ "h3",
66
+ "h4",
67
+ "h5",
68
+ "h6",
69
+ "header",
70
+ "hr",
71
+ "li",
72
+ "main",
73
+ "nav",
74
+ "ol",
75
+ "p",
76
+ "pre",
77
+ "section",
78
+ "summary",
79
+ "table",
80
+ "tbody",
81
+ "td",
82
+ "tfoot",
83
+ "th",
84
+ "thead",
85
+ "tr",
86
+ "ul",
87
+ }
88
+ )
89
+ _WHITESPACE = re.compile(r"[ \t\f\v\xa0]+")
90
+
91
+
92
+ class _TextExtractor(HTMLParser):
93
+ """Collect visible text and the document title from HTML."""
94
+
95
+ def __init__(self) -> None:
96
+ super().__init__(convert_charrefs=True)
97
+ self._chunks: list[str] = []
98
+ self._title_chunks: list[str] = []
99
+ self._skip_depth = 0
100
+ self._in_title = False
101
+
102
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
103
+ tag = tag.lower()
104
+ if tag in _SKIP_TAGS:
105
+ self._skip_depth += 1
106
+ return
107
+ if self._skip_depth:
108
+ return
109
+ if tag == "title":
110
+ self._in_title = True
111
+ return
112
+ if tag == "br" or tag in _BLOCK_TAGS:
113
+ self._chunks.append("\n")
114
+
115
+ def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
116
+ tag = tag.lower()
117
+ if tag in _SKIP_TAGS or self._skip_depth:
118
+ return
119
+ if tag == "br" or tag in _BLOCK_TAGS:
120
+ self._chunks.append("\n")
121
+
122
+ def handle_endtag(self, tag: str) -> None:
123
+ tag = tag.lower()
124
+ if tag in _SKIP_TAGS:
125
+ self._skip_depth = max(0, self._skip_depth - 1)
126
+ return
127
+ if self._skip_depth:
128
+ return
129
+ if tag == "title":
130
+ self._in_title = False
131
+ return
132
+ if tag in _BLOCK_TAGS:
133
+ self._chunks.append("\n")
134
+
135
+ def handle_data(self, data: str) -> None:
136
+ if self._skip_depth:
137
+ return
138
+ if self._in_title:
139
+ self._title_chunks.append(data)
140
+ return
141
+ self._chunks.append(data)
142
+
143
+ def title(self) -> str | None:
144
+ title = _WHITESPACE.sub(" ", "".join(self._title_chunks)).strip()
145
+ return title or None
146
+
147
+ def text(self) -> str:
148
+ lines = [
149
+ _WHITESPACE.sub(" ", line).strip()
150
+ for line in "".join(self._chunks).splitlines()
151
+ ]
152
+ text = "\n".join(lines)
153
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
154
+
155
+
156
+ def html_to_text(html: str) -> tuple[str | None, str]:
157
+ """Convert ``html`` to ``(title, text)``.
158
+
159
+ Script/style/noscript/svg content is dropped, block tags become newlines,
160
+ entities are decoded, and runs of more than two blank lines collapse to
161
+ one blank line. The title is ``None`` when the document has none.
162
+ """
163
+ parser = _TextExtractor()
164
+ try:
165
+ parser.feed(html)
166
+ parser.close()
167
+ except Exception: # HTMLParser is forgiving; never break a tool call
168
+ pass
169
+ return parser.title(), parser.text()
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Local / private address detection
174
+ # ---------------------------------------------------------------------------
175
+
176
+
177
+ def _host_of(url: str) -> str:
178
+ raw = str(url).strip()
179
+ if not raw:
180
+ return ""
181
+ # ``urlsplit`` reads "localhost:8080" as scheme="localhost"; retry as a
182
+ # host-relative form when there is no explicit scheme separator.
183
+ parsed = urlsplit(raw if "://" in raw else f"//{raw}")
184
+ return (parsed.hostname or "").strip().lower().rstrip(".")
185
+
186
+
187
+ def is_local_address(url: str) -> bool:
188
+ """Whether ``url`` points at localhost, a private IP, or a ``.local`` name."""
189
+ host = _host_of(url)
190
+ if not host:
191
+ return False
192
+ if host in {"localhost", "localhost.localdomain"} or host.endswith(
193
+ (".local", ".localhost")
194
+ ):
195
+ return True
196
+ try:
197
+ address = ipaddress.ip_address(host)
198
+ except ValueError:
199
+ return False
200
+ return (
201
+ address.is_loopback
202
+ or address.is_private
203
+ or address.is_link_local
204
+ or address.is_reserved
205
+ or address.is_unspecified
206
+ )
207
+
208
+
209
+ def clean_url(url: str) -> str:
210
+ """Validate an http(s) URL, raising :class:`WebError` for anything else."""
211
+ cleaned = str(url).strip()
212
+ if not cleaned:
213
+ raise WebError("A non-empty URL is required.")
214
+ parsed = urlsplit(cleaned)
215
+ if parsed.scheme not in {"http", "https"}:
216
+ raise WebError("Only http:// and https:// URLs are supported.")
217
+ if not parsed.hostname:
218
+ raise WebError(f"Could not parse a host from '{cleaned}'.")
219
+ return cleaned
220
+
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Fetching
224
+ # ---------------------------------------------------------------------------
225
+
226
+
227
+ def _mime_type(content_type: str) -> str:
228
+ return content_type.split(";", 1)[0].strip().lower()
229
+
230
+
231
+ def _is_textual(mime: str) -> bool:
232
+ if not mime:
233
+ return True
234
+ if mime.startswith("text/"):
235
+ return True
236
+ if mime in {
237
+ "application/json",
238
+ "application/ld+json",
239
+ "application/xml",
240
+ "application/xhtml+xml",
241
+ "application/javascript",
242
+ "application/x-javascript",
243
+ "application/rss+xml",
244
+ "application/atom+xml",
245
+ }:
246
+ return True
247
+ return mime.endswith(("+json", "+xml"))
248
+
249
+
250
+ def _is_html(mime: str) -> bool:
251
+ return mime in {"text/html", "application/xhtml+xml"} or mime.endswith("+html")
252
+
253
+
254
+ def _decode(body: bytes, charset: str) -> str:
255
+ try:
256
+ return body.decode(charset or "utf-8", errors="replace")
257
+ except (LookupError, TypeError):
258
+ return body.decode("utf-8", errors="replace")
259
+
260
+
261
+ def fetch_url(
262
+ url: str,
263
+ *,
264
+ timeout: float,
265
+ max_chars: int,
266
+ allow_local: bool,
267
+ transport: httpx.BaseTransport | None = None,
268
+ ) -> tuple[str, str]:
269
+ """Fetch ``url`` and return ``(final_url, text)``.
270
+
271
+ Redirects are followed; the body is read up to a byte cap derived from
272
+ ``max_chars``. HTML is converted to text, ``text/*`` is decoded, and
273
+ anything else is summarized as ``[binary content: <type>, <n> bytes]``.
274
+ Failures raise :class:`WebError` (never a raw ``httpx`` exception).
275
+ """
276
+ if network.offline_enabled():
277
+ raise WebError(network.offline_message("web fetch"))
278
+ cleaned = clean_url(url)
279
+ if is_local_address(cleaned) and not allow_local:
280
+ raise WebError(
281
+ "Refusing to fetch a local or private address. Enable it with "
282
+ "/config web allow-local on (or config web allow-local on)."
283
+ )
284
+
285
+ byte_cap = max(1024, int(max_chars) * MAX_BYTES_PER_CHAR)
286
+ final_url = cleaned
287
+ mime = ""
288
+ charset = "utf-8"
289
+ body = b""
290
+ try:
291
+ with httpx.Client(
292
+ timeout=timeout,
293
+ transport=transport,
294
+ follow_redirects=True,
295
+ **network.current_options(),
296
+ ) as client:
297
+ with client.stream(
298
+ "GET",
299
+ cleaned,
300
+ headers={
301
+ "User-Agent": USER_AGENT,
302
+ "Accept": (
303
+ "text/html,application/xhtml+xml,text/plain;q=0.9,"
304
+ "*/*;q=0.5"
305
+ ),
306
+ },
307
+ ) as response:
308
+ final_url = str(response.url)
309
+ if response.status_code >= 400:
310
+ raise WebError(
311
+ f"HTTP {response.status_code} when fetching {final_url}."
312
+ )
313
+ mime = _mime_type(response.headers.get("content-type", ""))
314
+ charset = response.charset_encoding or "utf-8"
315
+ data = bytearray()
316
+ for chunk in response.iter_bytes():
317
+ data.extend(chunk)
318
+ if len(data) >= byte_cap:
319
+ del data[byte_cap:]
320
+ break
321
+ body = bytes(data)
322
+ except WebError:
323
+ raise
324
+ except httpx.HTTPError as exc:
325
+ raise WebError(
326
+ f"Could not fetch {cleaned}: {exc.__class__.__name__}."
327
+ ) from exc
328
+
329
+ if not _is_textual(mime):
330
+ return final_url, f"[binary content: {mime or 'unknown'}, {len(body)} bytes]"
331
+
332
+ text = _decode(body, charset)
333
+ if _is_html(mime):
334
+ _title, text = html_to_text(text)
335
+ return final_url, text
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Search (DuckDuckGo HTML endpoint)
340
+ # ---------------------------------------------------------------------------
341
+
342
+
343
+ @dataclass(frozen=True)
344
+ class SearchResult:
345
+ """One search hit."""
346
+
347
+ title: str
348
+ url: str
349
+ snippet: str = ""
350
+
351
+
352
+ class _DDGParser(HTMLParser):
353
+ """Pull ``result__a`` / ``result__snippet`` / ``result__url`` blocks out of
354
+ the DuckDuckGo HTML results page."""
355
+
356
+ _FIELDS = {"title", "snippet", "display"}
357
+
358
+ def __init__(self) -> None:
359
+ super().__init__(convert_charrefs=True)
360
+ self.results: list[dict[str, str]] = []
361
+ self._current: dict[str, str] | None = None
362
+ self._capture: str | None = None
363
+ self._depth = 0
364
+
365
+ def _start_capture(self, field: str) -> None:
366
+ self._capture = field
367
+ self._depth = 1
368
+
369
+ def _finish(self) -> None:
370
+ if self._current is not None:
371
+ self.results.append(self._current)
372
+ self._current = None
373
+ self._capture = None
374
+ self._depth = 0
375
+
376
+ def finish(self) -> None:
377
+ """Flush the final in-progress result block."""
378
+ self._finish()
379
+
380
+ def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
381
+ if self._capture is not None:
382
+ self._depth += 1
383
+ if tag != "a":
384
+ return
385
+ attributes = {key.lower(): value or "" for key, value in attrs}
386
+ classes = set(attributes.get("class", "").split())
387
+ if "result__a" in classes:
388
+ self._finish()
389
+ self._current = {
390
+ "title": "",
391
+ "url": attributes.get("href", ""),
392
+ "display": "",
393
+ "snippet": "",
394
+ }
395
+ self._start_capture("title")
396
+ elif self._current is not None and "result__snippet" in classes:
397
+ self._start_capture("snippet")
398
+ elif self._current is not None and "result__url" in classes:
399
+ self._start_capture("display")
400
+
401
+ def handle_endtag(self, tag: str) -> None:
402
+ if self._capture is None:
403
+ return
404
+ if tag == "a" and self._depth <= 1:
405
+ self._capture = None
406
+ self._depth = 0
407
+ return
408
+ self._depth = max(0, self._depth - 1)
409
+ if self._depth == 0:
410
+ self._capture = None
411
+
412
+ def handle_data(self, data: str) -> None:
413
+ if self._capture is None or self._current is None:
414
+ return
415
+ if self._capture in self._FIELDS:
416
+ self._current[self._capture] += data
417
+
418
+
419
+ def _collapse(value: str) -> str:
420
+ return re.sub(r"\s+", " ", value).strip()
421
+
422
+
423
+ def _unwrap_ddg_url(raw: str) -> str:
424
+ """Undo DuckDuckGo's ``/l/?uddg=...`` click-tracking wrapper."""
425
+ url = raw.strip()
426
+ if not url:
427
+ return ""
428
+ if url.startswith("//"):
429
+ url = "https:" + url
430
+ parsed = urlsplit(url)
431
+ if parsed.path.startswith("/l/") or "uddg=" in parsed.query:
432
+ values = parse_qs(parsed.query).get("uddg")
433
+ if values and values[0]:
434
+ value = values[0]
435
+ return value if value.startswith(("http://", "https://")) else unquote(value)
436
+ return url
437
+
438
+
439
+ def parse_ddg_results(html: str, limit: int = DEFAULT_SEARCH_COUNT) -> list[SearchResult]:
440
+ """Parse a DuckDuckGo HTML results page into :class:`SearchResult`s."""
441
+ parser = _DDGParser()
442
+ parser.feed(html)
443
+ parser.close()
444
+ parser.finish()
445
+
446
+ results: list[SearchResult] = []
447
+ for raw in parser.results:
448
+ title = _collapse(raw.get("title", ""))
449
+ url = _unwrap_ddg_url(raw.get("url") or raw.get("display", ""))
450
+ if not title or not url:
451
+ continue
452
+ results.append(
453
+ SearchResult(title=title, url=url, snippet=_collapse(raw.get("snippet", "")))
454
+ )
455
+ if len(results) >= max(0, limit):
456
+ break
457
+ return results
458
+
459
+
460
+ def search_web(
461
+ query: str,
462
+ *,
463
+ count: int = DEFAULT_SEARCH_COUNT,
464
+ timeout: float,
465
+ provider: str,
466
+ api_key: str | None = None,
467
+ transport: httpx.BaseTransport | None = None,
468
+ ) -> list[SearchResult]:
469
+ """Search the web and return up to ``count`` results.
470
+
471
+ Only the DuckDuckGo HTML endpoint is supported for now; the provider and
472
+ optional API key are parameters so alternate backends can slot in later.
473
+ Failures raise :class:`WebError`.
474
+ """
475
+ del api_key # reserved for providers that need a key
476
+ if network.offline_enabled():
477
+ raise WebError(network.offline_message("web search"))
478
+ cleaned = str(query).strip()
479
+ if not cleaned:
480
+ raise WebError("Search query must not be empty.")
481
+ if provider != "duckduckgo":
482
+ raise WebError(
483
+ f"Unknown search provider '{provider}'. Only 'duckduckgo' is supported."
484
+ )
485
+ limit = max(1, min(int(count), MAX_SEARCH_COUNT))
486
+ try:
487
+ with httpx.Client(
488
+ timeout=timeout,
489
+ transport=transport,
490
+ follow_redirects=True,
491
+ headers={"User-Agent": USER_AGENT, "Accept": "text/html"},
492
+ **network.current_options(),
493
+ ) as client:
494
+ response = client.get(DDG_HTML_URL, params={"q": cleaned})
495
+ except httpx.HTTPError as exc:
496
+ raise WebError(f"Web search failed: {exc.__class__.__name__}.") from exc
497
+ if response.status_code != 200:
498
+ raise WebError(f"Search provider returned HTTP {response.status_code}.")
499
+ return parse_ddg_results(response.text, limit=limit)