@allansantos-dev/smart-tool 0.0.0-stage → 0.9.0
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 +23 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -3
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +82 -0
- package/browser_service_client.py +232 -0
- package/capabilities.py +63 -0
- package/client_hooks.py +192 -0
- package/code_graph.py +403 -0
- package/code_graph_java.mjs +49 -0
- package/code_graph_js.cjs +135 -0
- package/config.py +241 -0
- package/daemon_launcher.py +165 -0
- package/directory_watch.py +131 -0
- package/document_text.py +69 -0
- package/duplicates.py +313 -0
- package/embedding_cache.py +99 -0
- package/endpoint_sync.py +179 -0
- package/folder_picker.py +73 -0
- package/freshness.py +278 -0
- package/gateway.py +220 -0
- package/hook_decision.py +96 -0
- package/hooks/pretool_router.py +45 -0
- package/index_inventory.py +75 -0
- package/index_profile.py +167 -0
- package/index_scope.py +458 -0
- package/index_views.py +121 -0
- package/indexer.py +878 -0
- package/install.py +251 -0
- package/install_runtime.py +249 -0
- package/integration_metrics.py +82 -0
- package/local_embedder.py +172 -0
- package/model_adapters/__init__.py +0 -0
- package/model_adapters/openai_compatible.py +57 -0
- package/model_catalog.py +74 -0
- package/model_client.py +38 -0
- package/model_defaults.py +70 -0
- package/package.json +35 -6
- package/paths.py +6 -0
- package/project_identity.py +42 -0
- package/project_insights_ui.py +38 -0
- package/project_maps_ui.py +96 -0
- package/project_monitor.py +204 -0
- package/project_scheduler.py +61 -0
- package/project_storage.py +165 -0
- package/project_store.py +220 -0
- package/projects_ui.py +146 -0
- package/requirements.txt +8 -0
- package/research_cache.py +171 -0
- package/research_metrics.py +110 -0
- package/router.py +582 -0
- package/secret_store.py +121 -0
- package/setup_ui.py +986 -0
- package/site_memory.py +53 -0
- package/smart_tool_daemon.py +3604 -0
- package/tray.py +245 -0
- package/update_check.py +22 -0
- package/usage_meter.py +124 -0
- package/version.py +18 -0
- package/view_compare.py +68 -0
- package/web_adapters/browser/browser_service.py +302 -0
- package/web_adapters/browser/crawl4ai_search.py +236 -0
- package/web_adapters/browser/requirements.txt +2 -0
- package/web_adapters/node/adapter-common.mjs +40 -0
- package/web_adapters/node/ddg-search.mjs +89 -0
- package/web_adapters/node/open-websearch-adapter.mjs +77 -0
- package/web_adapters/node/package-lock.json +2720 -0
- package/web_adapters/node/package.json +12 -0
- package/web_adapters/node/wikipedia-search.mjs +44 -0
- package/web_document_graph.py +71 -0
- package/web_fetch.py +242 -0
- package/web_search_adapters.py +193 -0
- package/web_search_ddgs.py +42 -0
- package/web_search_external.py +288 -0
- package/web_search_health.py +88 -0
package/install.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import argparse
|
|
3
|
+
import filecmp
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import urllib.request
|
|
11
|
+
import venv
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import paths
|
|
15
|
+
|
|
16
|
+
SOURCE = Path(__file__).resolve().parent
|
|
17
|
+
DEFAULT_TARGET = Path(os.environ.get("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "Programs" / "SmartTool"
|
|
18
|
+
MANIFEST = ".install-manifest.json"
|
|
19
|
+
REGISTRY = Path(paths.DATA_DIR) / "daemon.json"
|
|
20
|
+
BLOCKED = {"__pycache__", ".token-guard", ".git", ".pytest_cache", ".data", ".artifacts", ".claude", ".vscode",
|
|
21
|
+
".impeccable", "tests", "sessions", "results", "dist", "node_modules", ".node-runtime", ".venv",
|
|
22
|
+
".scratch", ".memory", "docs", "reports", "site", "design", "release", "npm", ".github", "AGENTS.md", "CLAUDE.md", "DESIGN.md", "PRODUCT.md", "install.py", ".gitignore", ".gitattributes", MANIFEST}
|
|
23
|
+
PACKAGE_ONLY = {"bin", "package.json", ".npmignore"}
|
|
24
|
+
REQUIRED_TOOLS = {"smart_search", "smart_search_result", "web_search", "project_manage"}
|
|
25
|
+
_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
|
+
def ships(rel):
|
|
33
|
+
parts = Path(rel).parts
|
|
34
|
+
*folders, name = parts
|
|
35
|
+
return (parts[0] not in PACKAGE_ONLY
|
|
36
|
+
and not any(part in BLOCKED or part.startswith("_cdp_") for part in folders)
|
|
37
|
+
and name not in BLOCKED and not name.endswith((".log", ".pyc", ".png")))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def source_files():
|
|
41
|
+
files = []
|
|
42
|
+
for current, dirs, names in os.walk(SOURCE):
|
|
43
|
+
dirs[:] = sorted(d for d in dirs if d not in BLOCKED and not d.startswith("_cdp_"))
|
|
44
|
+
files += [rel for rel in ((Path(current) / name).relative_to(SOURCE).as_posix() for name in sorted(names))
|
|
45
|
+
if ships(rel)]
|
|
46
|
+
return files
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def python_paths(target):
|
|
50
|
+
scripts = target / ".venv" / "Scripts"
|
|
51
|
+
return scripts / "python.exe", scripts / "pythonw.exe"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def powershell(script, env=None, timeout=30):
|
|
55
|
+
return subprocess.run(["powershell.exe", "-NoProfile", "-Command", script], env={**os.environ, **(env or {})},
|
|
56
|
+
capture_output=True, text=True, timeout=timeout)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def stop_processes(target):
|
|
60
|
+
env = {"SMART_TRAY": str(target / "tray.py"), "SMART_DAEMON": str(target / "smart_tool_daemon.py")}
|
|
61
|
+
powershell(_FIND + " | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }", env=env)
|
|
62
|
+
deadline = time.monotonic() + 10
|
|
63
|
+
while True:
|
|
64
|
+
left = powershell("@(" + _FIND + ").Count", env=env).stdout.strip()
|
|
65
|
+
if left == "0":
|
|
66
|
+
return
|
|
67
|
+
if time.monotonic() > deadline:
|
|
68
|
+
raise RuntimeError(f"Could not close the running Smart Tool ({left or '?'} process(es) still active).")
|
|
69
|
+
time.sleep(0.5)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def wait_port_free():
|
|
73
|
+
deadline = time.monotonic() + 15
|
|
74
|
+
while time.monotonic() < deadline and _daemon_up():
|
|
75
|
+
time.sleep(0.5)
|
|
76
|
+
if _daemon_up():
|
|
77
|
+
raise RuntimeError("Another Smart Tool daemon is still active on the registered port; close it before updating.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _daemon_up():
|
|
81
|
+
try:
|
|
82
|
+
url = json.loads(REGISTRY.read_text(encoding="utf-8"))["url"]
|
|
83
|
+
with _OPENER.open(url + "/health", timeout=1) as response:
|
|
84
|
+
return response.status == 200
|
|
85
|
+
except (OSError, ValueError, KeyError):
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def copy_files(target, files, backup, changed, removed):
|
|
90
|
+
previous = set(json.loads((target / MANIFEST).read_text(encoding="utf-8"))["files"]) if (target / MANIFEST).is_file() else set()
|
|
91
|
+
if (target / MANIFEST).is_file():
|
|
92
|
+
shutil.copy2(target / MANIFEST, backup / MANIFEST)
|
|
93
|
+
for rel in files:
|
|
94
|
+
source, dest = SOURCE / rel, target / rel
|
|
95
|
+
if dest.is_file() and filecmp.cmp(source, dest, shallow=False):
|
|
96
|
+
continue
|
|
97
|
+
if dest.is_file():
|
|
98
|
+
(backup / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
shutil.copy2(dest, backup / rel)
|
|
100
|
+
changed.append(rel)
|
|
101
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
shutil.copy2(source, dest)
|
|
103
|
+
for rel in sorted(previous - set(files)):
|
|
104
|
+
if (target / rel).is_file():
|
|
105
|
+
(backup / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
shutil.move(target / rel, backup / rel)
|
|
107
|
+
removed.append(rel)
|
|
108
|
+
(target / MANIFEST).write_text(json.dumps({"installed_at": time.time(), "source": str(SOURCE), "files": files},
|
|
109
|
+
indent=1), encoding="utf-8")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def restore(target, backup, changed, removed):
|
|
113
|
+
for rel in changed:
|
|
114
|
+
if (backup / rel).is_file():
|
|
115
|
+
shutil.copy2(backup / rel, target / rel)
|
|
116
|
+
else:
|
|
117
|
+
(target / rel).unlink(missing_ok=True)
|
|
118
|
+
for rel in removed:
|
|
119
|
+
if (backup / rel).is_file():
|
|
120
|
+
(target / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
shutil.move(backup / rel, target / rel)
|
|
122
|
+
if (backup / MANIFEST).is_file():
|
|
123
|
+
shutil.copy2(backup / MANIFEST, target / MANIFEST)
|
|
124
|
+
else:
|
|
125
|
+
(target / MANIFEST).unlink(missing_ok=True)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def freeze(python):
|
|
129
|
+
if not python.exists():
|
|
130
|
+
return None
|
|
131
|
+
return subprocess.run([str(python), "-m", "pip", "freeze", "--disable-pip-version-check"],
|
|
132
|
+
check=True, capture_output=True, text=True).stdout
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def restore_packages(python, frozen, backup):
|
|
136
|
+
if frozen is None:
|
|
137
|
+
return
|
|
138
|
+
(backup / "requirements.frozen.txt").write_text(frozen, encoding="utf-8")
|
|
139
|
+
subprocess.run([str(python), "-m", "pip", "install", "--disable-pip-version-check", "-q", "-r",
|
|
140
|
+
str(backup / "requirements.frozen.txt")], check=True)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def install_dependencies(target, python):
|
|
144
|
+
if not python.exists():
|
|
145
|
+
venv.create(python.parent.parent, with_pip=True)
|
|
146
|
+
subprocess.run([str(python), "-m", "pip", "install", "--disable-pip-version-check", "-q", "-r",
|
|
147
|
+
str(target / "requirements.txt")], check=True)
|
|
148
|
+
subprocess.run([str(python), str(target / "install_runtime.py")], check=True)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def start(target, python, pythonw):
|
|
152
|
+
subprocess.run([str(python), "-c", "import autostart, json; print(json.dumps(autostart.install()))"],
|
|
153
|
+
cwd=target, check=True, capture_output=True, text=True, timeout=60)
|
|
154
|
+
subprocess.Popen([str(pythonw if pythonw.exists() else python), str(target / "tray.py")], cwd=target,
|
|
155
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
156
|
+
creationflags=getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NO_WINDOW", 0))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def verify(target, python):
|
|
160
|
+
script = ("import json,urllib.request,daemon_launcher; url=daemon_launcher.ensure_daemon_running(wait_seconds=30); "
|
|
161
|
+
"body=json.dumps({'jsonrpc':'2.0','id':1,'method':'tools/list','params':{}}).encode(); "
|
|
162
|
+
"req=urllib.request.Request(url+'/mcp',data=body,method='POST',headers={'Content-Type':'application/json'}); "
|
|
163
|
+
"opener=urllib.request.build_opener(urllib.request.ProxyHandler({})); "
|
|
164
|
+
"print(json.dumps(sorted(r['name'] for r in json.load(opener.open(req,timeout=10))['result']['tools'])))")
|
|
165
|
+
result = subprocess.run([str(python), "-c", script], cwd=target, capture_output=True, text=True, timeout=60)
|
|
166
|
+
if result.returncode:
|
|
167
|
+
raise RuntimeError("Daemon did not respond: " + ((result.stderr or result.stdout).strip().splitlines() or ["no output"])[-1])
|
|
168
|
+
missing = REQUIRED_TOOLS - set(json.loads(result.stdout.strip().splitlines()[-1]))
|
|
169
|
+
if missing:
|
|
170
|
+
raise RuntimeError("Missing MCP tools: " + ", ".join(sorted(missing)))
|
|
171
|
+
pid = json.loads(REGISTRY.read_text(encoding="utf-8"))["pid"]
|
|
172
|
+
command = powershell(f"(Get-CimInstance Win32_Process -Filter 'ProcessId = {int(pid)}').CommandLine").stdout
|
|
173
|
+
if str(target / "smart_tool_daemon.py").lower() not in command.lower():
|
|
174
|
+
raise RuntimeError(f"The active daemon (pid {pid}) does not belong to this installation: {command.strip()[:200]}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
MIN_PYTHON = (3, 11)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main():
|
|
181
|
+
if sys.version_info < MIN_PYTHON:
|
|
182
|
+
raise SystemExit(f"Smart Tool needs Python {'.'.join(map(str, MIN_PYTHON))} or newer; this is "
|
|
183
|
+
f"{sys.version.split()[0]} ({sys.executable}).")
|
|
184
|
+
parser = argparse.ArgumentParser(description="Install or update Smart Tool over the existing installation.")
|
|
185
|
+
parser.add_argument("--target", type=Path, default=DEFAULT_TARGET)
|
|
186
|
+
parser.add_argument("--skip-deps", action="store_true", help="do not run pip or install_runtime.py")
|
|
187
|
+
parser.add_argument("--no-start", action="store_true", help="do not restart or check the daemon")
|
|
188
|
+
parser.add_argument("--skip-tests", action="store_true",
|
|
189
|
+
help="install without running tests/run.py (npm package, whose tests ran in CI; emergency only otherwise)")
|
|
190
|
+
args = parser.parse_args()
|
|
191
|
+
target = args.target.resolve()
|
|
192
|
+
if target == SOURCE:
|
|
193
|
+
raise SystemExit("The target cannot be the source folder itself.")
|
|
194
|
+
if args.skip_tests:
|
|
195
|
+
print("Tests skipped by --skip-tests.")
|
|
196
|
+
elif not (SOURCE / "tests" / "run.py").is_file():
|
|
197
|
+
raise SystemExit(f"No test suite in {SOURCE}; a packaged install runs with --skip-tests (npx @allansantos-dev/smart-tool install).")
|
|
198
|
+
else:
|
|
199
|
+
interpreter = python_paths(target)[0] if python_paths(target)[0].is_file() else Path(sys.executable)
|
|
200
|
+
tests = subprocess.run([str(interpreter), str(SOURCE / "tests" / "run.py")], capture_output=True, text=True,
|
|
201
|
+
encoding="utf-8", errors="replace")
|
|
202
|
+
print(tests.stdout.rstrip())
|
|
203
|
+
if tests.stderr.strip():
|
|
204
|
+
print(tests.stderr.rstrip()[-3000:], file=sys.stderr)
|
|
205
|
+
if tests.returncode:
|
|
206
|
+
raise SystemExit("Tests failed; installation aborted without touching the installed version.")
|
|
207
|
+
python, pythonw = python_paths(target)
|
|
208
|
+
files = source_files()
|
|
209
|
+
existed = (target / "smart_tool_daemon.py").is_file()
|
|
210
|
+
print(("Updating" if existed else "Installing") + f" Smart Tool at {target} ({len(files)} files from {SOURCE})")
|
|
211
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
stop_processes(target)
|
|
213
|
+
if not args.no_start:
|
|
214
|
+
wait_port_free()
|
|
215
|
+
backup = target.parent / f".smart-tool-backup-{time.strftime('%Y%m%d-%H%M%S')}"
|
|
216
|
+
backup.mkdir()
|
|
217
|
+
changed, removed, frozen, deps_touched = [], [], None, False
|
|
218
|
+
try:
|
|
219
|
+
copy_files(target, files, backup, changed, removed)
|
|
220
|
+
print(f"Files changed: {len(changed)}; removed as obsolete: {len(removed)}")
|
|
221
|
+
if not args.skip_deps:
|
|
222
|
+
frozen = freeze(python)
|
|
223
|
+
deps_touched = True
|
|
224
|
+
install_dependencies(target, python)
|
|
225
|
+
if not args.no_start:
|
|
226
|
+
start(target, python, pythonw)
|
|
227
|
+
verify(target, python)
|
|
228
|
+
except BaseException as exc:
|
|
229
|
+
print(f"Failed: {exc}. Restoring the previous version.", file=sys.stderr)
|
|
230
|
+
problems = []
|
|
231
|
+
steps = [lambda: stop_processes(target), lambda: restore(target, backup, changed, removed)]
|
|
232
|
+
if deps_touched:
|
|
233
|
+
steps.append(lambda: restore_packages(python, frozen, backup))
|
|
234
|
+
if existed and not args.no_start:
|
|
235
|
+
steps.append(lambda: start(target, python, pythonw))
|
|
236
|
+
for step in steps:
|
|
237
|
+
try:
|
|
238
|
+
step()
|
|
239
|
+
except Exception as error:
|
|
240
|
+
problems.append(str(error))
|
|
241
|
+
if problems:
|
|
242
|
+
print(f"Incomplete rollback; backup kept at {backup}: " + " | ".join(problems), file=sys.stderr)
|
|
243
|
+
raise SystemExit(1)
|
|
244
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
245
|
+
raise SystemExit(1)
|
|
246
|
+
shutil.rmtree(backup)
|
|
247
|
+
print("Smart Tool ready." if not args.no_start else "Files installed; daemon not restarted (--no-start).")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
main()
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Instala as dependências opcionais de busca dentro da pasta da Smart Tool.
|
|
3
|
+
|
|
4
|
+
Uso: python install_runtime.py [--check]
|
|
5
|
+
Não altera ~/.claude nem instala pacotes npm globalmente.
|
|
6
|
+
"""
|
|
7
|
+
import argparse
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import time
|
|
17
|
+
import urllib.request
|
|
18
|
+
import venv
|
|
19
|
+
import zipfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
ROOT = Path(__file__).resolve().parent
|
|
24
|
+
NODE_DIR = ROOT / "web_adapters" / "node"
|
|
25
|
+
BROWSER_DIR = ROOT / "web_adapters" / "browser"
|
|
26
|
+
BROWSER_PYTHON = BROWSER_DIR / ".venv" / "Scripts" / "python.exe"
|
|
27
|
+
OPEN_WEBSEARCH_CLI = NODE_DIR / "node_modules" / "open-websearch" / "build" / "index.js"
|
|
28
|
+
TYPESCRIPT_PARSER = NODE_DIR / "node_modules" / "typescript" / "lib" / "typescript.js"
|
|
29
|
+
JAVA_PARSER = NODE_DIR / 'node_modules' / 'java-parser' / 'src' / 'index.js'
|
|
30
|
+
ANGULAR_PARSER = NODE_DIR / 'node_modules' / '@angular' / 'compiler' / 'fesm2022' / 'compiler.mjs'
|
|
31
|
+
NODE_RUNTIME = NODE_DIR / ".node-runtime"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _node_supported(node):
|
|
35
|
+
"""Aceita apenas versoes suportadas pelo @angular/compiler 20.3.0."""
|
|
36
|
+
try:
|
|
37
|
+
result = subprocess.run([str(node), "--version"], capture_output=True, text=True,
|
|
38
|
+
timeout=10, check=False)
|
|
39
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
40
|
+
return False
|
|
41
|
+
match = re.fullmatch(r"v(\d+)\.(\d+)\.\d+", result.stdout.strip())
|
|
42
|
+
if result.returncode or not match:
|
|
43
|
+
return False
|
|
44
|
+
major, minor = map(int, match.groups())
|
|
45
|
+
return (major == 20 and minor >= 19) or (major == 22 and minor >= 12) or major >= 24
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _node_commands():
|
|
49
|
+
local_node, local_npm = NODE_RUNTIME / "node.exe", NODE_RUNTIME / "npm.cmd"
|
|
50
|
+
if NODE_RUNTIME.exists():
|
|
51
|
+
if local_node.is_file() and local_npm.is_file() and _node_supported(local_node):
|
|
52
|
+
return str(local_node), str(local_npm)
|
|
53
|
+
return None, None
|
|
54
|
+
node = shutil.which("node")
|
|
55
|
+
npm = shutil.which("npm.cmd") or shutil.which("npm")
|
|
56
|
+
return (node, npm) if node and npm and _node_supported(node) else (None, None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _install_node():
|
|
60
|
+
"""Baixa o Node LTS oficial e confere SHA-256 antes de extrair."""
|
|
61
|
+
print("Downloading Node LTS for the Smart Tool web runtime...", flush=True)
|
|
62
|
+
with urllib.request.urlopen("https://nodejs.org/dist/index.json", timeout=30) as response:
|
|
63
|
+
releases = json.load(response)
|
|
64
|
+
release = next((row for row in releases if row.get("lts") and
|
|
65
|
+
"win-x64-zip" in row.get("files", [])), None)
|
|
66
|
+
if not release:
|
|
67
|
+
raise RuntimeError("Node LTS for Windows x64 not found in the official index.")
|
|
68
|
+
version = release["version"]
|
|
69
|
+
archive = f"node-{version}-win-x64.zip"
|
|
70
|
+
base = f"https://nodejs.org/dist/{version}/"
|
|
71
|
+
with urllib.request.urlopen(base + "SHASUMS256.txt", timeout=30) as response:
|
|
72
|
+
checksums = response.read().decode("ascii")
|
|
73
|
+
expected = next((line.split()[0] for line in checksums.splitlines()
|
|
74
|
+
if line.split()[-1:] == [archive]), None)
|
|
75
|
+
if not expected:
|
|
76
|
+
raise RuntimeError("Official Node checksum not found.")
|
|
77
|
+
with tempfile.TemporaryDirectory(prefix="node-setup-", dir=NODE_DIR) as temp:
|
|
78
|
+
temp_dir = Path(temp)
|
|
79
|
+
zip_path = temp_dir / archive
|
|
80
|
+
digest = hashlib.sha256()
|
|
81
|
+
with urllib.request.urlopen(base + archive, timeout=60) as response, zip_path.open("wb") as out:
|
|
82
|
+
while block := response.read(1024 * 1024):
|
|
83
|
+
digest.update(block)
|
|
84
|
+
out.write(block)
|
|
85
|
+
if digest.hexdigest().lower() != expected.lower():
|
|
86
|
+
raise RuntimeError("Node SHA-256 checksum does not match the official index.")
|
|
87
|
+
unpacked = temp_dir / "runtime"
|
|
88
|
+
unpacked.mkdir()
|
|
89
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
90
|
+
for member in zf.infolist():
|
|
91
|
+
parts = Path(member.filename.replace("\\", "/")).parts
|
|
92
|
+
if parts == (f"node-{version}-win-x64",) and member.is_dir():
|
|
93
|
+
continue
|
|
94
|
+
if len(parts) < 2 or parts[0] != f"node-{version}-win-x64" or ".." in parts:
|
|
95
|
+
raise RuntimeError("Node archive contains an unexpected path.")
|
|
96
|
+
destination = unpacked.joinpath(*parts[1:])
|
|
97
|
+
if member.is_dir():
|
|
98
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
99
|
+
else:
|
|
100
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
with zf.open(member) as source, destination.open("wb") as target:
|
|
102
|
+
shutil.copyfileobj(source, target)
|
|
103
|
+
if not (unpacked / "node.exe").is_file() or not (unpacked / "npm.cmd").is_file():
|
|
104
|
+
raise RuntimeError("Node package does not contain the expected executables.")
|
|
105
|
+
previous = temp_dir / "previous-node-runtime"
|
|
106
|
+
if NODE_RUNTIME.exists():
|
|
107
|
+
os.replace(NODE_RUNTIME, previous)
|
|
108
|
+
try:
|
|
109
|
+
os.replace(unpacked, NODE_RUNTIME)
|
|
110
|
+
except OSError:
|
|
111
|
+
if previous.exists() and not NODE_RUNTIME.exists():
|
|
112
|
+
os.replace(previous, NODE_RUNTIME)
|
|
113
|
+
raise
|
|
114
|
+
print(f"Node {version} installed inside Smart Tool.")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _run(args, *, cwd=None, label="Web dependency", timeout_s=600):
|
|
118
|
+
env = os.environ.copy()
|
|
119
|
+
if NODE_RUNTIME.is_dir():
|
|
120
|
+
env["PATH"] = str(NODE_RUNTIME) + os.pathsep + env.get("PATH", "")
|
|
121
|
+
flags = (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW) if os.name == "nt" else 0
|
|
122
|
+
print(f"{label}: starting...", flush=True)
|
|
123
|
+
started = time.monotonic()
|
|
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.")
|
|
141
|
+
try:
|
|
142
|
+
stdout, stderr = process.communicate(timeout=min(30, remaining))
|
|
143
|
+
break
|
|
144
|
+
except subprocess.TimeoutExpired:
|
|
145
|
+
print(f"{label}: waiting for {int(time.monotonic() - started)}s...", flush=True)
|
|
146
|
+
if process.returncode:
|
|
147
|
+
detail = (stderr or stdout).strip()[-1500:]
|
|
148
|
+
raise RuntimeError(f"{' '.join(map(str, args[:3]))} failed: {detail}")
|
|
149
|
+
print(f"{label}: done.", flush=True)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _check():
|
|
153
|
+
node, _npm = _node_commands()
|
|
154
|
+
status = {
|
|
155
|
+
"node": bool(node),
|
|
156
|
+
"open_websearch": bool(node and OPEN_WEBSEARCH_CLI.is_file()),
|
|
157
|
+
"typescript_parser": bool(node and TYPESCRIPT_PARSER.is_file()),
|
|
158
|
+
"java_parser":bool(node and JAVA_PARSER.is_file()),
|
|
159
|
+
"angular_parser":bool(node and ANGULAR_PARSER.is_file()),
|
|
160
|
+
"browser_python": BROWSER_PYTHON.is_file(),
|
|
161
|
+
"browser_packages": False,
|
|
162
|
+
}
|
|
163
|
+
if status["browser_python"]:
|
|
164
|
+
try:
|
|
165
|
+
result = subprocess.run(
|
|
166
|
+
[str(BROWSER_PYTHON), "-c", "import camoufox, crawl4ai, playwright"],
|
|
167
|
+
capture_output=True, check=False, timeout=30,
|
|
168
|
+
)
|
|
169
|
+
status["browser_packages"] = result.returncode == 0
|
|
170
|
+
except subprocess.TimeoutExpired:
|
|
171
|
+
status["browser_packages"] = False
|
|
172
|
+
return status
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _link_existing_browser(existing, link):
|
|
176
|
+
try:
|
|
177
|
+
os.symlink(existing, link, target_is_directory=True)
|
|
178
|
+
except OSError:
|
|
179
|
+
# Junction não exige Developer Mode nem privilégio de symlink no Windows.
|
|
180
|
+
env = os.environ.copy()
|
|
181
|
+
env["SMART_TOOL_BROWSER_LINK_PATH"] = str(link)
|
|
182
|
+
env["SMART_TOOL_BROWSER_LINK_TARGET"] = str(existing)
|
|
183
|
+
result = subprocess.run(
|
|
184
|
+
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command",
|
|
185
|
+
"New-Item -ItemType Junction -Path $env:SMART_TOOL_BROWSER_LINK_PATH "
|
|
186
|
+
"-Target $env:SMART_TOOL_BROWSER_LINK_TARGET | Out-Null"],
|
|
187
|
+
env=env, capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
188
|
+
)
|
|
189
|
+
if result.returncode or not link.is_dir():
|
|
190
|
+
raise RuntimeError(f"Could not link the existing runtime: {result.stderr.strip()[-600:]}")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def install(reuse_browser_runtime=None):
|
|
194
|
+
if os.name != "nt":
|
|
195
|
+
raise RuntimeError("This browser installer is built for Windows.")
|
|
196
|
+
node, npm = _node_commands()
|
|
197
|
+
if not node or not npm:
|
|
198
|
+
_install_node()
|
|
199
|
+
node, npm = _node_commands()
|
|
200
|
+
if not all(path.is_file() for path in (OPEN_WEBSEARCH_CLI,TYPESCRIPT_PARSER,JAVA_PARSER,ANGULAR_PARSER)):
|
|
201
|
+
_run([npm, "ci", "--omit=dev", "--no-audit", "--no-fund"], cwd=NODE_DIR,
|
|
202
|
+
label="Web adapters and code parser via npm", timeout_s=300)
|
|
203
|
+
if reuse_browser_runtime:
|
|
204
|
+
existing = Path(reuse_browser_runtime).expanduser().resolve()
|
|
205
|
+
if not (existing / "Scripts" / "python.exe").is_file():
|
|
206
|
+
raise RuntimeError(f"Invalid browser environment: {existing}")
|
|
207
|
+
link = BROWSER_DIR / ".venv"
|
|
208
|
+
if link.exists() and link.resolve() != existing:
|
|
209
|
+
raise RuntimeError(f"Another browser environment already exists at {link}")
|
|
210
|
+
if not link.exists():
|
|
211
|
+
_link_existing_browser(existing, link)
|
|
212
|
+
if not BROWSER_PYTHON.is_file():
|
|
213
|
+
venv.create(BROWSER_DIR / ".venv", with_pip=True)
|
|
214
|
+
if not _check()["browser_packages"]:
|
|
215
|
+
_run([str(BROWSER_PYTHON), "-m", "pip", "install", "--disable-pip-version-check",
|
|
216
|
+
"--no-input", "-r", str(BROWSER_DIR / "requirements.txt")],
|
|
217
|
+
label="Camoufox and Crawl4AI via pip", timeout_s=600)
|
|
218
|
+
# Camoufox usa Firefox modificado; Crawl4AI usa Chromium via Playwright.
|
|
219
|
+
if not reuse_browser_runtime:
|
|
220
|
+
_run([str(BROWSER_PYTHON), "-m", "camoufox", "fetch"],
|
|
221
|
+
label="Camoufox browser", timeout_s=600)
|
|
222
|
+
_run([str(BROWSER_PYTHON), "-m", "playwright", "install", "chromium"],
|
|
223
|
+
label="Chromium browser", timeout_s=600)
|
|
224
|
+
status = _check()
|
|
225
|
+
if not all(status.values()):
|
|
226
|
+
raise RuntimeError(f"Incomplete web runtime: {status}")
|
|
227
|
+
print("Smart Tool runtime installed: Node, open-websearch, TypeScript, Java, Angular, Camoufox and Crawl4AI.")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main():
|
|
231
|
+
parser = argparse.ArgumentParser(description="Install/check the Smart Tool web runtime")
|
|
232
|
+
parser.add_argument("--check", action="store_true", help="Show the status without installing")
|
|
233
|
+
parser.add_argument("--reuse-browser-runtime", metavar="FOLDER",
|
|
234
|
+
help="Reuse an existing venv without duplicating files (useful when disk is low)")
|
|
235
|
+
args = parser.parse_args()
|
|
236
|
+
if args.check:
|
|
237
|
+
for name, ready in _check().items():
|
|
238
|
+
print(f"{name}: {'ok' if ready else 'missing'}")
|
|
239
|
+
return 0 if all(_check().values()) else 1
|
|
240
|
+
try:
|
|
241
|
+
install(args.reuse_browser_runtime)
|
|
242
|
+
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
|
243
|
+
print(f"Web runtime installation failed: {exc}", file=sys.stderr)
|
|
244
|
+
return 1
|
|
245
|
+
return 0
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
raise SystemExit(main())
|
|
@@ -0,0 +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}}
|