@allansantos-dev/smart-tool 0.9.0 → 0.9.1

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/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
- 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)
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 = powershell("@(" + _FIND + ").Count", env=env).stdout.strip()
65
- if left == "0":
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 or '?'} process(es) still active).")
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()
@@ -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,71 @@ 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", timeout_s=600):
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
- 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.")
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
- 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)
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"{label}: {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
+ raise RuntimeError(f"{' '.join(map(str, args[:3]))} failed (exit {process.returncode}): "
183
+ + "\n".join(tail)[-1500:])
150
184
 
151
185
 
152
186
  def _check():
@@ -199,7 +233,7 @@ def install(reuse_browser_runtime=None):
199
233
  node, npm = _node_commands()
200
234
  if not all(path.is_file() for path in (OPEN_WEBSEARCH_CLI,TYPESCRIPT_PARSER,JAVA_PARSER,ANGULAR_PARSER)):
201
235
  _run([npm, "ci", "--omit=dev", "--no-audit", "--no-fund"], cwd=NODE_DIR,
202
- label="Web adapters and code parser via npm", timeout_s=300)
236
+ label="Web adapters and code parser via npm", idle_s=300)
203
237
  if reuse_browser_runtime:
204
238
  existing = Path(reuse_browser_runtime).expanduser().resolve()
205
239
  if not (existing / "Scripts" / "python.exe").is_file():
@@ -214,13 +248,13 @@ def install(reuse_browser_runtime=None):
214
248
  if not _check()["browser_packages"]:
215
249
  _run([str(BROWSER_PYTHON), "-m", "pip", "install", "--disable-pip-version-check",
216
250
  "--no-input", "-r", str(BROWSER_DIR / "requirements.txt")],
217
- label="Camoufox and Crawl4AI via pip", timeout_s=600)
251
+ label="Camoufox and Crawl4AI via pip", idle_s=600)
218
252
  # Camoufox usa Firefox modificado; Crawl4AI usa Chromium via Playwright.
219
253
  if not reuse_browser_runtime:
220
- _run([str(BROWSER_PYTHON), "-m", "camoufox", "fetch"],
221
- label="Camoufox browser", timeout_s=600)
254
+ _run([str(BROWSER_PYTHON), str(BROWSER_DIR / "fetch_camoufox.py")], label="Camoufox browser (1.3 GB)",
255
+ idle_s=180, stream=True, attempts=3)
222
256
  _run([str(BROWSER_PYTHON), "-m", "playwright", "install", "chromium"],
223
- label="Chromium browser", timeout_s=600)
257
+ label="Chromium browser", idle_s=180, stream=True, attempts=3)
224
258
  status = _check()
225
259
  if not all(status.values()):
226
260
  raise RuntimeError(f"Incomplete web runtime: {status}")
@@ -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}}