@allansantos-dev/smart-tool 0.9.26 → 0.9.28
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 +36 -0
- package/README.md +1 -1
- package/browser_control.py +29 -0
- package/code_graph.py +1 -1
- package/index_scope.py +77 -16
- package/install_runtime.py +14 -11
- package/node_locator.py +103 -0
- package/package.json +1 -1
- package/smart_tool_daemon.py +24 -7
- package/update_check.py +5 -2
- package/version.py +1 -1
- package/web_search_adapters.py +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.28 - beta
|
|
4
|
+
|
|
5
|
+
- Node, npx and npm are looked up when they are needed, never remembered. A process keeps the PATH it was born with:
|
|
6
|
+
the tray started at logon never saw a Node installed, moved or removed afterwards, so the automatic update
|
|
7
|
+
(`npx --yes ... install`, run from the tray) stopped working once Node changed place, and everything the update then
|
|
8
|
+
started (installer, restarted tray) inherited the same stale PATH. New `node_locator.py`: the process PATH, then the
|
|
9
|
+
machine and user PATH stored in the registry, then the usual install folders (Program Files, nvm, Volta, fnm, Scoop,
|
|
10
|
+
Chocolatey); the Node inside Smart Tool (`web_adapters/node/.node-runtime`) is used only when the machine has none.
|
|
11
|
+
- The updater launches the install with that fresh PATH and the folder of the `npx` it found first in it.
|
|
12
|
+
- The web runtime installer takes the machine's Node from the PATH first (it preferred the one inside Smart Tool).
|
|
13
|
+
- Search adapters and the code analyzers resolve Node at each launch, so a Node changed while the daemon runs is
|
|
14
|
+
followed without a restart.
|
|
15
|
+
- The Playwright MCP registration writes the absolute Node path into the client's configuration; the daemon now
|
|
16
|
+
registers it again at start when that executable no longer exists (`browser_control.heal`).
|
|
17
|
+
- Tests: `tests/test_node_locator.py` (Node in an unusual folder, only in the registry PATH, only in a known folder,
|
|
18
|
+
nothing found, the updater without npx in the tray PATH, a registration whose Node is gone).
|
|
19
|
+
|
|
20
|
+
## 0.9.27 - beta
|
|
21
|
+
|
|
22
|
+
- No folder is read before it is classified, and a tool's hidden folder (`.crowd`, `.tokenguard`, `.vscode`, any name
|
|
23
|
+
starting with a dot) is never indexed, whatever the scope says:
|
|
24
|
+
- An empty or missing `include` used to mean "the whole project" (the saved scopes of `sistema` and a Claude memory
|
|
25
|
+
folder had `include: []`), and a search of a folder with no index and no scope read every file of it from disk (one
|
|
26
|
+
registered folder had 715 427). An empty `include` now indexes only the loose files at the root, and that first
|
|
27
|
+
keyword search reads only those.
|
|
28
|
+
- The `scope` action without a saved scope defaulted to `include: ["."]`; it now requires `include`, and refuses an
|
|
29
|
+
include inside a hidden folder.
|
|
30
|
+
- A model that answered with `docs/**`, `*` or `**/*` (the cause of the `error` status of two registered folders)
|
|
31
|
+
now has the entry reduced to the folder it names; an entry that is absolute, climbs out with `..`, sits in a hidden
|
|
32
|
+
folder or keeps a wildcard in the middle is dropped and kept in `dropped_include` of the saved scope.
|
|
33
|
+
- Hidden files at the root and inside any included folder are no longer listed (they were listed and then skipped
|
|
34
|
+
when read).
|
|
35
|
+
- The mechanical exclusion grows: `site-packages`, `bower_components`, `jspm_packages` everywhere, and `target`
|
|
36
|
+
(Cargo.toml or pom.xml at the root) and `Pods` (Podfile). These are never offered to the scope model.
|
|
37
|
+
- Tests for each rule: `tests/test_scope_rules.py`.
|
|
38
|
+
|
|
3
39
|
## 0.9.26 - beta
|
|
4
40
|
|
|
5
41
|
- The daemon no longer re-reads and re-hashes every watched folder in a loop. Measured on the owner's machine: one
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ registered as the `playwright` server where this machine lets a browser be drive
|
|
|
15
15
|
An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
|
|
16
16
|
are cheaper, or just tells the agent what Smart Tool would do.
|
|
17
17
|
|
|
18
|
-
**Status:** 0.9.
|
|
18
|
+
**Status:** 0.9.28 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
|
|
19
19
|
|
|
20
20
|
## How it works
|
|
21
21
|
|
package/browser_control.py
CHANGED
|
@@ -26,6 +26,7 @@ import tomllib
|
|
|
26
26
|
import atomic_io
|
|
27
27
|
import endpoint_sync
|
|
28
28
|
import install_runtime
|
|
29
|
+
import node_locator
|
|
29
30
|
import paths
|
|
30
31
|
|
|
31
32
|
SERVER = "playwright"
|
|
@@ -157,6 +158,34 @@ def registered():
|
|
|
157
158
|
return found
|
|
158
159
|
|
|
159
160
|
|
|
161
|
+
def registered_command(client):
|
|
162
|
+
"""Executable the client's `playwright` server is registered with, or None when it is not registered."""
|
|
163
|
+
try:
|
|
164
|
+
if client == "claude":
|
|
165
|
+
with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
|
|
166
|
+
entry = (json.load(stream).get("mcpServers") or {}).get(SERVER)
|
|
167
|
+
else:
|
|
168
|
+
with open(_codex_config(), encoding="utf-8") as stream:
|
|
169
|
+
entry = (tomllib.loads(stream.read()).get("mcp_servers") or {}).get(SERVER)
|
|
170
|
+
except (OSError, ValueError, AttributeError):
|
|
171
|
+
return None
|
|
172
|
+
command = entry.get("command") if isinstance(entry, dict) else None
|
|
173
|
+
return command if isinstance(command, str) else None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def heal(log=None):
|
|
177
|
+
"""Registers again, through ensure, each registration of ours whose executable no longer exists: the Node path is
|
|
178
|
+
written into the client's configuration, which a moved or reinstalled Node leaves pointing nowhere. Returns
|
|
179
|
+
{client: ensure's outcome} for the clients refreshed."""
|
|
180
|
+
refreshed = {}
|
|
181
|
+
for client in ("claude", "codex"):
|
|
182
|
+
command = registered_command(client)
|
|
183
|
+
ours = status().get(client, {}).get("state") in ("registered", "undenied")
|
|
184
|
+
if ours and command and not node_locator.exists(command):
|
|
185
|
+
refreshed[client] = ensure(client, log)
|
|
186
|
+
return refreshed
|
|
187
|
+
|
|
188
|
+
|
|
160
189
|
def _codex_config():
|
|
161
190
|
"""Codex keeps config.toml in CODEX_HOME when set, else ~/.codex."""
|
|
162
191
|
return os.path.join(endpoint_sync.codex_dir(), "config.toml")
|
package/code_graph.py
CHANGED
|
@@ -242,7 +242,7 @@ def _run_analyzer(script, payload, failure):
|
|
|
242
242
|
needed more than the former fixed 512 MB). The run is killed after `ANALYZER_TIMEOUT_S` seconds (a context
|
|
243
243
|
variable: the edit hook lowers it so a slow file cannot hold its worker)."""
|
|
244
244
|
for heap in NODE_HEAP_MB:
|
|
245
|
-
process=subprocess.run([web_search_adapters.NODE_EXECUTABLE,f'--max-old-space-size={heap}',str(Path(__file__).with_name(script))],
|
|
245
|
+
process=subprocess.run([web_search_adapters.executable_path(web_search_adapters.NODE_EXECUTABLE),f'--max-old-space-size={heap}',str(Path(__file__).with_name(script))],
|
|
246
246
|
input=payload,capture_output=True,text=True,encoding='utf-8',timeout=ANALYZER_TIMEOUT_S.get(),
|
|
247
247
|
creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0))
|
|
248
248
|
if process.returncode==0:
|
package/index_scope.py
CHANGED
|
@@ -28,8 +28,18 @@ SCOPE_DIR = paths.DATA_DIR
|
|
|
28
28
|
_ALWAYS_EXCLUDE = {
|
|
29
29
|
".git", "node_modules", "__pycache__", ".venv", "venv", "env",
|
|
30
30
|
".tox", ".mypy_cache", ".pytest_cache", "*.egg-info",
|
|
31
|
+
"site-packages", "bower_components", "jspm_packages",
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
# Folders that are generated output only when the root proves the project type: a root marker file maps to the names
|
|
35
|
+
# it makes unambiguous (Cargo's and Maven's `target`, CocoaPods' `Pods`). Excluded mechanically, before any model sees
|
|
36
|
+
# the folder list.
|
|
37
|
+
_PROJECT_TYPE_EXCLUDES = (
|
|
38
|
+
(("Cargo.toml",), ("target",)),
|
|
39
|
+
(("pom.xml",), ("target",)),
|
|
40
|
+
(("Podfile",), ("Pods",)),
|
|
41
|
+
)
|
|
42
|
+
|
|
33
43
|
# Mesmo critério do `_ALWAYS_EXCLUDE`, para arquivo: o significado não varia por
|
|
34
44
|
# projeto. Lockfile é grafo de dependência resolvido por ferramenta — nunca é fonte de
|
|
35
45
|
# resposta numa busca semântica, e é grande o bastante pra dominar o índice.
|
|
@@ -55,6 +65,51 @@ def tool_state_excludes(root):
|
|
|
55
65
|
return _TOOL_STATE_DIRS.get(os.path.basename(os.path.normpath(root)).casefold(), ())
|
|
56
66
|
|
|
57
67
|
|
|
68
|
+
def project_type_excludes(root):
|
|
69
|
+
"""Names of the generated-output folders `root`'s marker files make unambiguous (empty for an unknown type)."""
|
|
70
|
+
found = []
|
|
71
|
+
for markers, names in _PROJECT_TYPE_EXCLUDES:
|
|
72
|
+
if any(os.path.isfile(os.path.join(root, marker)) for marker in markers):
|
|
73
|
+
found.extend(names)
|
|
74
|
+
return tuple(found)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def mechanical_excludes(root):
|
|
78
|
+
"""Everything excluded without asking a model: the tool-runtime folders of a config home and the generated output
|
|
79
|
+
of the project type. The names of `_ALWAYS_EXCLUDE` and every hidden folder are excluded on top of these."""
|
|
80
|
+
return (*tool_state_excludes(root), *project_type_excludes(root))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def is_hidden(rel_path):
|
|
84
|
+
"""Whether any part of the relative path starts with a dot (a tool's folder: .git, .vscode, .crowd, .tokenguard)."""
|
|
85
|
+
return any(part.startswith(".") for part in str(rel_path).replace("\\", "/").split("/") if part not in ("", ".", ".."))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def normalize_include(include):
|
|
89
|
+
"""`(bases, dropped)` from the include entries of a scope. A base is a folder or file path relative to the root, with
|
|
90
|
+
the shapes a model emits for "everything under it" (`docs/**`, `docs/*`, `docs/`) reduced to the folder; `*`, `**`
|
|
91
|
+
and `**/*` mean the whole project and become ``. An entry that is absolute, climbs out with `..`, sits inside a hidden
|
|
92
|
+
folder or keeps a wildcard in the middle is dropped: it can never name something indexable, and it is returned so the
|
|
93
|
+
caller can report it. No entries means no folder: only the loose files at the root are indexed."""
|
|
94
|
+
bases, dropped = [], []
|
|
95
|
+
for raw in include or []:
|
|
96
|
+
base = str(raw).replace("\\", "/").strip()
|
|
97
|
+
while base.startswith("./"):
|
|
98
|
+
base = base[2:]
|
|
99
|
+
for tail in ("/**/*", "/**", "/*"):
|
|
100
|
+
if base.endswith(tail):
|
|
101
|
+
base = base[:-len(tail)]
|
|
102
|
+
base = base.strip("/")
|
|
103
|
+
if base in ("*", "**", "**/*", "."):
|
|
104
|
+
base = ""
|
|
105
|
+
if (os.path.isabs(str(raw)) or str(raw).strip().startswith(("/", "\\")) or ".." in base.split("/") or is_hidden(base)
|
|
106
|
+
or any(ch in base for ch in "*?[")):
|
|
107
|
+
dropped.append(raw)
|
|
108
|
+
elif base not in bases:
|
|
109
|
+
bases.append(base)
|
|
110
|
+
return bases, dropped
|
|
111
|
+
|
|
112
|
+
|
|
58
113
|
def always_excluded(name):
|
|
59
114
|
"""Directory name in _ALWAYS_EXCLUDE, entries may be globs (e.g. *.egg-info)."""
|
|
60
115
|
folded = name.casefold()
|
|
@@ -122,10 +177,12 @@ def _top_level_dirs(root):
|
|
|
122
177
|
names = os.listdir(root)
|
|
123
178
|
except OSError:
|
|
124
179
|
return []
|
|
180
|
+
skipped = mechanical_excludes(root)
|
|
125
181
|
return sorted(
|
|
126
182
|
name for name in names
|
|
127
183
|
if not name.startswith(".")
|
|
128
184
|
and not always_excluded(name)
|
|
185
|
+
and name not in skipped
|
|
129
186
|
and os.path.isdir(os.path.join(root, name))
|
|
130
187
|
and _within_root(root, os.path.join(root, name))
|
|
131
188
|
)
|
|
@@ -293,7 +350,8 @@ def _build_scope_prompt(root, ambiguous_dirs, root_files, doc_context):
|
|
|
293
350
|
"You are deciding what to include in a semantic code-search index for the "
|
|
294
351
|
f"repository at {root}.\n\n"
|
|
295
352
|
"These directories are already excluded automatically (dependency/tooling caches "
|
|
296
|
-
f"whose meaning does not vary by project): {', '.join(sorted(_ALWAYS_EXCLUDE))}. "
|
|
353
|
+
f"whose meaning does not vary by project): {', '.join(sorted({*_ALWAYS_EXCLUDE, *mechanical_excludes(root)}))}. "
|
|
354
|
+
"Every hidden folder (name starting with a dot: .git, .vscode, .crowd, .tokenguard...) is also never indexed. "
|
|
297
355
|
"Do not decide on them.\n\n"
|
|
298
356
|
f"Directories that still need a decision: {', '.join(ambiguous_dirs) or '(none)'}\n\n"
|
|
299
357
|
"Loose files at the repository ROOT are indexed automatically, because in a flat "
|
|
@@ -317,6 +375,8 @@ def _build_scope_prompt(root, ambiguous_dirs, root_files, doc_context):
|
|
|
317
375
|
"source, even if it also contains a few generated/log files. Only call submit_scope "
|
|
318
376
|
"once every non-obvious directory is grounded in inspection data or in the "
|
|
319
377
|
"documentation.\n\n"
|
|
378
|
+
"`include` lists folder paths relative to the root, without wildcards; a folder you leave out of `include` is "
|
|
379
|
+
"not indexed, and an empty `include` indexes only the loose root files. "
|
|
320
380
|
"Include sources useful for search even if they are gitignored (e.g. docs/, README, "
|
|
321
381
|
"ADRs). Java, Angular, HTML/CSS, plain text and Markdown are supported sources. "
|
|
322
382
|
"DOCX documents are supported through bounded local text extraction; do not exclude "
|
|
@@ -333,7 +393,7 @@ def decide_scope(root, router_model, force=False, deadline=None, cancel_check=No
|
|
|
333
393
|
return cached
|
|
334
394
|
|
|
335
395
|
top_level_dirs = _top_level_dirs(root)
|
|
336
|
-
ambiguous_dirs =
|
|
396
|
+
ambiguous_dirs = list(top_level_dirs)
|
|
337
397
|
root_files = _root_level_files(root, _exclude_matcher(_ALWAYS_EXCLUDE_FILES))
|
|
338
398
|
doc_context = _read_root_docs(root)
|
|
339
399
|
|
|
@@ -392,11 +452,13 @@ def decide_scope(root, router_model, force=False, deadline=None, cancel_check=No
|
|
|
392
452
|
|
|
393
453
|
if decision is None:
|
|
394
454
|
raise RuntimeError("The model did not define a valid scope. Check the model or set the scope manually.")
|
|
395
|
-
|
|
396
|
-
|
|
455
|
+
include, dropped = normalize_include(decision.get("include"))
|
|
456
|
+
final = {"include": include,
|
|
457
|
+
"exclude": [*(decision.get("exclude") or []), *mechanical_excludes(root)],
|
|
397
458
|
"user_exclude": (cached or {}).get("user_exclude", [])}
|
|
398
459
|
scope = {
|
|
399
460
|
"include": final["include"],
|
|
461
|
+
"dropped_include": [str(item) for item in dropped],
|
|
400
462
|
"exclude": final["exclude"],
|
|
401
463
|
"top_level_dirs": top_level_dirs,
|
|
402
464
|
"scope_version": SCOPE_VERSION,
|
|
@@ -422,7 +484,7 @@ def _root_level_files(root, matcher):
|
|
|
422
484
|
raise OSError("Could not list the root; the existing index was kept.") from exc
|
|
423
485
|
out = []
|
|
424
486
|
for name in names:
|
|
425
|
-
if matcher(name):
|
|
487
|
+
if matcher(name) or name.startswith("."):
|
|
426
488
|
continue
|
|
427
489
|
full = os.path.join(root, name)
|
|
428
490
|
# `isfile` segue link: sem a checagem de contenção, um link na raiz apontando
|
|
@@ -466,16 +528,15 @@ def scope_filter(scope, root=None):
|
|
|
466
528
|
"""Predicate `keep(rel_path)`: whether resolve_included_files would list rel_path (a file) or walk it (a folder) —
|
|
467
529
|
under an include base, outside the exclusions, outside hidden folders. Built once so a burst of file events does not
|
|
468
530
|
rebuild the matcher per path; `root` adds the tool-runtime folders of a known config home."""
|
|
469
|
-
|
|
531
|
+
bases, _dropped = normalize_include(scope.get("include"))
|
|
470
532
|
matcher = _exclude_matcher(set(scope.get("exclude") or []) | set(scope.get("user_exclude") or []) |
|
|
471
|
-
set(
|
|
472
|
-
|
|
473
|
-
whole = any(base in ("", ".") for base in include)
|
|
533
|
+
set(mechanical_excludes(root) if root else ()) | _ALWAYS_EXCLUDE | _ALWAYS_EXCLUDE_FILES)
|
|
534
|
+
whole = "" in bases
|
|
474
535
|
|
|
475
536
|
def keep(rel_path):
|
|
476
537
|
rel_path = rel_path.replace("\\", "/")
|
|
477
538
|
parts = rel_path.split("/")
|
|
478
|
-
if matcher(rel_path) or
|
|
539
|
+
if matcher(rel_path) or is_hidden(rel_path):
|
|
479
540
|
return False
|
|
480
541
|
if whole or len(parts) == 1:
|
|
481
542
|
return True
|
|
@@ -493,16 +554,16 @@ def in_scope(scope, rel_path, root=None):
|
|
|
493
554
|
def resolve_included_files(root, scope, cancel_check=None):
|
|
494
555
|
if not valid_scope(scope):
|
|
495
556
|
raise ValueError("Invalid scope; request a new project analysis.")
|
|
496
|
-
include = scope.get("include")
|
|
557
|
+
include, _dropped = normalize_include(scope.get("include"))
|
|
497
558
|
matcher = _exclude_matcher(set(scope.get("exclude") or []) | set(scope.get("user_exclude") or []) |
|
|
498
|
-
set(
|
|
559
|
+
set(mechanical_excludes(root)) | _ALWAYS_EXCLUDE | _ALWAYS_EXCLUDE_FILES)
|
|
499
560
|
files = []
|
|
500
561
|
def walk_error(error):
|
|
501
562
|
raise OSError("Could not list a folder in the scope; try again when it is accessible.") from error
|
|
502
|
-
if
|
|
563
|
+
if "" not in include:
|
|
503
564
|
files.extend(_root_level_files(root, matcher))
|
|
504
565
|
for base in include:
|
|
505
|
-
base_full = os.path.join(root, base) if base
|
|
566
|
+
base_full = os.path.join(root, base) if base else root
|
|
506
567
|
# `include` vem de tool call de um LLM cujo prompt embute documentação do
|
|
507
568
|
# repositório analisado: um valor absoluto ou com `..` faria a varredura sair do
|
|
508
569
|
# projeto e mandar conteúdo de fora pro endpoint de embeddings.
|
|
@@ -510,7 +571,7 @@ def resolve_included_files(root, scope, cancel_check=None):
|
|
|
510
571
|
continue
|
|
511
572
|
if os.path.isfile(base_full):
|
|
512
573
|
rel = os.path.relpath(base_full, root).replace(os.sep, "/")
|
|
513
|
-
if not matcher(rel):
|
|
574
|
+
if not matcher(rel) and not is_hidden(rel):
|
|
514
575
|
files.append(rel)
|
|
515
576
|
continue
|
|
516
577
|
for dirpath, dirnames, filenames in os.walk(base_full, onerror=walk_error):
|
|
@@ -528,6 +589,6 @@ def resolve_included_files(root, scope, cancel_check=None):
|
|
|
528
589
|
]
|
|
529
590
|
for name in filenames:
|
|
530
591
|
rel_path = f"{rel_dir}/{name}" if rel_dir else name
|
|
531
|
-
if not matcher(rel_path) and _within_root(root, os.path.join(root, rel_path)):
|
|
592
|
+
if not name.startswith(".") and not matcher(rel_path) and _within_root(root, os.path.join(root, rel_path)):
|
|
532
593
|
files.append(rel_path)
|
|
533
594
|
return sorted(set(files))
|
package/install_runtime.py
CHANGED
|
@@ -21,6 +21,8 @@ import venv
|
|
|
21
21
|
import zipfile
|
|
22
22
|
from pathlib import Path
|
|
23
23
|
|
|
24
|
+
import node_locator
|
|
25
|
+
|
|
24
26
|
|
|
25
27
|
ROOT = Path(__file__).resolve().parent
|
|
26
28
|
NODE_DIR = ROOT / "web_adapters" / "node"
|
|
@@ -32,7 +34,7 @@ JAVA_PARSER = NODE_DIR / 'node_modules' / 'java-parser' / 'src' / 'index.js'
|
|
|
32
34
|
ANGULAR_PARSER = NODE_DIR / 'node_modules' / '@angular' / 'compiler' / 'fesm2022' / 'compiler.mjs'
|
|
33
35
|
# Browser control for agents (browser_control.py): Microsoft's Playwright MCP over the Chromium installed below.
|
|
34
36
|
PLAYWRIGHT_MCP_CLI = NODE_DIR / "node_modules" / "@playwright" / "mcp" / "cli.js"
|
|
35
|
-
NODE_RUNTIME =
|
|
37
|
+
NODE_RUNTIME = Path(node_locator.LOCAL_RUNTIME)
|
|
36
38
|
# Moli renders the JavaScript pages web_fetch cannot read over HTTP; Chromium (Crawl4AI) stays as its fallback.
|
|
37
39
|
# 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.
|
|
38
40
|
MOLI_VERSION = "1.1.15"
|
|
@@ -58,14 +60,16 @@ def _node_supported(node):
|
|
|
58
60
|
|
|
59
61
|
|
|
60
62
|
def _node_commands():
|
|
63
|
+
"""(node, npm) of the Node to use: the machine's, found through the PATH first, else the one inside Smart Tool;
|
|
64
|
+
(None, None) when neither is a supported version."""
|
|
65
|
+
node = node_locator.which("node")
|
|
66
|
+
npm = node_locator.which("npm.cmd") or node_locator.which("npm")
|
|
67
|
+
if node and npm and _node_supported(node):
|
|
68
|
+
return node, npm
|
|
61
69
|
local_node, local_npm = NODE_RUNTIME / "node.exe", NODE_RUNTIME / "npm.cmd"
|
|
62
|
-
if
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return None, None
|
|
66
|
-
node = shutil.which("node")
|
|
67
|
-
npm = shutil.which("npm.cmd") or shutil.which("npm")
|
|
68
|
-
return (node, npm) if node and npm and _node_supported(node) else (None, None)
|
|
70
|
+
if local_node.is_file() and local_npm.is_file() and _node_supported(local_node):
|
|
71
|
+
return str(local_node), str(local_npm)
|
|
72
|
+
return None, None
|
|
69
73
|
|
|
70
74
|
|
|
71
75
|
def _download_verified(url, expected_sha256, path):
|
|
@@ -169,10 +173,9 @@ def _install_moli():
|
|
|
169
173
|
def _run(args, *, cwd=None, label="Web dependency", idle_s=600, stream=False, attempts=1):
|
|
170
174
|
"""Runs a step with UTF-8 output and closed stdin (a prompt fails at once instead of hanging); it is stopped only
|
|
171
175
|
after idle_s without any output, and stream shows the output live."""
|
|
172
|
-
|
|
176
|
+
node, _npm = _node_commands()
|
|
177
|
+
env = node_locator.environment(prefer=node)
|
|
173
178
|
env.update(PYTHONUTF8="1", PYTHONIOENCODING="utf-8")
|
|
174
|
-
if NODE_RUNTIME.is_dir():
|
|
175
|
-
env["PATH"] = str(NODE_RUNTIME) + os.pathsep + env.get("PATH", "")
|
|
176
179
|
flags = (subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW) if os.name == "nt" else 0
|
|
177
180
|
for attempt in range(1, attempts + 1):
|
|
178
181
|
print(f"{label}: starting{f' (attempt {attempt} of {attempts})' if attempt > 1 else ''}...", flush=True)
|
package/node_locator.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Where Node.js (and npx/npm) is on this machine, looked up when it is needed and never remembered.
|
|
2
|
+
|
|
3
|
+
A process keeps the PATH it was born with: the tray started at logon never sees a Node installed, moved or removed
|
|
4
|
+
afterwards, and everything it launches (the updater, the installer, the daemon) inherits that stale PATH. The lookup is
|
|
5
|
+
therefore: the process PATH, then the PATH Windows stores for the machine and the user (what a new process would get),
|
|
6
|
+
then the usual install folders. The Node that Smart Tool downloads into web_adapters/node/.node-runtime is the last
|
|
7
|
+
resort of `node()`, used only when the machine has none.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
|
|
12
|
+
LOCAL_RUNTIME = os.path.join(os.path.dirname(os.path.abspath(__file__)), "web_adapters", "node", ".node-runtime")
|
|
13
|
+
_MACHINE_ENVIRONMENT = r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _registry_path():
|
|
17
|
+
"""Entries of the machine and user PATH as stored in the registry, variables expanded."""
|
|
18
|
+
if os.name != "nt":
|
|
19
|
+
return []
|
|
20
|
+
import winreg
|
|
21
|
+
entries = []
|
|
22
|
+
for hive, subkey in ((winreg.HKEY_LOCAL_MACHINE, _MACHINE_ENVIRONMENT), (winreg.HKEY_CURRENT_USER, "Environment")):
|
|
23
|
+
try:
|
|
24
|
+
with winreg.OpenKey(hive, subkey) as key:
|
|
25
|
+
entries += os.path.expandvars(winreg.QueryValueEx(key, "Path")[0]).split(os.pathsep)
|
|
26
|
+
except FileNotFoundError:
|
|
27
|
+
continue
|
|
28
|
+
return entries
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _unique(entries):
|
|
32
|
+
seen, kept = set(), []
|
|
33
|
+
for entry in entries:
|
|
34
|
+
entry = entry.strip().strip('"')
|
|
35
|
+
if entry and os.path.normcase(entry) not in seen:
|
|
36
|
+
seen.add(os.path.normcase(entry))
|
|
37
|
+
kept.append(entry)
|
|
38
|
+
return kept
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def search_path():
|
|
42
|
+
"""The process PATH followed by the registry PATH entries it does not have."""
|
|
43
|
+
return _unique(os.environ.get("PATH", "").split(os.pathsep) + _registry_path())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def known_folders():
|
|
47
|
+
"""Where Node installers and version managers put node.exe, for a machine whose PATH does not list it."""
|
|
48
|
+
program_files = [os.environ.get(name) for name in ("ProgramFiles", "ProgramW6432", "ProgramFiles(x86)")]
|
|
49
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
50
|
+
home = os.path.expanduser("~")
|
|
51
|
+
candidates = [os.path.join(base, "nodejs") for base in program_files if base]
|
|
52
|
+
candidates += [os.environ.get("NVM_SYMLINK"), os.environ.get("FNM_MULTISHELL_PATH"),
|
|
53
|
+
os.path.join(os.environ["VOLTA_HOME"], "bin") if os.environ.get("VOLTA_HOME") else None,
|
|
54
|
+
os.path.join(local, "Programs", "nodejs") if local else None,
|
|
55
|
+
os.path.join(home, "scoop", "apps", "nodejs", "current"),
|
|
56
|
+
os.path.join(home, "scoop", "apps", "nodejs-lts", "current"),
|
|
57
|
+
os.path.join(os.environ["ChocolateyInstall"], "bin") if os.environ.get("ChocolateyInstall") else None]
|
|
58
|
+
return _unique(path for path in candidates if path)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
_FOUND = {}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def which(name):
|
|
65
|
+
"""Full path of an executable (`node`, `npx.cmd`...): the PATH first, the known install folders only when the PATH
|
|
66
|
+
does not have it; None when neither does. A path found is remembered per PATH value and used again only while the
|
|
67
|
+
file is still there, so a lookup costs a stat, not a scan of every folder, and a moved Node is searched again."""
|
|
68
|
+
key = (name, os.environ.get("PATH", ""))
|
|
69
|
+
found = _FOUND.get(key)
|
|
70
|
+
if found and os.path.isfile(found):
|
|
71
|
+
return found
|
|
72
|
+
found = (shutil.which(name, path=os.pathsep.join(search_path()))
|
|
73
|
+
or shutil.which(name, path=os.pathsep.join(known_folders())))
|
|
74
|
+
if found:
|
|
75
|
+
_FOUND[key] = found
|
|
76
|
+
return found
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def local_node():
|
|
80
|
+
"""The Node downloaded into Smart Tool, or None."""
|
|
81
|
+
path = os.path.join(LOCAL_RUNTIME, "node.exe")
|
|
82
|
+
return path if os.path.isfile(path) else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def node():
|
|
86
|
+
"""node.exe of the machine (PATH first), else the Node inside Smart Tool, else None."""
|
|
87
|
+
return which("node") or local_node()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def environment(prefer=None):
|
|
91
|
+
"""A copy of the environment with the current PATH (see search_path), the folder of `prefer` (an executable)
|
|
92
|
+
first: what a process launched now should inherit."""
|
|
93
|
+
env = {key: value for key, value in os.environ.items() if key.upper() != "PATH"}
|
|
94
|
+
entries = search_path()
|
|
95
|
+
if prefer:
|
|
96
|
+
entries.insert(0, os.path.dirname(prefer))
|
|
97
|
+
env["PATH"] = os.pathsep.join(_unique(entries))
|
|
98
|
+
return env
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def exists(command):
|
|
102
|
+
"""Whether a registered executable (a full path, or a bare name resolved through the PATH) is still there."""
|
|
103
|
+
return os.path.isfile(command) or bool(which(command))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allansantos-dev/smart-tool",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.28",
|
|
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",
|
package/smart_tool_daemon.py
CHANGED
|
@@ -40,6 +40,7 @@ import capabilities
|
|
|
40
40
|
import client_hooks
|
|
41
41
|
import install_runtime
|
|
42
42
|
import integration_metrics
|
|
43
|
+
import node_locator
|
|
43
44
|
import config
|
|
44
45
|
import freshness
|
|
45
46
|
import model_client
|
|
@@ -892,11 +893,11 @@ def _cold_index(root, cfg, problem):
|
|
|
892
893
|
|
|
893
894
|
|
|
894
895
|
def _cold_overlay(root, queries):
|
|
895
|
-
"""`(overlay, warning)` for a project with no index: the in-scope files (the saved scope, else the
|
|
896
|
-
the
|
|
897
|
-
index is being built. The scan stops after `COLD_SCAN_BUDGET_S`."""
|
|
896
|
+
"""`(overlay, warning)` for a project with no index: the in-scope files (the saved scope, else only the loose files
|
|
897
|
+
at the root: no folder is read before it is classified) holding a query term, read from disk and ranked by
|
|
898
|
+
keyword, plus the notice that the index is being built. The scan stops after `COLD_SCAN_BUDGET_S`."""
|
|
898
899
|
scope = index_scope.load_scope(root)
|
|
899
|
-
files = index_scope.resolve_included_files(root, scope if index_scope.valid_scope(scope) else {"include": [
|
|
900
|
+
files = index_scope.resolve_included_files(root, scope if index_scope.valid_scope(scope) else {"include": []})
|
|
900
901
|
chunks, scanned = indexer.keyword_chunks(root, files, queries, COLD_SCAN_BUDGET_S)
|
|
901
902
|
reached = f"{scanned} of {len(files)} files" if scanned < len(files) else f"all {len(files)} files"
|
|
902
903
|
warning = ("Indexing in progress: this folder has no index yet, so the results are keyword matches read straight "
|
|
@@ -1958,7 +1959,10 @@ def _project_action_locked(arguments):
|
|
|
1958
1959
|
manual = arguments.get("manual", True)
|
|
1959
1960
|
if type(manual) is not bool:
|
|
1960
1961
|
raise ValueError("manual must be true or false.")
|
|
1961
|
-
scope = dict(index_scope.load_scope(project["root"]) or {"include": [
|
|
1962
|
+
scope = dict(index_scope.load_scope(project["root"]) or {"include": [], "exclude": []})
|
|
1963
|
+
if "include" not in arguments and not scope.get("include"):
|
|
1964
|
+
raise ValueError("include is required: list the folders with the project's code, tests and docs. "
|
|
1965
|
+
"Nothing is indexed from a folder that is not listed.")
|
|
1962
1966
|
for field in ("include", "exclude", "user_exclude"):
|
|
1963
1967
|
values = arguments.get(field, scope.get(field, []))
|
|
1964
1968
|
if not isinstance(values, list) or len(values) > 200 or any(
|
|
@@ -1969,6 +1973,10 @@ def _project_action_locked(arguments):
|
|
|
1969
1973
|
raise ValueError("The scope must stay inside the project folder.")
|
|
1970
1974
|
if field == "include" and not project_identity.within_root(project["root"], os.path.join(project["root"], value)):
|
|
1971
1975
|
raise ValueError("Include points outside the folder.")
|
|
1976
|
+
if field == "include" and index_scope.is_hidden(value):
|
|
1977
|
+
raise ValueError(f"Include '{value}' is inside a hidden folder: hidden folders are never indexed.")
|
|
1978
|
+
if field == "include":
|
|
1979
|
+
values = index_scope.normalize_include(values)[0]
|
|
1972
1980
|
scope[field] = values
|
|
1973
1981
|
scope.update(manual=manual, scope_version=index_scope.SCOPE_VERSION if manual else 0)
|
|
1974
1982
|
scope.pop("profile", None)
|
|
@@ -2071,9 +2079,9 @@ def _run_search_script(executable, script_path, args, timeout_s):
|
|
|
2071
2079
|
if _IS_WINDOWS else {"start_new_session": True}
|
|
2072
2080
|
)
|
|
2073
2081
|
proc = subprocess.Popen(
|
|
2074
|
-
[executable, script_path, *args],
|
|
2082
|
+
[web_search_adapters.executable_path(executable), script_path, *args],
|
|
2075
2083
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
|
|
2076
|
-
env={**
|
|
2084
|
+
env={**node_locator.environment(), "SMART_TOOL_USER_AGENT": version.user_agent(config.load_config().get("contact"))},
|
|
2077
2085
|
**popen_kwargs,
|
|
2078
2086
|
)
|
|
2079
2087
|
try:
|
|
@@ -4176,6 +4184,14 @@ def _probe_capabilities():
|
|
|
4176
4184
|
_log(f"capabilities probe failed: {type(exc).__name__}: {exc}")
|
|
4177
4185
|
|
|
4178
4186
|
|
|
4187
|
+
def _heal_browser_control():
|
|
4188
|
+
"""Re-registers the Playwright MCP server when the Node it was registered with is gone (Node moved or reinstalled)."""
|
|
4189
|
+
try:
|
|
4190
|
+
browser_control.heal(_log)
|
|
4191
|
+
except Exception as exc:
|
|
4192
|
+
_log(f"browser control not refreshed: {type(exc).__name__}: {exc}")
|
|
4193
|
+
|
|
4194
|
+
|
|
4179
4195
|
def main():
|
|
4180
4196
|
global _PROJECT_MONITOR
|
|
4181
4197
|
# O bind vem antes de qualquer I/O de rede: enquanto a porta não abre, o
|
|
@@ -4199,6 +4215,7 @@ def main():
|
|
|
4199
4215
|
except (OSError, ValueError) as exc:
|
|
4200
4216
|
_log(f"clients not pointed to port {_BOUND_PORT}: {type(exc).__name__}: {exc}")
|
|
4201
4217
|
atexit.register(BROWSER_SERVICE.close)
|
|
4218
|
+
threading.Thread(target=_heal_browser_control, daemon=True).start()
|
|
4202
4219
|
try:
|
|
4203
4220
|
recovered = project_store.recover_interrupted()
|
|
4204
4221
|
project_store.import_known_legacy(indexer.INDEX_DIR)
|
package/update_check.py
CHANGED
|
@@ -13,6 +13,7 @@ import urllib.request
|
|
|
13
13
|
|
|
14
14
|
import atomic_io
|
|
15
15
|
import config
|
|
16
|
+
import node_locator
|
|
16
17
|
import paths
|
|
17
18
|
import version
|
|
18
19
|
|
|
@@ -136,7 +137,9 @@ def notices(now=None):
|
|
|
136
137
|
|
|
137
138
|
def run_update(latest, automatic):
|
|
138
139
|
"""Starts the install command in its own visible console, detached from the tray (the installer stops and restarts
|
|
139
|
-
the tray). The console closes on success and stays open with the error on failure.
|
|
140
|
+
the tray). The console closes on success and stays open with the error on failure. npx is looked up now, not in the
|
|
141
|
+
PATH the tray was born with: a Node installed or moved since the last logon still runs the update, and the folder
|
|
142
|
+
found is put first in the PATH the installer (and the tray it restarts) inherits."""
|
|
140
143
|
if automatic:
|
|
141
144
|
state = _state()
|
|
142
145
|
state.update(attempted=latest, at=time.time())
|
|
@@ -144,4 +147,4 @@ def run_update(latest, automatic):
|
|
|
144
147
|
flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
145
148
|
command = f'title Smart Tool update to {latest} && {UPDATE_COMMAND} || pause'
|
|
146
149
|
subprocess.Popen(["cmd.exe", "/c", command], creationflags=flags, close_fds=True,
|
|
147
|
-
cwd=os.path.expanduser("~"))
|
|
150
|
+
cwd=os.path.expanduser("~"), env=node_locator.environment(prefer=node_locator.which("npx.cmd")))
|
package/version.py
CHANGED
package/web_search_adapters.py
CHANGED
|
@@ -31,11 +31,14 @@ Bing entra pelo tier yahoo.
|
|
|
31
31
|
import os
|
|
32
32
|
import sys
|
|
33
33
|
|
|
34
|
+
import node_locator
|
|
35
|
+
|
|
34
36
|
PACKAGE_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
35
37
|
NODE_ADAPTER_DIR = os.path.join(PACKAGE_DIR, "web_adapters", "node")
|
|
36
38
|
BROWSER_ADAPTER_DIR = os.path.join(PACKAGE_DIR, "web_adapters", "browser")
|
|
37
|
-
|
|
38
|
-
|
|
39
|
+
# Adapters name Node, not a path: `executable_path` finds it when the adapter is launched (node_locator), so a Node
|
|
40
|
+
# installed, moved or removed while the daemon runs is followed without a restart.
|
|
41
|
+
NODE_EXECUTABLE = "node"
|
|
39
42
|
CAMOUFOX_VENV_PYTHON = os.path.join(BROWSER_ADAPTER_DIR, ".venv", "Scripts", "python.exe")
|
|
40
43
|
BROWSER_SERVICE_SCRIPT = os.path.join(BROWSER_ADAPTER_DIR, "browser_service.py")
|
|
41
44
|
DDG_SEARCH_SCRIPT = os.path.join(NODE_ADAPTER_DIR, "ddg-search.mjs")
|
|
@@ -44,6 +47,13 @@ WIKIPEDIA_SEARCH_SCRIPT = os.path.join(NODE_ADAPTER_DIR, "wikipedia-search.mjs")
|
|
|
44
47
|
EXTERNAL_SEARCH_SCRIPT = os.path.join(PACKAGE_DIR, "web_search_external.py")
|
|
45
48
|
DDGS_SEARCH_SCRIPT = os.path.join(PACKAGE_DIR, "web_search_ddgs.py")
|
|
46
49
|
|
|
50
|
+
|
|
51
|
+
def executable_path(executable):
|
|
52
|
+
"""The program to launch for an adapter's `executable`: Node is looked up now (PATH first, then the Node inside
|
|
53
|
+
Smart Tool); any other executable is returned unchanged. A Node that is nowhere stays `node`, so the launch fails
|
|
54
|
+
with the system's own "not found"."""
|
|
55
|
+
return (node_locator.node() or NODE_EXECUTABLE) if executable == NODE_EXECUTABLE else executable
|
|
56
|
+
|
|
47
57
|
FETCH_TIMEOUT_S = 15
|
|
48
58
|
OPEN_WEBSEARCH_TIMEOUT_S = 30
|
|
49
59
|
WIKIPEDIA_TIMEOUT_S = 15
|