@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.
- package/CHANGELOG.md +62 -0
- package/README.md +27 -2
- package/affected_tests.py +292 -0
- package/client_hooks.py +2 -2
- package/code_graph.py +75 -14
- package/code_graph_java.mjs +4 -1
- package/code_graph_js.cjs +15 -1
- package/code_impact.py +132 -0
- package/config.py +22 -0
- package/doc_check.py +256 -0
- package/duplicates.py +148 -13
- package/edit_preview.py +92 -0
- package/hook_decision.py +96 -2
- package/index_profile.py +1 -1
- package/package.json +1 -1
- package/router.py +4 -2
- package/setup_ui.py +53 -5
- package/smart_tool_daemon.py +108 -21
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +16 -8
- package/web_adapters/browser/ranged_download.py +78 -0
- package/web_fetch.py +60 -3
- package/web_search_health.py +12 -4
|
@@ -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.
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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[:
|
|
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={
|
package/web_search_health.py
CHANGED
|
@@ -21,9 +21,17 @@ _MAX_LINES_BEFORE_ROTATE = 5000
|
|
|
21
21
|
_LOCK = threading.Lock()
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
|
|
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:
|