@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -3
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +82 -0
  8. package/browser_service_client.py +232 -0
  9. package/capabilities.py +63 -0
  10. package/client_hooks.py +192 -0
  11. package/code_graph.py +403 -0
  12. package/code_graph_java.mjs +49 -0
  13. package/code_graph_js.cjs +135 -0
  14. package/config.py +241 -0
  15. package/daemon_launcher.py +165 -0
  16. package/directory_watch.py +131 -0
  17. package/document_text.py +69 -0
  18. package/duplicates.py +313 -0
  19. package/embedding_cache.py +99 -0
  20. package/endpoint_sync.py +179 -0
  21. package/folder_picker.py +73 -0
  22. package/freshness.py +278 -0
  23. package/gateway.py +220 -0
  24. package/hook_decision.py +96 -0
  25. package/hooks/pretool_router.py +45 -0
  26. package/index_inventory.py +75 -0
  27. package/index_profile.py +167 -0
  28. package/index_scope.py +458 -0
  29. package/index_views.py +121 -0
  30. package/indexer.py +878 -0
  31. package/install.py +251 -0
  32. package/install_runtime.py +249 -0
  33. package/integration_metrics.py +82 -0
  34. package/local_embedder.py +172 -0
  35. package/model_adapters/__init__.py +0 -0
  36. package/model_adapters/openai_compatible.py +57 -0
  37. package/model_catalog.py +74 -0
  38. package/model_client.py +38 -0
  39. package/model_defaults.py +70 -0
  40. package/package.json +35 -6
  41. package/paths.py +6 -0
  42. package/project_identity.py +42 -0
  43. package/project_insights_ui.py +38 -0
  44. package/project_maps_ui.py +96 -0
  45. package/project_monitor.py +204 -0
  46. package/project_scheduler.py +61 -0
  47. package/project_storage.py +165 -0
  48. package/project_store.py +220 -0
  49. package/projects_ui.py +146 -0
  50. package/requirements.txt +8 -0
  51. package/research_cache.py +171 -0
  52. package/research_metrics.py +110 -0
  53. package/router.py +582 -0
  54. package/secret_store.py +121 -0
  55. package/setup_ui.py +986 -0
  56. package/site_memory.py +53 -0
  57. package/smart_tool_daemon.py +3604 -0
  58. package/tray.py +245 -0
  59. package/update_check.py +22 -0
  60. package/usage_meter.py +124 -0
  61. package/version.py +18 -0
  62. package/view_compare.py +68 -0
  63. package/web_adapters/browser/browser_service.py +302 -0
  64. package/web_adapters/browser/crawl4ai_search.py +236 -0
  65. package/web_adapters/browser/requirements.txt +2 -0
  66. package/web_adapters/node/adapter-common.mjs +40 -0
  67. package/web_adapters/node/ddg-search.mjs +89 -0
  68. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  69. package/web_adapters/node/package-lock.json +2720 -0
  70. package/web_adapters/node/package.json +12 -0
  71. package/web_adapters/node/wikipedia-search.mjs +44 -0
  72. package/web_document_graph.py +71 -0
  73. package/web_fetch.py +242 -0
  74. package/web_search_adapters.py +193 -0
  75. package/web_search_ddgs.py +42 -0
  76. package/web_search_external.py +288 -0
  77. 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}}