@allansantos-dev/smart-tool 0.9.0 → 0.9.2
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 +50 -23
- package/NOTICE +32 -32
- package/README.md +117 -116
- package/atomic_io.py +94 -94
- package/bin/smart-tool.mjs +5 -1
- package/client_hooks.py +192 -192
- package/code_graph_java.mjs +49 -49
- package/code_graph_js.cjs +135 -135
- package/duplicates.py +313 -313
- package/embedding_cache.py +99 -99
- package/endpoint_sync.py +179 -179
- package/gateway.py +220 -220
- package/hook_decision.py +96 -96
- package/index_inventory.py +75 -75
- package/indexer.py +878 -878
- package/install.py +113 -11
- package/install_runtime.py +101 -35
- package/integration_metrics.py +82 -82
- package/local_embedder.py +172 -172
- package/model_defaults.py +70 -70
- package/package.json +35 -35
- package/project_insights_ui.py +38 -38
- package/project_monitor.py +204 -204
- package/project_store.py +220 -220
- package/projects_ui.py +146 -146
- package/requirements.txt +8 -8
- package/research_cache.py +171 -171
- package/research_metrics.py +110 -110
- package/router.py +582 -582
- package/secret_store.py +121 -121
- package/setup_ui.py +1011 -986
- package/smart_tool_daemon.py +3604 -3604
- package/version.py +1 -1
- package/web_adapters/browser/fetch_camoufox.py +69 -0
- package/web_adapters/node/ddg-search.mjs +89 -89
- package/web_adapters/node/open-websearch-adapter.mjs +77 -77
- package/web_adapters/node/package.json +12 -12
- package/web_adapters/node/wikipedia-search.mjs +44 -44
- package/web_search_adapters.py +193 -193
- package/web_search_external.py +288 -288
- package/web_search_health.py +88 -88
package/install.py
CHANGED
|
@@ -3,6 +3,7 @@ import argparse
|
|
|
3
3
|
import filecmp
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
|
+
import re
|
|
6
7
|
import shutil
|
|
7
8
|
import subprocess
|
|
8
9
|
import sys
|
|
@@ -15,6 +16,10 @@ import paths
|
|
|
15
16
|
|
|
16
17
|
SOURCE = Path(__file__).resolve().parent
|
|
17
18
|
DEFAULT_TARGET = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "Programs" / "SmartTool"
|
|
19
|
+
TASK_NAME = "SmartTool-Tray"
|
|
20
|
+
ENTRY_SCRIPTS = ("tray.py", "smart_tool_daemon.py", "pretool_router.py")
|
|
21
|
+
STARTUP_SCRIPT = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming"))) / "Microsoft" / "Windows" / \
|
|
22
|
+
"Start Menu" / "Programs" / "Startup" / "SmartTool-Tray.vbs"
|
|
18
23
|
MANIFEST = ".install-manifest.json"
|
|
19
24
|
REGISTRY = Path(paths.DATA_DIR) / "daemon.json"
|
|
20
25
|
BLOCKED = {"__pycache__", ".token-guard", ".git", ".pytest_cache", ".data", ".artifacts", ".claude", ".vscode",
|
|
@@ -23,12 +28,6 @@ BLOCKED = {"__pycache__", ".token-guard", ".git", ".pytest_cache", ".data", ".ar
|
|
|
23
28
|
PACKAGE_ONLY = {"bin", "package.json", ".npmignore"}
|
|
24
29
|
REQUIRED_TOOLS = {"smart_search", "smart_search_result", "web_search", "project_manage"}
|
|
25
30
|
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
26
|
-
_FIND = ("$tray = $env:SMART_TRAY.ToLowerInvariant(); $daemon = $env:SMART_DAEMON.ToLowerInvariant(); "
|
|
27
|
-
"Get-CimInstance Win32_Process -Filter \"Name = 'pythonw.exe' OR Name = 'python.exe'\" | "
|
|
28
|
-
"Where-Object { $_.CommandLine -and ($_.CommandLine.ToLowerInvariant().Contains($tray) -or "
|
|
29
|
-
"$_.CommandLine.ToLowerInvariant().Contains($daemon)) }")
|
|
30
|
-
|
|
31
|
-
|
|
32
31
|
def ships(rel):
|
|
33
32
|
parts = Path(rel).parts
|
|
34
33
|
*folders, name = parts
|
|
@@ -56,16 +55,41 @@ def powershell(script, env=None, timeout=30):
|
|
|
56
55
|
capture_output=True, text=True, timeout=timeout)
|
|
57
56
|
|
|
58
57
|
|
|
58
|
+
def _processes():
|
|
59
|
+
"""(pid, command line) of every python/pythonw process."""
|
|
60
|
+
listed = powershell("Get-CimInstance Win32_Process -Filter \"Name = 'pythonw.exe' OR Name = 'python.exe'\" | "
|
|
61
|
+
"ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }").stdout
|
|
62
|
+
rows = (row.partition("\t") for row in listed.splitlines())
|
|
63
|
+
return [(int(pid), line) for pid, _tab, line in rows if pid.strip().isdigit()]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _script_folder(script):
|
|
67
|
+
path = Path(script)
|
|
68
|
+
return path.parent.parent if path.name == "pretool_router.py" else path.parent
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _same_folder(path, folder):
|
|
72
|
+
"""Compares through resolve(), which turns 8.3 short and mixed spellings into the long name."""
|
|
73
|
+
try:
|
|
74
|
+
return path.resolve() == folder.resolve()
|
|
75
|
+
except OSError:
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
59
79
|
def stop_processes(target):
|
|
60
|
-
|
|
61
|
-
|
|
80
|
+
def running():
|
|
81
|
+
return [pid for pid, line in _processes() for script in _script_paths(line)
|
|
82
|
+
if Path(script).name != "pretool_router.py" and _same_folder(_script_folder(script), target)]
|
|
83
|
+
|
|
84
|
+
for pid in running():
|
|
85
|
+
powershell(f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue")
|
|
62
86
|
deadline = time.monotonic() + 10
|
|
63
87
|
while True:
|
|
64
|
-
left =
|
|
65
|
-
if left
|
|
88
|
+
left = running()
|
|
89
|
+
if not left:
|
|
66
90
|
return
|
|
67
91
|
if time.monotonic() > deadline:
|
|
68
|
-
raise RuntimeError(f"Could not close the running Smart Tool ({left
|
|
92
|
+
raise RuntimeError(f"Could not close the running Smart Tool ({len(left)} process(es) still active).")
|
|
69
93
|
time.sleep(0.5)
|
|
70
94
|
|
|
71
95
|
|
|
@@ -86,6 +110,79 @@ def _daemon_up():
|
|
|
86
110
|
return False
|
|
87
111
|
|
|
88
112
|
|
|
113
|
+
def hook_files():
|
|
114
|
+
claude = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude")
|
|
115
|
+
return claude / "settings.json", Path.home() / ".codex" / "hooks.json"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _script_paths(text):
|
|
119
|
+
"""Absolute paths ending in an entry script, as written in text, tried from every drive letter (paths may hold
|
|
120
|
+
spaces)."""
|
|
121
|
+
script = re.compile(r'[^"\r\n]*?(?:' + "|".join(map(re.escape, ENTRY_SCRIPTS)) + ")")
|
|
122
|
+
for drive in re.finditer(r"[A-Za-z]:[\\/]", text):
|
|
123
|
+
found = script.match(text, drive.start())
|
|
124
|
+
if found:
|
|
125
|
+
yield found.group(0)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _install_folder(script):
|
|
129
|
+
folder = _script_folder(script)
|
|
130
|
+
try:
|
|
131
|
+
return folder.resolve() if (folder / MANIFEST).is_file() and (folder / "smart_tool_daemon.py").is_file() else None
|
|
132
|
+
except OSError:
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _task_definition():
|
|
137
|
+
result = subprocess.run(["schtasks", "/Query", "/TN", TASK_NAME, "/XML"], capture_output=True, text=True,
|
|
138
|
+
timeout=20)
|
|
139
|
+
return result.stdout if result.returncode == 0 else ""
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def other_installs(target):
|
|
143
|
+
"""Installer-made Smart Tool folders other than target, found through what still runs or points at them: tray and
|
|
144
|
+
daemon processes, the logon task or Startup script, and agent hook commands."""
|
|
145
|
+
texts = [line for _pid, line in _processes()] + [_task_definition()]
|
|
146
|
+
texts += [path.read_text(encoding="utf-8", errors="replace") for path in (STARTUP_SCRIPT, *hook_files())
|
|
147
|
+
if path.is_file()]
|
|
148
|
+
folders = {_install_folder(script) for text in texts for script in _script_paths(text)}
|
|
149
|
+
return sorted(folder for folder in folders
|
|
150
|
+
if folder and not _same_folder(folder, target) and not _same_folder(folder, SOURCE))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _repointed(text, folder, target):
|
|
154
|
+
"""text with each spelling of folder found in front of an entry script (long, 8.3 or mixed) replaced by target."""
|
|
155
|
+
for script in set(_script_paths(text)):
|
|
156
|
+
cut = len(Path(script).name) + 1 + (len("hooks") + 1 if Path(script).name == "pretool_router.py" else 0)
|
|
157
|
+
spelled = script[:-cut]
|
|
158
|
+
if _same_folder(Path(spelled), folder):
|
|
159
|
+
text = text.replace(spelled, target.as_posix() if "/" in spelled else str(target))
|
|
160
|
+
return text
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def remove_other_installs(target):
|
|
164
|
+
"""An older installation in another folder keeps the port and the hooks: stop it, point the hooks at target and
|
|
165
|
+
delete its program folder. Its state folders are left alone."""
|
|
166
|
+
removed = []
|
|
167
|
+
stamp = time.strftime("%Y%m%d-%H%M%S")
|
|
168
|
+
for folder in other_installs(target):
|
|
169
|
+
print(f"Older Smart Tool installation found at {folder}; removing it.")
|
|
170
|
+
stop_processes(folder)
|
|
171
|
+
for path in hook_files():
|
|
172
|
+
original = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
173
|
+
text = _repointed(original, folder, target)
|
|
174
|
+
if text != original:
|
|
175
|
+
shutil.copy2(path, path.with_name(f"{path.name}.smart-tool-backup-{stamp}"))
|
|
176
|
+
path.write_text(text, encoding="utf-8")
|
|
177
|
+
print(f"Hooks in {path} now point to {target}.")
|
|
178
|
+
startup = STARTUP_SCRIPT.read_text(encoding="utf-8", errors="replace") if STARTUP_SCRIPT.is_file() else ""
|
|
179
|
+
if any(_same_folder(_script_folder(script), folder) for script in _script_paths(startup)):
|
|
180
|
+
STARTUP_SCRIPT.unlink()
|
|
181
|
+
shutil.rmtree(folder)
|
|
182
|
+
removed.append(folder)
|
|
183
|
+
return removed
|
|
184
|
+
|
|
185
|
+
|
|
89
186
|
def copy_files(target, files, backup, changed, removed):
|
|
90
187
|
previous = set(json.loads((target / MANIFEST).read_text(encoding="utf-8"))["files"]) if (target / MANIFEST).is_file() else set()
|
|
91
188
|
if (target / MANIFEST).is_file():
|
|
@@ -105,6 +202,10 @@ def copy_files(target, files, backup, changed, removed):
|
|
|
105
202
|
(backup / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
106
203
|
shutil.move(target / rel, backup / rel)
|
|
107
204
|
removed.append(rel)
|
|
205
|
+
folder = (target / rel).parent
|
|
206
|
+
while folder != target and not any(folder.iterdir()):
|
|
207
|
+
folder.rmdir()
|
|
208
|
+
folder = folder.parent
|
|
108
209
|
(target / MANIFEST).write_text(json.dumps({"installed_at": time.time(), "source": str(SOURCE), "files": files},
|
|
109
210
|
indent=1), encoding="utf-8")
|
|
110
211
|
|
|
@@ -209,6 +310,7 @@ def main():
|
|
|
209
310
|
existed = (target / "smart_tool_daemon.py").is_file()
|
|
210
311
|
print(("Updating" if existed else "Installing") + f" Smart Tool at {target} ({len(files)} files from {SOURCE})")
|
|
211
312
|
target.mkdir(parents=True, exist_ok=True)
|
|
313
|
+
remove_other_installs(target)
|
|
212
314
|
stop_processes(target)
|
|
213
315
|
if not args.no_start:
|
|
214
316
|
wait_port_free()
|
package/install_runtime.py
CHANGED
|
@@ -5,6 +5,7 @@ Uso: python install_runtime.py [--check]
|
|
|
5
5
|
Não altera ~/.claude nem instala pacotes npm globalmente.
|
|
6
6
|
"""
|
|
7
7
|
import argparse
|
|
8
|
+
import collections
|
|
8
9
|
import hashlib
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
@@ -13,6 +14,7 @@ import shutil
|
|
|
13
14
|
import subprocess
|
|
14
15
|
import sys
|
|
15
16
|
import tempfile
|
|
17
|
+
import threading
|
|
16
18
|
import time
|
|
17
19
|
import urllib.request
|
|
18
20
|
import venv
|
|
@@ -114,39 +116,73 @@ def _install_node():
|
|
|
114
116
|
print(f"Node {version} installed inside Smart Tool.")
|
|
115
117
|
|
|
116
118
|
|
|
117
|
-
def _run(args, *, cwd=None, label="Web dependency",
|
|
119
|
+
def _run(args, *, cwd=None, label="Web dependency", idle_s=600, stream=False, attempts=1):
|
|
120
|
+
"""Runs a step with UTF-8 output and closed stdin (a prompt fails at once instead of hanging); it is stopped only
|
|
121
|
+
after idle_s without any output, and stream shows the output live."""
|
|
118
122
|
env = os.environ.copy()
|
|
123
|
+
env.update(PYTHONUTF8="1", PYTHONIOENCODING="utf-8")
|
|
119
124
|
if NODE_RUNTIME.is_dir():
|
|
120
125
|
env["PATH"] = str(NODE_RUNTIME) + os.pathsep + env.get("PATH", "")
|
|
121
126
|
flags = (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW) if os.name == "nt" else 0
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
process = subprocess.Popen(args, cwd=cwd, env=env, stdout=subprocess.PIPE,
|
|
125
|
-
stderr=subprocess.PIPE, text=True, encoding="utf-8",
|
|
126
|
-
errors="replace", creationflags=flags)
|
|
127
|
-
while True:
|
|
128
|
-
remaining = timeout_s - (time.monotonic() - started)
|
|
129
|
-
if remaining <= 0:
|
|
130
|
-
if os.name == "nt":
|
|
131
|
-
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
132
|
-
capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
|
|
133
|
-
else:
|
|
134
|
-
process.kill()
|
|
135
|
-
try:
|
|
136
|
-
process.communicate(timeout=5)
|
|
137
|
-
except subprocess.TimeoutExpired:
|
|
138
|
-
process.kill()
|
|
139
|
-
process.communicate(timeout=5)
|
|
140
|
-
raise RuntimeError(f"{label} exceeded {timeout_s}s; check network, proxy and access to the package registry.")
|
|
127
|
+
for attempt in range(1, attempts + 1):
|
|
128
|
+
print(f"{label}: starting{f' (attempt {attempt} of {attempts})' if attempt > 1 else ''}...", flush=True)
|
|
141
129
|
try:
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
130
|
+
_run_once(args, cwd, env, flags, label, idle_s, stream)
|
|
131
|
+
print(f"{label}: done.", flush=True)
|
|
132
|
+
return
|
|
133
|
+
except RuntimeError as exc:
|
|
134
|
+
if attempt == attempts:
|
|
135
|
+
raise
|
|
136
|
+
print(f"{exc}; trying again.", flush=True)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _run_once(args, cwd, env, flags, label, idle_s, stream):
|
|
140
|
+
with subprocess.Popen(args, cwd=cwd, env=env, stdin=subprocess.DEVNULL,
|
|
141
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, encoding="utf-8",
|
|
142
|
+
errors="replace", creationflags=flags) as process:
|
|
143
|
+
tail = collections.deque(maxlen=40)
|
|
144
|
+
last_output = [time.monotonic()]
|
|
145
|
+
shown = {"stage": None, "tens": -1}
|
|
146
|
+
|
|
147
|
+
def pump():
|
|
148
|
+
for line in process.stdout:
|
|
149
|
+
line = line.rstrip()
|
|
150
|
+
if not line:
|
|
151
|
+
continue
|
|
152
|
+
tail.append(line)
|
|
153
|
+
last_output[0] = time.monotonic()
|
|
154
|
+
percent = re.match(r"(.*?)(\d{1,3})%$", line)
|
|
155
|
+
if percent:
|
|
156
|
+
stage, tens = percent.group(1), int(percent.group(2)) // 10
|
|
157
|
+
if stage == shown["stage"] and tens <= shown["tens"]:
|
|
158
|
+
continue
|
|
159
|
+
shown.update(stage=stage, tens=tens)
|
|
160
|
+
if stream:
|
|
161
|
+
print(f"{label}: {line}", flush=True)
|
|
162
|
+
|
|
163
|
+
reader = threading.Thread(target=pump, daemon=True)
|
|
164
|
+
reader.start()
|
|
165
|
+
started = noted = time.monotonic()
|
|
166
|
+
while process.poll() is None:
|
|
167
|
+
now = time.monotonic()
|
|
168
|
+
if now - last_output[0] > idle_s:
|
|
169
|
+
if os.name == "nt":
|
|
170
|
+
subprocess.run(["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
171
|
+
capture_output=True, creationflags=subprocess.CREATE_NO_WINDOW)
|
|
172
|
+
else:
|
|
173
|
+
process.kill()
|
|
174
|
+
process.wait(timeout=10)
|
|
175
|
+
raise RuntimeError(f"{label} stopped: no output for {idle_s}s; last output: {tail[-1] if tail else 'none'}")
|
|
176
|
+
if not stream and now - noted >= 30:
|
|
177
|
+
noted = now
|
|
178
|
+
print(f"{label}: waiting for {int(now - started)}s...", flush=True)
|
|
179
|
+
time.sleep(0.5)
|
|
180
|
+
reader.join(timeout=10)
|
|
181
|
+
if process.returncode:
|
|
182
|
+
if not stream:
|
|
183
|
+
for line in list(tail)[-15:]:
|
|
184
|
+
print(f"{label}: {line}", flush=True)
|
|
185
|
+
raise RuntimeError(f"{label} failed (exit {process.returncode}): {tail[-1] if tail else 'no output'}")
|
|
150
186
|
|
|
151
187
|
|
|
152
188
|
def _check():
|
|
@@ -159,6 +195,8 @@ def _check():
|
|
|
159
195
|
"angular_parser":bool(node and ANGULAR_PARSER.is_file()),
|
|
160
196
|
"browser_python": BROWSER_PYTHON.is_file(),
|
|
161
197
|
"browser_packages": False,
|
|
198
|
+
"camoufox_browser": False,
|
|
199
|
+
"chromium_browser": False,
|
|
162
200
|
}
|
|
163
201
|
if status["browser_python"]:
|
|
164
202
|
try:
|
|
@@ -169,9 +207,36 @@ def _check():
|
|
|
169
207
|
status["browser_packages"] = result.returncode == 0
|
|
170
208
|
except subprocess.TimeoutExpired:
|
|
171
209
|
status["browser_packages"] = False
|
|
210
|
+
if status["browser_packages"]:
|
|
211
|
+
status.update(_browser_executables())
|
|
172
212
|
return status
|
|
173
213
|
|
|
174
214
|
|
|
215
|
+
_BROWSERS_PROBE = """
|
|
216
|
+
import json, os
|
|
217
|
+
from camoufox.pkgman import LAUNCH_FILE, OS_NAME, camoufox_path
|
|
218
|
+
from playwright.sync_api import sync_playwright
|
|
219
|
+
found = {}
|
|
220
|
+
try:
|
|
221
|
+
found["camoufox_browser"] = os.path.isfile(os.path.join(camoufox_path(download_if_missing=False), LAUNCH_FILE[OS_NAME]))
|
|
222
|
+
except Exception:
|
|
223
|
+
found["camoufox_browser"] = False
|
|
224
|
+
with sync_playwright() as playwright:
|
|
225
|
+
found["chromium_browser"] = os.path.isfile(playwright.chromium.executable_path)
|
|
226
|
+
print(json.dumps(found))
|
|
227
|
+
"""
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _browser_executables():
|
|
231
|
+
"""Both browsers must exist on disk: a download tool reporting success is not enough."""
|
|
232
|
+
try:
|
|
233
|
+
result = subprocess.run([str(BROWSER_PYTHON), "-c", _BROWSERS_PROBE], capture_output=True, text=True,
|
|
234
|
+
encoding="utf-8", errors="replace", check=False, timeout=60)
|
|
235
|
+
return json.loads(result.stdout.strip().splitlines()[-1])
|
|
236
|
+
except (subprocess.TimeoutExpired, ValueError, IndexError):
|
|
237
|
+
return {"camoufox_browser": False, "chromium_browser": False}
|
|
238
|
+
|
|
239
|
+
|
|
175
240
|
def _link_existing_browser(existing, link):
|
|
176
241
|
try:
|
|
177
242
|
os.symlink(existing, link, target_is_directory=True)
|
|
@@ -199,7 +264,7 @@ def install(reuse_browser_runtime=None):
|
|
|
199
264
|
node, npm = _node_commands()
|
|
200
265
|
if not all(path.is_file() for path in (OPEN_WEBSEARCH_CLI,TYPESCRIPT_PARSER,JAVA_PARSER,ANGULAR_PARSER)):
|
|
201
266
|
_run([npm, "ci", "--omit=dev", "--no-audit", "--no-fund"], cwd=NODE_DIR,
|
|
202
|
-
label="Web adapters and code parser via npm",
|
|
267
|
+
label="Web adapters and code parser via npm", idle_s=300)
|
|
203
268
|
if reuse_browser_runtime:
|
|
204
269
|
existing = Path(reuse_browser_runtime).expanduser().resolve()
|
|
205
270
|
if not (existing / "Scripts" / "python.exe").is_file():
|
|
@@ -214,13 +279,13 @@ def install(reuse_browser_runtime=None):
|
|
|
214
279
|
if not _check()["browser_packages"]:
|
|
215
280
|
_run([str(BROWSER_PYTHON), "-m", "pip", "install", "--disable-pip-version-check",
|
|
216
281
|
"--no-input", "-r", str(BROWSER_DIR / "requirements.txt")],
|
|
217
|
-
label="Camoufox and Crawl4AI via pip",
|
|
282
|
+
label="Camoufox and Crawl4AI via pip", idle_s=600)
|
|
218
283
|
# Camoufox usa Firefox modificado; Crawl4AI usa Chromium via Playwright.
|
|
219
284
|
if not reuse_browser_runtime:
|
|
220
|
-
_run([str(BROWSER_PYTHON), "
|
|
221
|
-
|
|
285
|
+
_run([str(BROWSER_PYTHON), str(BROWSER_DIR / "fetch_camoufox.py")], label="Camoufox browser (1.3 GB)",
|
|
286
|
+
idle_s=180, stream=True, attempts=3)
|
|
222
287
|
_run([str(BROWSER_PYTHON), "-m", "playwright", "install", "chromium"],
|
|
223
|
-
label="Chromium browser",
|
|
288
|
+
label="Chromium browser", idle_s=180, stream=True, attempts=3)
|
|
224
289
|
status = _check()
|
|
225
290
|
if not all(status.values()):
|
|
226
291
|
raise RuntimeError(f"Incomplete web runtime: {status}")
|
|
@@ -234,9 +299,10 @@ def main():
|
|
|
234
299
|
help="Reuse an existing venv without duplicating files (useful when disk is low)")
|
|
235
300
|
args = parser.parse_args()
|
|
236
301
|
if args.check:
|
|
237
|
-
|
|
302
|
+
status = _check()
|
|
303
|
+
for name, ready in status.items():
|
|
238
304
|
print(f"{name}: {'ok' if ready else 'missing'}")
|
|
239
|
-
return 0 if all(
|
|
305
|
+
return 0 if all(status.values()) else 1
|
|
240
306
|
try:
|
|
241
307
|
install(args.reuse_browser_runtime)
|
|
242
308
|
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
package/integration_metrics.py
CHANGED
|
@@ -1,82 +1,82 @@
|
|
|
1
|
-
import collections
|
|
2
|
-
import json
|
|
3
|
-
import os
|
|
4
|
-
import paths
|
|
5
|
-
import threading
|
|
6
|
-
import time
|
|
7
|
-
|
|
8
|
-
import atomic_io
|
|
9
|
-
import router
|
|
10
|
-
|
|
11
|
-
PATH = os.path.join(paths.DATA_DIR, "integration-metrics.jsonl")
|
|
12
|
-
MAX_BYTES = 20 * 1024 * 1024
|
|
13
|
-
_LOCK = threading.Lock()
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def record(event, **fields):
|
|
17
|
-
line = json.dumps({"ts": time.time(), "event": event, **fields}, ensure_ascii=False)
|
|
18
|
-
with _LOCK:
|
|
19
|
-
os.makedirs(os.path.dirname(PATH), exist_ok=True)
|
|
20
|
-
atomic_io.rotate(PATH, MAX_BYTES)
|
|
21
|
-
with open(PATH, "a", encoding="utf-8") as stream:
|
|
22
|
-
stream.write(line + "\n")
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def _read(path, since):
|
|
26
|
-
rows = []
|
|
27
|
-
for candidate in (path + ".1", path):
|
|
28
|
-
try:
|
|
29
|
-
with open(candidate, encoding="utf-8") as stream:
|
|
30
|
-
for line in stream:
|
|
31
|
-
try:
|
|
32
|
-
row = json.loads(line)
|
|
33
|
-
except ValueError:
|
|
34
|
-
continue
|
|
35
|
-
if isinstance(row, dict) and isinstance(row.get("ts"), (int, float)) and row["ts"] >= since:
|
|
36
|
-
rows.append(row)
|
|
37
|
-
except FileNotFoundError:
|
|
38
|
-
continue
|
|
39
|
-
return rows
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def summary(days=7):
|
|
43
|
-
since = time.time() - days * 86400
|
|
44
|
-
events = _read(PATH, since)
|
|
45
|
-
by_client = collections.defaultdict(collections.Counter)
|
|
46
|
-
reasons, rejects = collections.Counter(), collections.Counter()
|
|
47
|
-
for row in events:
|
|
48
|
-
by_client[row.get("client") or "desconhecido"][row.get("event", "?")] += 1
|
|
49
|
-
if row.get("event") == "search" and row.get("single_language_reason"):
|
|
50
|
-
reasons[row["single_language_reason"][:120]] += 1
|
|
51
|
-
if row.get("event") == "reject":
|
|
52
|
-
rejects[row.get("kind", "outro")] += 1
|
|
53
|
-
web = [row for row in events if row.get("event") == "web_search"]
|
|
54
|
-
origin = lambda row: "error" if row.get("error") else row.get("cache", "?")
|
|
55
|
-
by_cache = collections.Counter(origin(row) for row in web)
|
|
56
|
-
elapsed = collections.defaultdict(list)
|
|
57
|
-
for row in web:
|
|
58
|
-
if isinstance(row.get("elapsed_s"), (int, float)):
|
|
59
|
-
elapsed[origin(row)].append(row["elapsed_s"])
|
|
60
|
-
reused = sum(count for status, count in by_cache.items() if status not in ("fresh", "error"))
|
|
61
|
-
web_cache = {
|
|
62
|
-
"calls": len(web), "reused": reused, "reuse_rate": round(reused / len(web), 3) if web else None,
|
|
63
|
-
"by_cache": dict(by_cache),
|
|
64
|
-
"probes": sum(1 for row in web if row.get("probe")),
|
|
65
|
-
"probe_verdicts": dict(collections.Counter(row["verdict"] for row in web if row.get("verdict"))),
|
|
66
|
-
"errors": dict(collections.Counter(row["error"] for row in web if row.get("error"))),
|
|
67
|
-
"reused_volatility": dict(collections.Counter(row.get("volatility") or "?" for row in web
|
|
68
|
-
if origin(row) not in ("fresh", "error"))),
|
|
69
|
-
"bilingual": sum(1 for row in web if row.get("bilingual")),
|
|
70
|
-
"degraded": sum(1 for row in web if row.get("degraded")),
|
|
71
|
-
"with_warnings": sum(1 for row in web if row.get("warnings")),
|
|
72
|
-
"avg_elapsed_s": {status: round(sum(values) / len(values), 2) for status, values in elapsed.items()},
|
|
73
|
-
}
|
|
74
|
-
routed = _read(router.METRICS_PATH, since)
|
|
75
|
-
decisions = collections.defaultdict(collections.Counter)
|
|
76
|
-
for row in routed:
|
|
77
|
-
decisions[row.get("client") or "no client"][row.get("decision", "?")] += 1
|
|
78
|
-
return {"days": days, "events_by_client": {k: dict(v) for k, v in by_client.items()},
|
|
79
|
-
"rejects_by_kind": dict(rejects), "single_language_reasons": dict(reasons.most_common(20)),
|
|
80
|
-
"hook_decisions_by_client": {k: dict(v) for k, v in decisions.items()},
|
|
81
|
-
"web_search_cache": web_cache,
|
|
82
|
-
"files": {"integration": PATH, "router": router.METRICS_PATH}}
|
|
1
|
+
import collections
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import paths
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import atomic_io
|
|
9
|
+
import router
|
|
10
|
+
|
|
11
|
+
PATH = os.path.join(paths.DATA_DIR, "integration-metrics.jsonl")
|
|
12
|
+
MAX_BYTES = 20 * 1024 * 1024
|
|
13
|
+
_LOCK = threading.Lock()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def record(event, **fields):
|
|
17
|
+
line = json.dumps({"ts": time.time(), "event": event, **fields}, ensure_ascii=False)
|
|
18
|
+
with _LOCK:
|
|
19
|
+
os.makedirs(os.path.dirname(PATH), exist_ok=True)
|
|
20
|
+
atomic_io.rotate(PATH, MAX_BYTES)
|
|
21
|
+
with open(PATH, "a", encoding="utf-8") as stream:
|
|
22
|
+
stream.write(line + "\n")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read(path, since):
|
|
26
|
+
rows = []
|
|
27
|
+
for candidate in (path + ".1", path):
|
|
28
|
+
try:
|
|
29
|
+
with open(candidate, encoding="utf-8") as stream:
|
|
30
|
+
for line in stream:
|
|
31
|
+
try:
|
|
32
|
+
row = json.loads(line)
|
|
33
|
+
except ValueError:
|
|
34
|
+
continue
|
|
35
|
+
if isinstance(row, dict) and isinstance(row.get("ts"), (int, float)) and row["ts"] >= since:
|
|
36
|
+
rows.append(row)
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
continue
|
|
39
|
+
return rows
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def summary(days=7):
|
|
43
|
+
since = time.time() - days * 86400
|
|
44
|
+
events = _read(PATH, since)
|
|
45
|
+
by_client = collections.defaultdict(collections.Counter)
|
|
46
|
+
reasons, rejects = collections.Counter(), collections.Counter()
|
|
47
|
+
for row in events:
|
|
48
|
+
by_client[row.get("client") or "desconhecido"][row.get("event", "?")] += 1
|
|
49
|
+
if row.get("event") == "search" and row.get("single_language_reason"):
|
|
50
|
+
reasons[row["single_language_reason"][:120]] += 1
|
|
51
|
+
if row.get("event") == "reject":
|
|
52
|
+
rejects[row.get("kind", "outro")] += 1
|
|
53
|
+
web = [row for row in events if row.get("event") == "web_search"]
|
|
54
|
+
origin = lambda row: "error" if row.get("error") else row.get("cache", "?")
|
|
55
|
+
by_cache = collections.Counter(origin(row) for row in web)
|
|
56
|
+
elapsed = collections.defaultdict(list)
|
|
57
|
+
for row in web:
|
|
58
|
+
if isinstance(row.get("elapsed_s"), (int, float)):
|
|
59
|
+
elapsed[origin(row)].append(row["elapsed_s"])
|
|
60
|
+
reused = sum(count for status, count in by_cache.items() if status not in ("fresh", "error"))
|
|
61
|
+
web_cache = {
|
|
62
|
+
"calls": len(web), "reused": reused, "reuse_rate": round(reused / len(web), 3) if web else None,
|
|
63
|
+
"by_cache": dict(by_cache),
|
|
64
|
+
"probes": sum(1 for row in web if row.get("probe")),
|
|
65
|
+
"probe_verdicts": dict(collections.Counter(row["verdict"] for row in web if row.get("verdict"))),
|
|
66
|
+
"errors": dict(collections.Counter(row["error"] for row in web if row.get("error"))),
|
|
67
|
+
"reused_volatility": dict(collections.Counter(row.get("volatility") or "?" for row in web
|
|
68
|
+
if origin(row) not in ("fresh", "error"))),
|
|
69
|
+
"bilingual": sum(1 for row in web if row.get("bilingual")),
|
|
70
|
+
"degraded": sum(1 for row in web if row.get("degraded")),
|
|
71
|
+
"with_warnings": sum(1 for row in web if row.get("warnings")),
|
|
72
|
+
"avg_elapsed_s": {status: round(sum(values) / len(values), 2) for status, values in elapsed.items()},
|
|
73
|
+
}
|
|
74
|
+
routed = _read(router.METRICS_PATH, since)
|
|
75
|
+
decisions = collections.defaultdict(collections.Counter)
|
|
76
|
+
for row in routed:
|
|
77
|
+
decisions[row.get("client") or "no client"][row.get("decision", "?")] += 1
|
|
78
|
+
return {"days": days, "events_by_client": {k: dict(v) for k, v in by_client.items()},
|
|
79
|
+
"rejects_by_kind": dict(rejects), "single_language_reasons": dict(reasons.most_common(20)),
|
|
80
|
+
"hook_decisions_by_client": {k: dict(v) for k, v in decisions.items()},
|
|
81
|
+
"web_search_cache": web_cache,
|
|
82
|
+
"files": {"integration": PATH, "router": router.METRICS_PATH}}
|