@allansantos-dev/smart-tool 0.9.7 → 0.9.9

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 CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.9 - beta
4
+
5
+ - `web_fetch` renders JavaScript pages with [Moli](https://github.com/lexmount/moli) first (a headless browser built
6
+ for agents: layout and paint only when a page needs them; 43 MB download, SHA-256 checked) and starts Chromium
7
+ through Crawl4AI only when Moli fails or comes back thin. On 21 pages HTTP could not read: Moli 1.6 s and 90 MB
8
+ peak (median) against 4.2 s and 581 MB; Moli read 14, Chromium 16, both together 17 (only Chromium read YouTube,
9
+ Google Maps and Airbnb). With several agent sessions reading at once, measured on N simultaneous rendered reads:
10
+ aggregate peak memory 448 → 35 MB (1), 1,788 → 194 MB (3), 2,901 → 634 MB (5), 4.4-4.7 GB → 1.0-1.4 GB (10), and
11
+ the median read time halved (10 at once: 5.0 s → 2.5 s). Pages only Chromium reads now take Moli's 1-4 s more.
12
+ The `web_fetch` metric records which engine read the page and Moli's error when it fell back.
13
+
14
+ ## 0.9.8 - beta
15
+
16
+ - An update could keep running the previous code: npm dates every file in the package 1985-10-26, the installer kept
17
+ that date when copying, and Python reuses a compiled `.pyc` when the source's date and size match, so a file changed
18
+ without changing size (a version bump, a one-character fix) went on running the old bytecode. Reproduced on a real
19
+ reinstall: `version.py` said 0.9.6 and the daemon served 0.9.8. Copied and restored files now get the current time,
20
+ and the program's `__pycache__` folders are cleared after copying and after a rollback (two copies within the same
21
+ second keep the same date). The update to this version already clears any stale bytecode left by earlier ones.
22
+ Reported by the agent-launcher session, which had the same bug.
23
+
3
24
  ## 0.9.7 - beta
4
25
 
5
26
  The hook decides code searches with a fixed rule instead of the router model.
package/NOTICE CHANGED
@@ -15,6 +15,7 @@ Python packages (installation venv, from PyPI)
15
15
 
16
16
  Browser runtime (web_adapters/browser/.venv, from PyPI and the projects' download servers)
17
17
  - camoufox Python package (MIT) and the Camoufox browser, a Firefox fork (MPL-2.0), fetched by `camoufox fetch`
18
+ - Moli (Apache-2.0 OR MIT), https://github.com/lexmount/moli: renders JavaScript pages for web_fetch; downloaded at install time from its GitHub release (SHA-256 checked), with its own license files
18
19
  - crawl4ai (Apache-2.0), https://github.com/unclecode/crawl4ai: page rendering is powered by Crawl4AI
19
20
  - Playwright (Apache-2.0) and the Chromium build it installs (BSD-style, see https://www.chromium.org)
20
21
 
package/README.md CHANGED
@@ -146,4 +146,4 @@ user` / `codex mcp remove smart-tool`, then delete `%LOCALAPPDATA%\Programs\Smar
146
146
 
147
147
  Apache License 2.0. See `LICENSE` and `NOTICE` for third-party components.
148
148
 
149
- Page rendering is powered by [Crawl4AI](https://github.com/unclecode/crawl4ai) (Apache-2.0).
149
+ Page rendering is powered by [Moli](https://github.com/lexmount/moli) (Apache-2.0 / MIT), with [Crawl4AI](https://github.com/unclecode/crawl4ai) (Apache-2.0) as its fallback.
package/install.py CHANGED
@@ -197,6 +197,7 @@ def copy_files(target, files, backup, changed, removed):
197
197
  changed.append(rel)
198
198
  dest.parent.mkdir(parents=True, exist_ok=True)
199
199
  shutil.copy2(source, dest)
200
+ os.utime(dest)
200
201
  for rel in sorted(previous - set(files)):
201
202
  if (target / rel).is_file():
202
203
  (backup / rel).parent.mkdir(parents=True, exist_ok=True)
@@ -210,16 +211,31 @@ def copy_files(target, files, backup, changed, removed):
210
211
  indent=1), encoding="utf-8")
211
212
 
212
213
 
214
+ def clear_bytecode(target):
215
+ """Deletes the program's __pycache__ folders (the virtual environment's are left alone). npm writes the same
216
+ mtime (1985-10-26) into every tarball entry, and Python's bytecode check compares only the source's mtime and
217
+ size: a file changed without changing size kept running the old code after an update (reproduced on 2026-10-10:
218
+ version.py said 0.9.6, the daemon served 0.9.8)."""
219
+ for current, dirs, _files in os.walk(target):
220
+ dirs[:] = [d for d in dirs if d != ".venv"]
221
+ if "__pycache__" in dirs:
222
+ shutil.rmtree(Path(current) / "__pycache__", ignore_errors=True)
223
+ dirs.remove("__pycache__")
224
+
225
+
213
226
  def restore(target, backup, changed, removed):
214
227
  for rel in changed:
215
228
  if (backup / rel).is_file():
216
229
  shutil.copy2(backup / rel, target / rel)
230
+ os.utime(target / rel)
217
231
  else:
218
232
  (target / rel).unlink(missing_ok=True)
219
233
  for rel in removed:
220
234
  if (backup / rel).is_file():
221
235
  (target / rel).parent.mkdir(parents=True, exist_ok=True)
222
236
  shutil.move(backup / rel, target / rel)
237
+ os.utime(target / rel)
238
+ clear_bytecode(target)
223
239
  if (backup / MANIFEST).is_file():
224
240
  shutil.copy2(backup / MANIFEST, target / MANIFEST)
225
241
  else:
@@ -319,6 +335,7 @@ def main():
319
335
  changed, removed, frozen, deps_touched = [], [], None, False
320
336
  try:
321
337
  copy_files(target, files, backup, changed, removed)
338
+ clear_bytecode(target)
322
339
  print(f"Files changed: {len(changed)}; removed as obsolete: {len(removed)}")
323
340
  if not args.skip_deps:
324
341
  frozen = freeze(python)
@@ -31,6 +31,14 @@ TYPESCRIPT_PARSER = NODE_DIR / "node_modules" / "typescript" / "lib" / "typescri
31
31
  JAVA_PARSER = NODE_DIR / 'node_modules' / 'java-parser' / 'src' / 'index.js'
32
32
  ANGULAR_PARSER = NODE_DIR / 'node_modules' / '@angular' / 'compiler' / 'fesm2022' / 'compiler.mjs'
33
33
  NODE_RUNTIME = NODE_DIR / ".node-runtime"
34
+ # Moli renders the JavaScript pages web_fetch cannot read over HTTP; Chromium (Crawl4AI) stays as its fallback.
35
+ # Measured on 2026-10-10 over 21 such pages: 1.6 s and 90 MB peak (median) against 4.2 s and 581 MB for Chromium.
36
+ MOLI_VERSION = "1.1.15"
37
+ MOLI_FOLDER = f"moli-v{MOLI_VERSION}-x86_64-pc-windows-msvc"
38
+ MOLI_URL = f"https://github.com/lexmount/moli/releases/download/v{MOLI_VERSION}/moli-x86_64-pc-windows-msvc.zip"
39
+ MOLI_SHA256 = "64c00ce02d6db8e1c22c55e88fbcaaf3e7517e930f24c25ff46a457b421040c8"
40
+ MOLI_DIR = BROWSER_DIR / "moli"
41
+ MOLI_EXE = MOLI_DIR / "moli.exe"
34
42
 
35
43
 
36
44
  def _node_supported(node):
@@ -58,6 +66,49 @@ def _node_commands():
58
66
  return (node, npm) if node and npm and _node_supported(node) else (None, None)
59
67
 
60
68
 
69
+ def _download_verified(url, expected_sha256, path):
70
+ """Downloads url to path and fails when its SHA-256 differs from the published one."""
71
+ digest = hashlib.sha256()
72
+ with urllib.request.urlopen(url, timeout=60) as response, path.open("wb") as out:
73
+ while block := response.read(1024 * 1024):
74
+ digest.update(block)
75
+ out.write(block)
76
+ if digest.hexdigest().lower() != expected_sha256.lower():
77
+ raise RuntimeError(f"SHA-256 of {url} does not match the published checksum.")
78
+
79
+
80
+ def _extract_folder(zip_path, top, destination):
81
+ """Extracts the files under the archive's single top folder into destination, refusing any other path."""
82
+ destination.mkdir()
83
+ with zipfile.ZipFile(zip_path) as zf:
84
+ for member in zf.infolist():
85
+ parts = Path(member.filename.replace("\\", "/")).parts
86
+ if parts == (top,) and member.is_dir():
87
+ continue
88
+ if len(parts) < 2 or parts[0] != top or ".." in parts:
89
+ raise RuntimeError(f"{zip_path.name} contains an unexpected path: {member.filename}")
90
+ target = destination.joinpath(*parts[1:])
91
+ if member.is_dir():
92
+ target.mkdir(parents=True, exist_ok=True)
93
+ else:
94
+ target.parent.mkdir(parents=True, exist_ok=True)
95
+ with zf.open(member) as source, target.open("wb") as out:
96
+ shutil.copyfileobj(source, out)
97
+
98
+
99
+ def _replace_folder(unpacked, final, temp_dir):
100
+ """Swaps final for unpacked, putting the previous folder back if the swap fails."""
101
+ previous = temp_dir / "previous"
102
+ if final.exists():
103
+ os.replace(final, previous)
104
+ try:
105
+ os.replace(unpacked, final)
106
+ except OSError:
107
+ if previous.exists() and not final.exists():
108
+ os.replace(previous, final)
109
+ raise
110
+
111
+
61
112
  def _install_node():
62
113
  """Baixa o Node LTS oficial e confere SHA-256 antes de extrair."""
63
114
  print("Downloading Node LTS for the Smart Tool web runtime...", flush=True)
@@ -79,43 +130,38 @@ def _install_node():
79
130
  with tempfile.TemporaryDirectory(prefix="node-setup-", dir=NODE_DIR) as temp:
80
131
  temp_dir = Path(temp)
81
132
  zip_path = temp_dir / archive
82
- digest = hashlib.sha256()
83
- with urllib.request.urlopen(base + archive, timeout=60) as response, zip_path.open("wb") as out:
84
- while block := response.read(1024 * 1024):
85
- digest.update(block)
86
- out.write(block)
87
- if digest.hexdigest().lower() != expected.lower():
88
- raise RuntimeError("Node SHA-256 checksum does not match the official index.")
133
+ _download_verified(base + archive, expected, zip_path)
89
134
  unpacked = temp_dir / "runtime"
90
- unpacked.mkdir()
91
- with zipfile.ZipFile(zip_path) as zf:
92
- for member in zf.infolist():
93
- parts = Path(member.filename.replace("\\", "/")).parts
94
- if parts == (f"node-{version}-win-x64",) and member.is_dir():
95
- continue
96
- if len(parts) < 2 or parts[0] != f"node-{version}-win-x64" or ".." in parts:
97
- raise RuntimeError("Node archive contains an unexpected path.")
98
- destination = unpacked.joinpath(*parts[1:])
99
- if member.is_dir():
100
- destination.mkdir(parents=True, exist_ok=True)
101
- else:
102
- destination.parent.mkdir(parents=True, exist_ok=True)
103
- with zf.open(member) as source, destination.open("wb") as target:
104
- shutil.copyfileobj(source, target)
135
+ _extract_folder(zip_path, f"node-{version}-win-x64", unpacked)
105
136
  if not (unpacked / "node.exe").is_file() or not (unpacked / "npm.cmd").is_file():
106
137
  raise RuntimeError("Node package does not contain the expected executables.")
107
- previous = temp_dir / "previous-node-runtime"
108
- if NODE_RUNTIME.exists():
109
- os.replace(NODE_RUNTIME, previous)
110
- try:
111
- os.replace(unpacked, NODE_RUNTIME)
112
- except OSError:
113
- if previous.exists() and not NODE_RUNTIME.exists():
114
- os.replace(previous, NODE_RUNTIME)
115
- raise
138
+ _replace_folder(unpacked, NODE_RUNTIME, temp_dir)
116
139
  print(f"Node {version} installed inside Smart Tool.")
117
140
 
118
141
 
142
+ def _moli_ready():
143
+ try:
144
+ return MOLI_EXE.is_file() and (MOLI_DIR / "VERSION").read_text(encoding="utf-8").strip() == MOLI_VERSION
145
+ except OSError:
146
+ return False
147
+
148
+
149
+ def _install_moli():
150
+ """Downloads the pinned Moli release from GitHub, checks its SHA-256 and unpacks it into web_adapters/browser."""
151
+ print(f"Downloading Moli {MOLI_VERSION} (structured-first headless browser, 43 MB)...", flush=True)
152
+ BROWSER_DIR.mkdir(parents=True, exist_ok=True)
153
+ with tempfile.TemporaryDirectory(prefix="moli-setup-", dir=BROWSER_DIR) as temp:
154
+ temp_dir = Path(temp)
155
+ zip_path = temp_dir / "moli.zip"
156
+ _download_verified(MOLI_URL, MOLI_SHA256, zip_path)
157
+ unpacked = temp_dir / "moli"
158
+ _extract_folder(zip_path, MOLI_FOLDER, unpacked)
159
+ if not (unpacked / "moli.exe").is_file():
160
+ raise RuntimeError("The Moli package does not contain moli.exe.")
161
+ _replace_folder(unpacked, MOLI_DIR, temp_dir)
162
+ print(f"Moli {MOLI_VERSION} installed inside Smart Tool.")
163
+
164
+
119
165
  def _run(args, *, cwd=None, label="Web dependency", idle_s=600, stream=False, attempts=1):
120
166
  """Runs a step with UTF-8 output and closed stdin (a prompt fails at once instead of hanging); it is stopped only
121
167
  after idle_s without any output, and stream shows the output live."""
@@ -197,6 +243,7 @@ def _check():
197
243
  "browser_packages": False,
198
244
  "camoufox_browser": False,
199
245
  "chromium_browser": False,
246
+ "moli_browser": _moli_ready(),
200
247
  }
201
248
  if status["browser_python"]:
202
249
  try:
@@ -286,10 +333,12 @@ def install(reuse_browser_runtime=None):
286
333
  idle_s=180, stream=True, attempts=3)
287
334
  _run([str(BROWSER_PYTHON), "-m", "playwright", "install", "chromium"],
288
335
  label="Chromium browser", idle_s=180, stream=True, attempts=3)
336
+ if not _moli_ready():
337
+ _install_moli()
289
338
  status = _check()
290
339
  if not all(status.values()):
291
340
  raise RuntimeError(f"Incomplete web runtime: {status}")
292
- print("Smart Tool runtime installed: Node, open-websearch, TypeScript, Java, Angular, Camoufox and Crawl4AI.")
341
+ print("Smart Tool runtime installed: Node, open-websearch, TypeScript, Java, Angular, Camoufox, Crawl4AI and Moli.")
293
342
 
294
343
 
295
344
  def main():
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.7",
3
+ "version": "0.9.9",
4
4
  "description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Allan Santos",
@@ -37,6 +37,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
37
37
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
38
38
  import capabilities
39
39
  import client_hooks
40
+ import install_runtime
40
41
  import integration_metrics
41
42
  import config
42
43
  import freshness
@@ -3187,17 +3188,52 @@ def _tool_result(text, is_error=False):
3187
3188
 
3188
3189
  WEB_FETCH_TIMEOUT_S = 90
3189
3190
  WEB_FETCH_RENDER_TIMEOUT_S = 60
3191
+ MOLI_RENDER_TIMEOUT_S = 25
3192
+ # Engine of the last render on this request's thread: web_fetch.read_page calls _render_page on the handler's thread.
3193
+ _RENDER = threading.local()
3190
3194
  WEB_FETCH_VECTOR_ROOT = os.path.join(paths.DATA_DIR, "web-fetch")
3191
3195
 
3192
3196
 
3197
+ def _moli_markdown(url, timeout):
3198
+ """Markdown of a JavaScript page rendered by Moli (layout and paint only when the page needs them)."""
3199
+ if not install_runtime.MOLI_EXE.is_file():
3200
+ raise RuntimeError(f"Moli is not installed ({install_runtime.MOLI_EXE}); reinstall Smart Tool.")
3201
+ done = subprocess.run([str(install_runtime.MOLI_EXE), "fetch", "--dump", "markdown", url], capture_output=True,
3202
+ timeout=timeout, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
3203
+ if done.returncode:
3204
+ raise RuntimeError(f"moli exited {done.returncode}: {done.stderr.decode('utf-8', 'replace').strip()[-300:]}")
3205
+ return done.stdout.decode("utf-8", "replace")
3206
+
3207
+
3193
3208
  def _render_page(url, deadline):
3209
+ """Moli first; Chromium through Crawl4AI only when Moli fails or comes back thin. Measured on 2026-10-10 over 21
3210
+ pages HTTP could not read: Moli 1.6 s and 90 MB peak (median), Chromium 4.2 s and 581 MB; Moli read 14, Chromium
3211
+ 16, both together 17 (only Chromium read YouTube, Google Maps and Airbnb). The engine that answered goes to the
3212
+ web_fetch metric through _RENDER."""
3194
3213
  left = deadline - time.monotonic()
3195
3214
  if left < 5:
3196
3215
  return ""
3216
+ _RENDER.engine, _RENDER.moli_error = None, None
3217
+ try:
3218
+ markdown = _moli_markdown(url, min(MOLI_RENDER_TIMEOUT_S, left - 2))
3219
+ except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
3220
+ markdown, _RENDER.moli_error = "", f"{type(exc).__name__}: {exc}"[:200]
3221
+ _log(f"web_fetch Moli render failed, trying Chromium: {_RENDER.moli_error}")
3222
+ if not web_fetch.thin(markdown):
3223
+ _RENDER.engine = "moli"
3224
+ return markdown
3225
+ left = deadline - time.monotonic()
3226
+ if left < 5:
3227
+ return markdown
3197
3228
  raw = _run_search_script(CAMOUFOX_VENV_PYTHON, CRAWL4AI_SEARCH_SCRIPT, [url, "--max-pages", "1"],
3198
3229
  min(WEB_FETCH_RENDER_TIMEOUT_S, left))
3199
3230
  pages = raw.get("pages") if isinstance(raw, dict) else None
3200
- return (pages[0].get("markdown") or "") if pages else ""
3231
+ chromium = (pages[0].get("markdown") or "") if pages else ""
3232
+ if len(chromium.strip()) > len(markdown.strip()):
3233
+ _RENDER.engine = "chromium"
3234
+ return chromium
3235
+ _RENDER.engine = "moli"
3236
+ return markdown
3201
3237
 
3202
3238
 
3203
3239
  def _web_fetch_embed(texts, deadline):
@@ -3243,6 +3279,7 @@ def _handle_web_fetch(arguments):
3243
3279
  if problem:
3244
3280
  raise RuntimeError(f"Gateway model unavailable ({problem}).")
3245
3281
  deadline = started + WEB_FETCH_TIMEOUT_S
3282
+ _RENDER.engine, _RENDER.moli_error = None, None
3246
3283
  page = web_fetch.read_page(url, deadline, _render_page)
3247
3284
  selected, pieces = web_fetch.select(page["text"], prompt, lambda texts: _web_fetch_embed(texts, deadline))
3248
3285
  text = web_fetch.answer(config.load_config()["router_model"], prompt, {**page, "text": selected}, deadline)
@@ -3252,6 +3289,7 @@ def _handle_web_fetch(arguments):
3252
3289
  raise RuntimeError(f"web_fetch failed: {str(exc).rstrip('.')}. The native WebFetch is allowed for this URL for "
3253
3290
  f"{web_fetch.FAILED_TTL_S // 60} min.") from None
3254
3291
  _metric("web_fetch", cached=page["cached"], rendered=page["rendered"], render_error=page.get("render_error"),
3292
+ render_engine=_RENDER.engine if page["rendered"] else None, moli_error=_RENDER.moli_error,
3255
3293
  page_chars=page["chars"], read_chars=len(selected), pieces=pieces,
3256
3294
  answer_chars=len(text), elapsed_s=round(time.monotonic() - started, 2))
3257
3295
  origin = "from cache" if page["cached"] else "rendered in the browser" if page["rendered"] else "downloaded now"
package/version.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.7"
4
+ VERSION = "0.9.9"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7