@allansantos-dev/smart-tool 0.9.2 → 0.9.3

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.
@@ -1,24 +1,26 @@
1
1
  """`camoufox fetch` for the installer. camoufox 0.5.6 prints a failed download and still exits 0, so the error is
2
- caught here and turned into a failing exit, and the browser executable must exist afterwards. Its downloads have no
3
- timeout (requests passes None, which also overrides socket.setdefaulttimeout), so every request gets one here: a
4
- stalled download fails after 60 s instead of hanging. Progress is printed as plain lines (the rich bar stays
5
- invisible in a pipe)."""
2
+ caught here and turned into a failing exit, and the browser executable must exist afterwards. The 1.3 GB download
3
+ goes through ranged_download (Range requests that resume after a dropped or stalled connection; camoufox still checks
4
+ the sha256), its other requests get a timeout (requests passes None, which also overrides socket.setdefaulttimeout),
5
+ and progress is printed as plain lines (the rich bar stays invisible in a pipe)."""
6
6
  import sys
7
+ from io import BytesIO
7
8
  from pathlib import Path
8
9
 
9
10
  import camoufox.__main__ as camoufox_cli
10
11
  import requests
11
12
  from camoufox import pkgman
12
13
 
14
+ import ranged_download
15
+
13
16
  TIMEOUT_S = (30, 60)
14
17
  REPORT_EVERY_MB = 50
15
- _webdl = pkgman.webdl
16
18
  _update = camoufox_cli.CamoufoxUpdate.update
17
19
  _get = requests.get
18
20
  failures = []
19
21
 
20
22
 
21
- def _reporting_webdl(url, desc=None, buffer=None, bar=True, progress_callback=None):
23
+ def _reporter():
22
24
  reported = {"mb": -REPORT_EVERY_MB}
23
25
 
24
26
  def report(done, total):
@@ -27,7 +29,13 @@ def _reporting_webdl(url, desc=None, buffer=None, bar=True, progress_callback=No
27
29
  reported["mb"] = mb
28
30
  print(f"downloaded {mb} of {total // 1048576} MB", flush=True)
29
31
 
30
- return _webdl(url, desc=desc, buffer=buffer, bar=False, progress_callback=progress_callback or report)
32
+ return report
33
+
34
+
35
+ def _ranged_webdl(url, desc=None, buffer=None, bar=True, progress_callback=None):
36
+ buffer = BytesIO() if buffer is None else buffer
37
+ ranged_download.download(url, buffer, progress=progress_callback or _reporter())
38
+ return buffer
31
39
 
32
40
 
33
41
  def _get_with_timeout(*args, **kwargs):
@@ -53,7 +61,7 @@ def installed_executable():
53
61
 
54
62
  def main():
55
63
  requests.get = _get_with_timeout
56
- pkgman.webdl = _reporting_webdl
64
+ pkgman.webdl = _ranged_webdl
57
65
  camoufox_cli.CamoufoxUpdate.update = _recording_update
58
66
  camoufox_cli.cli.main(["fetch"], standalone_mode=False)
59
67
  if failures:
@@ -0,0 +1,78 @@
1
+ """Downloads a file in HTTP Range requests of CHUNK_BYTES, each on a new connection, resuming from the last byte
2
+ received when a connection drops or stalls. Some networks (proxies, TLS inspection) cut every connection after a few
3
+ hundred MB, so a single long transfer can never finish there. It gives up only after MAX_IDLE_ATTEMPTS attempts in a
4
+ row without a single new byte. Standard library only: on Windows urllib trusts the system certificate store, including
5
+ a corporate inspection CA, and honors HTTPS_PROXY."""
6
+ import http.client
7
+ import time
8
+ import urllib.error
9
+ import urllib.request
10
+
11
+ CHUNK_BYTES = 32 * 1024 * 1024
12
+ READ_BYTES = 256 * 1024
13
+ TIMEOUT_S = 60
14
+ MAX_IDLE_ATTEMPTS = 6
15
+ MAX_FULL_RESTARTS = 3
16
+ RETRYABLE_HTTP = {408, 429}
17
+
18
+
19
+ class DownloadError(RuntimeError):
20
+ pass
21
+
22
+
23
+ def _open(url, start, end, timeout):
24
+ request = urllib.request.Request(url, headers={"Range": f"bytes={start}-{end}", "User-Agent": "SmartTool-installer"})
25
+ return urllib.request.urlopen(request, timeout=timeout)
26
+
27
+
28
+ def download(url, out, progress=None, chunk_bytes=CHUNK_BYTES, timeout=TIMEOUT_S, attempts=MAX_IDLE_ATTEMPTS,
29
+ sleep=time.sleep):
30
+ """Writes url into the seekable binary file out and returns its size; progress(done, total) after every read."""
31
+ done, total, idle, restarts = 0, None, 0, 0
32
+ while total is None or done < total:
33
+ end = done + chunk_bytes - 1 if total is None else min(done + chunk_bytes, total) - 1
34
+ before = done
35
+ try:
36
+ with _open(url, done, end, timeout) as response:
37
+ if response.status == 200:
38
+ if done:
39
+ restarts += 1
40
+ if restarts > MAX_FULL_RESTARTS:
41
+ raise DownloadError(f"{url} ignores Range requests and broke {restarts} times; giving up.")
42
+ out.seek(0)
43
+ out.truncate()
44
+ done = before = 0
45
+ length = response.headers.get("Content-Length")
46
+ total = int(length) if length else None
47
+ elif response.status == 206:
48
+ total = int(response.headers["Content-Range"].rsplit("/", 1)[1])
49
+ else:
50
+ raise DownloadError(f"{url} answered HTTP {response.status} to a Range request.")
51
+ out.seek(done)
52
+ while data := response.read1(READ_BYTES):
53
+ out.write(data)
54
+ done += len(data)
55
+ if progress:
56
+ progress(done, total or 0)
57
+ if response.status == 200 and total is None:
58
+ total = done
59
+ except urllib.error.HTTPError as exc:
60
+ exc.close()
61
+ if exc.code < 500 and exc.code not in RETRYABLE_HTTP:
62
+ raise DownloadError(f"{url} answered HTTP {exc.code}.") from exc
63
+ error = exc
64
+ except (OSError, http.client.HTTPException) as exc:
65
+ error = exc
66
+ else:
67
+ error = None
68
+ if done > before:
69
+ idle = 0
70
+ elif error is not None or done < (total or 0):
71
+ idle += 1
72
+ if idle >= attempts:
73
+ raise DownloadError(f"Download stopped at {done // 1048576} of {(total or 0) // 1048576} MB after "
74
+ f"{attempts} attempts without progress: {error or 'empty response'}")
75
+ sleep(min(2 ** idle, 30))
76
+ out.flush()
77
+ out.seek(0)
78
+ return done
package/web_fetch.py CHANGED
@@ -6,7 +6,14 @@ e do OpenCode (`packages/opencode/src/tool/webfetch.ts`: limite de 5 MB, User-Ag
6
6
  renderizada: melhor resposta 18 × 18 (1 empate), respondeu certo 22 × 16, caracteres devolvidos 35 mil × 72 mil;
7
7
  3,6 s de mediana, navegador em 7 de 37 (páginas montadas por JavaScript). Repetições da mesma URL nos transcripts:
8
8
  92 de 124 dentro de 24 h (PAGE_TTL_S).
9
+
10
+ Páginas acima de PAGE_MAX_CHARS (14,6% do cache, 10,4% das chamadas reais em 2026-10-08) não são mais cortadas no
11
+ começo: o texto inteiro fica no cache e o modelo lê os pedaços mais parecidos com o pedido, até SELECT_BUDGET_CHARS, na
12
+ ordem da página. Medido em 18 páginas reais e 41 perguntas: trechos depois do corte 0/28 → 13/28, cabeça 6/13 → 5/13,
13
+ latência mediana 3,3 s → 3,1 s. Abaixo do limite a página vai inteira (seleção ali empatou em qualidade e piorou a
14
+ cauda de latência; docs/BACKLOG.md).
9
15
  """
16
+ import math
10
17
  import codecs
11
18
  import http.client
12
19
  import ipaddress
@@ -25,6 +32,10 @@ import research_cache
25
32
  CACHE_NAMESPACE = "web_fetch"
26
33
  PAGE_TTL_S = 86400
27
34
  PAGE_MAX_CHARS = 60_000
35
+ PAGE_STORE_MAX_CHARS = 1_000_000
36
+ SELECT_BUDGET_CHARS = 15_000
37
+ CHUNK_CHARS = 2_000
38
+ PAGE_FORMAT = 2
28
39
  MAX_DOWNLOAD_BYTES = 5 * 1024 * 1024
29
40
  MAX_REDIRECTS = 5
30
41
  HTTP_TIMEOUT_S = 20
@@ -201,7 +212,8 @@ def read_page(url, deadline, render):
201
212
  """Page text (Markdown) from the 24 h cache, HTTP + trafilatura, or `render(url, deadline)` when HTTP comes thin."""
202
213
  url = normalize(url)
203
214
  for entry in research_cache.candidates(CACHE_NAMESPACE, url, limit=1):
204
- if entry.get("query") == url and time.time() - entry["saved_at"] < PAGE_TTL_S:
215
+ if (entry.get("query") == url and time.time() - entry["saved_at"] < PAGE_TTL_S
216
+ and entry["result"].get("format") == PAGE_FORMAT):
205
217
  return {**entry["result"], "cached": True, "fetched_at": entry["saved_at"]}
206
218
  final_url, kind, charset, body = fetch_http(url, deadline)
207
219
  rendered, render_error = False, None
@@ -223,12 +235,57 @@ def read_page(url, deadline, render):
223
235
  if len(text.strip()) < MIN_CHARS:
224
236
  detail = f"; browser failed: {render_error}" if render_error else ", not even when rendered in the browser"
225
237
  raise FetchError(f"Page without readable text ({final_url}){detail}.")
226
- page = {"url": url, "final_url": final_url, "text": text[:PAGE_MAX_CHARS], "truncated": len(text) > PAGE_MAX_CHARS,
227
- "rendered": rendered}
238
+ page = {"url": url, "final_url": final_url, "text": text[:PAGE_STORE_MAX_CHARS], "chars": len(text),
239
+ "truncated": len(text) > PAGE_STORE_MAX_CHARS, "rendered": rendered, "format": PAGE_FORMAT}
228
240
  saved_at = research_cache.put(CACHE_NAMESPACE, url, page, {})
229
241
  return {**page, "cached": False, "fetched_at": saved_at or time.time(), "render_error": render_error}
230
242
 
231
243
 
244
+ def chunks(text, size=CHUNK_CHARS):
245
+ """Consecutive pieces of about size characters, cut at a line break in the second half of each piece."""
246
+ parts, start = [], 0
247
+ while start < len(text):
248
+ end = min(len(text), start + size)
249
+ if end < len(text):
250
+ cut = text.rfind("\n", start + size // 2, end)
251
+ end = cut + 1 if cut > start else end
252
+ parts.append(text[start:end])
253
+ start = end
254
+ return parts
255
+
256
+
257
+ def _cosine(a, b):
258
+ norm = math.sqrt(sum(x * x for x in a)) * math.sqrt(sum(y * y for y in b))
259
+ return sum(x * y for x, y in zip(a, b)) / norm if norm else 0.0
260
+
261
+
262
+ def select(text, prompt, embed, budget=SELECT_BUDGET_CHARS):
263
+ """Text the model reads: the whole page up to PAGE_MAX_CHARS; above it, the pieces most similar to the prompt up
264
+ to budget characters, in page order, with [...] where pieces were skipped. embed(texts) returns one vector per
265
+ text and raises when the embedding model is unavailable. Returns (text, number of pieces kept or None)."""
266
+ if len(text) <= PAGE_MAX_CHARS:
267
+ return text, None
268
+ parts = chunks(text)
269
+ vectors = embed(parts + [prompt])
270
+ if len(vectors) != len(parts) + 1:
271
+ raise RuntimeError("The embedding model returned a different number of vectors than page pieces.")
272
+ query = vectors[-1]
273
+ ranked = sorted(range(len(parts)), key=lambda i: -_cosine(vectors[i], query))
274
+ kept, size = [], 0
275
+ for i in ranked:
276
+ if size + len(parts[i]) <= budget:
277
+ kept.append(i)
278
+ size += len(parts[i])
279
+ kept.sort()
280
+ pieces, last = [], None
281
+ for i in kept:
282
+ if last is not None and i != last + 1:
283
+ pieces.append("\n\n[...]\n\n")
284
+ pieces.append(parts[i])
285
+ last = i
286
+ return "".join(pieces), len(kept)
287
+
288
+
232
289
  def answer(model, prompt, page, deadline):
233
290
  response = model_client.fetch("/v1/chat/completions", model_client.get_token(), method="POST",
234
291
  timeout=min(90, _left(deadline)), body={
@@ -21,9 +21,17 @@ _MAX_LINES_BEFORE_ROTATE = 5000
21
21
  _LOCK = threading.Lock()
22
22
 
23
23
 
24
- def record(tier_name, success):
24
+ OUTCOMES = ("ok", "failed", "rate_limited", "captcha", "paused")
25
+
26
+
27
+ def record(tier_name, success, outcome=None):
28
+ """One tier call: outcome tells a provider 429 (rate_limited), a captcha and a self-imposed quota pause (paused,
29
+ left out of success rates) apart from an ordinary failure, so quota pressure can be read from the log."""
30
+ outcome = outcome or ("ok" if success else "failed")
31
+ if outcome not in OUTCOMES:
32
+ raise ValueError(f"Unknown web search outcome: {outcome}")
25
33
  os.makedirs(os.path.dirname(HEALTH_PATH), exist_ok=True)
26
- entry = {"ts": time.time(), "tier": tier_name, "success": bool(success)}
34
+ entry = {"ts": time.time(), "tier": tier_name, "success": bool(success), "outcome": outcome}
27
35
  with _LOCK:
28
36
  with open(HEALTH_PATH, "a", encoding="utf-8") as f:
29
37
  f.write(json.dumps(entry, ensure_ascii=False) + "\n")
@@ -67,7 +75,7 @@ def _read_recent_records():
67
75
 
68
76
 
69
77
  def recent_success_rate(tier_name, window=DEFAULT_WINDOW):
70
- records = [r for r in _read_recent_records() if r.get("tier") == tier_name]
78
+ records = [r for r in _read_recent_records() if r.get("tier") == tier_name and r.get("outcome") != "paused"]
71
79
  if not records:
72
80
  return None
73
81
  records = records[-window:]
@@ -79,7 +87,7 @@ def summary_for_tiers(tier_names, window=DEFAULT_WINDOW):
79
87
  all_records = _read_recent_records()
80
88
  summary = {}
81
89
  for name in tier_names:
82
- records = [r for r in all_records if r.get("tier") == name][-window:]
90
+ records = [r for r in all_records if r.get("tier") == name and r.get("outcome") != "paused"][-window:]
83
91
  if not records:
84
92
  summary[name] = None
85
93
  else: