@allansantos-dev/smart-tool 0.9.4 → 0.9.6
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 +62 -0
- package/README.md +3 -1
- package/affected_tests.py +89 -6
- package/code_graph.py +1 -1
- package/code_impact.py +28 -1
- package/config.py +9 -0
- package/duplicates.py +23 -7
- package/hook_decision.py +117 -4
- package/package.json +1 -1
- package/router.py +28 -1
- package/smart_tool_daemon.py +48 -7
- package/tray.py +51 -8
- package/update_check.py +36 -2
- package/version.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,67 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.6 - beta
|
|
4
|
+
|
|
5
|
+
Reported by the claude-code-boss session validating 0.9.5.
|
|
6
|
+
|
|
7
|
+
- `project_manage action=duplicates` reads the files the index does not reflect yet (working tree, files changed on
|
|
8
|
+
disk, commits after the last indexing) and says how many it read: it kept reporting a function removed in a commit
|
|
9
|
+
made after the last indexing, with no sign that the index was behind.
|
|
10
|
+
- In `graph` impact results, a test or caller that is an anonymous callback registered with `test('title', fn)`,
|
|
11
|
+
`it()` or `describe()` shows its title (`test "embedder (Q47): ..."`) instead of `callback`.
|
|
12
|
+
- `graph` with `symbol` accepts a partial path before `::` (`brain-embedder.js::loadConfig`), matching the files whose
|
|
13
|
+
path ends with it; it needed the full path from the project root.
|
|
14
|
+
|
|
15
|
+
Automatic updates.
|
|
16
|
+
|
|
17
|
+
- When the tray starts (at logon, before agent sessions use the daemon) and npm has a newer version, it runs the
|
|
18
|
+
install command in a visible console that closes on success and stays open on failure. Each version is installed
|
|
19
|
+
automatically once: a failed update restores the previous version and restarts the tray, which then only offers it.
|
|
20
|
+
- The tray menu has "Update to X" (or "Check for updates", which says when the installed version is the latest) and an
|
|
21
|
+
"Update automatically" switch (`auto_update`, on by default). Later daily checks only notify.
|
|
22
|
+
|
|
23
|
+
Agents that ignored the hook's redirect: only 44 of 368 blocked calls (12%) were followed by a `smart_search` within
|
|
24
|
+
30 s; the rest rewrote the search in Bash, node or python.
|
|
25
|
+
|
|
26
|
+
- The block message names the exact tool and arguments (`mcp__smart-tool__smart_search` with the project root filled
|
|
27
|
+
in, `web_fetch` with the URL), says it is a routing rule and not a failure, that redoing the search through Bash,
|
|
28
|
+
python, node or PowerShell bypasses it, and, in Claude Code, how to load the tool with ToolSearch.
|
|
29
|
+
- The MCP server sends instructions on what to do when a call is blocked, and `smart_search`, `web_fetch`,
|
|
30
|
+
`web_search` and their `_result` tools load upfront (`anthropic/alwaysLoad`): Claude Code defers MCP tools, so a
|
|
31
|
+
blocked agent often did not have the replacement in its tool list. In real `claude -p` sessions, an agent asked to
|
|
32
|
+
read a page went straight to `web_fetch`, and one asked to run a broad `grep -rn` switched to `smart_search` after
|
|
33
|
+
the block.
|
|
34
|
+
- `cd dir; grep -n x file.py` was measured as a search over the whole tree (the `;` stuck to the directory name hid
|
|
35
|
+
the file): 45 of the 92 logged Bash redirects with `cd dir;` were reads of one file. A command with a recursive
|
|
36
|
+
search after a file read (`sed -n 1,9p a.py; grep -rn x src`) is still measured by the directory it searches.
|
|
37
|
+
- A subagent whose tool list has no Smart Tool tool (claude-code-guide, statusline-setup, or an agent in
|
|
38
|
+
`.claude/agents` with a `tools` list without it) is not redirected: it had nothing to switch to and gave up.
|
|
39
|
+
- Bash commands that change files, the repository or dependencies (`git checkout`, `rm`, `sed -i`, `writeFileSync`,
|
|
40
|
+
`npm run`, ...) are never redirected and skip the router model: 12 of 283 Bash redirects were such commands.
|
|
41
|
+
|
|
42
|
+
## 0.9.5 - beta
|
|
43
|
+
|
|
44
|
+
Reported by the claude-code-boss session on its first day using 0.9.4.
|
|
45
|
+
|
|
46
|
+
- `affected_tests` answered "no tests" for a module covered by a 1.4 MB test file (1,427 cases): the file was over the
|
|
47
|
+
index size limit and the working-tree reading skipped files over 1 MB, so it fell out of the import graph silently.
|
|
48
|
+
Files up to 5 MB are now analyzed for the graph, and any tracked test file still outside it is selected ("may cover
|
|
49
|
+
the change") instead of disappearing.
|
|
50
|
+
- Tests that run a script by path (`spawnSync('node', [path.join(SCRIPTS, 'hook.js')])`, `subprocess.run(["python",
|
|
51
|
+
"tools/x.py"])`) now depend on that script: a file name in a string literal of a test, resolved against the test's
|
|
52
|
+
folder, the folders above it and the project root, links them.
|
|
53
|
+
- A test is left out of the list only when it has no test cases (`conftest.py`, fixtures, helpers). The former rule
|
|
54
|
+
("imported by another test") dropped real Java tests that share test types: on gson it caught 19 of 28 injected
|
|
55
|
+
faults; now 28 of 28. JUnit 3 suites (`@RunWith(AllTests.class)`, `static Test suite()`) count as tests.
|
|
56
|
+
- Test files named in `package.json` scripts (`"test": "node scripts/test-units.js"`) get a `node <file>` command.
|
|
57
|
+
- An anonymous callback among the touched functions is named after the function that contains it.
|
|
58
|
+
- `project_manage register` and `status` return the project with its last jobs summarized and without the scope's
|
|
59
|
+
folder structure and profile groups (`scope_summary` keeps include/exclude): they returned the full result of every
|
|
60
|
+
past search (108-128 thousand characters on one project, now 3.3 thousand). The projects screen keeps the full
|
|
61
|
+
payload.
|
|
62
|
+
- A project is no longer marked `degraded` because the per-chunk function analysis was still being prepared: that note
|
|
63
|
+
is shown with the search results but is not a degradation.
|
|
64
|
+
|
|
3
65
|
## 0.9.4 - beta
|
|
4
66
|
|
|
5
67
|
Found by using 0.9.3 on its own repository right after installing it.
|
package/README.md
CHANGED
|
@@ -29,7 +29,9 @@ are cheaper, or just tells the agent what Smart Tool would do.
|
|
|
29
29
|
npx @allansantos-dev/smart-tool@latest install
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
Updates install themselves: when the tray starts (at logon) and npm has a newer version, it runs this same command in
|
|
33
|
+
a visible console, once per version; the tray menu has "Update to X" (or "Check for updates") on demand and an
|
|
34
|
+
"Update automatically" switch. Running the command by hand also updates; `~/.smart-tool` is kept. It downloads [uv](https://docs.astral.sh/uv/) (checksum
|
|
33
35
|
verified) and a private Python 3.12 into `~/.smart-tool/runtime`, with nothing added to `PATH` or the registry, then
|
|
34
36
|
copies Smart Tool to `%LOCALAPPDATA%\Programs\SmartTool` with its own virtual environment,
|
|
35
37
|
installs the web runtime, registers a per-user Scheduled Task that starts the tray at logon and starts the daemon on
|
package/affected_tests.py
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"""Tests to run for the current changes: git diff against a base (default HEAD, so staged, unstaged and untracked
|
|
2
2
|
files), then every test file that imports a changed file directly or through other files, plus changed tests, ordered
|
|
3
3
|
so the most likely failures come first (tests calling a touched function, test file named after the function or the
|
|
4
|
-
module, import distance). Test support files (conftest.py, helpers
|
|
5
|
-
not listed to run. Selection by file imports, not by calls: in fault-injection measurements on Python,
|
|
4
|
+
module, import distance). Test support files (conftest.py, fixtures and helpers without test cases) carry the
|
|
5
|
+
selection but are not listed to run. Selection by file imports, not by calls: in fault-injection measurements on Python,
|
|
6
6
|
JavaScript, TypeScript and Java projects static calls alone found 2 to 37% of the failing test files."""
|
|
7
7
|
import ast
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
|
+
import posixpath
|
|
10
11
|
import re
|
|
11
12
|
import subprocess
|
|
12
13
|
from collections import defaultdict, deque
|
|
@@ -30,6 +31,8 @@ NOTE = ("Selection from the indexed import graph: tests that load code through s
|
|
|
30
31
|
"suite before committing; run_all is true when a changed file can change every test (config, lockfile, "
|
|
31
32
|
"unanalyzed code). Changed files and files committed after the last indexing are read from disk, so new "
|
|
32
33
|
"files and imports count before reindexing; pytest fixtures link a test to the conftest.py that defines them.")
|
|
34
|
+
TEST_SOURCES = (".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".java")
|
|
35
|
+
_PATH_LITERAL = re.compile(r"""['"`]((?:\.{0,2}/)?[\w.-]+(?:/[\w.-]+)*\.(?:py|js|mjs|cjs|ts|mts|cts|java))['"`]""")
|
|
33
36
|
_HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", re.M)
|
|
34
37
|
|
|
35
38
|
|
|
@@ -122,6 +125,10 @@ def _commands(root, tests):
|
|
|
122
125
|
runner = next((cmd for key, cmd in (("vitest", "npx vitest run"), ("jest", "npx jest"),
|
|
123
126
|
("mocha", "npx mocha"), ("node --test", "node --test"))
|
|
124
127
|
if key in tools), None)
|
|
128
|
+
local = os.path.relpath(path, folder or ".").replace(os.sep, "/")
|
|
129
|
+
scripts = " ".join(str(v) for v in (package.get("scripts") or {}).values())
|
|
130
|
+
if runner is None and re.search(rf"\bnode\s+(\./)?{re.escape(local)}\b", scripts):
|
|
131
|
+
runner = "node-script"
|
|
125
132
|
groups[(folder or "", runner)].append(path)
|
|
126
133
|
elif lower.endswith(".java"):
|
|
127
134
|
folder, name = _nearest(root, path, ("pom.xml", "build.gradle", "build.gradle.kts"))
|
|
@@ -138,6 +145,9 @@ def _commands(root, tests):
|
|
|
138
145
|
if runner == "maven":
|
|
139
146
|
names = ",".join(sorted({os.path.basename(f).rsplit(".", 1)[0] for f in files}))
|
|
140
147
|
line = f"mvn test -Dtest={names} -Dsurefire.failIfNoSpecifiedTests=false"
|
|
148
|
+
elif runner == "node-script":
|
|
149
|
+
commands.extend({"cwd": folder or ".", "command": f"node {f}"} for f in sorted(local))
|
|
150
|
+
continue
|
|
141
151
|
elif runner == "unittest-package":
|
|
142
152
|
line = "python -m unittest " + " ".join(sorted(f[:-3].replace("/", ".") for f in files))
|
|
143
153
|
elif runner == "unittest-folder":
|
|
@@ -194,6 +204,75 @@ def _fixture_users(root, test_files):
|
|
|
194
204
|
return users
|
|
195
205
|
|
|
196
206
|
|
|
207
|
+
def _path_literal_users(root, test_files, known):
|
|
208
|
+
"""Code files a test names in a string literal ('graph-guard.js', "scripts/run.py"), resolved against the test's
|
|
209
|
+
folder, the folders above it and the project root: tests that run scripts as subprocesses or load them by path
|
|
210
|
+
(spawnSync('node', [path.join(SCRIPTS, 'hook.js')])) depend on them without importing them."""
|
|
211
|
+
users = defaultdict(set)
|
|
212
|
+
for test in test_files:
|
|
213
|
+
if not test.lower().endswith(TEST_SOURCES):
|
|
214
|
+
continue
|
|
215
|
+
source = _read(os.path.join(root, test))
|
|
216
|
+
folders, folder = [], os.path.dirname(test)
|
|
217
|
+
while True:
|
|
218
|
+
folders.append(folder)
|
|
219
|
+
if not folder:
|
|
220
|
+
break
|
|
221
|
+
folder = os.path.dirname(folder)
|
|
222
|
+
for literal in set(_PATH_LITERAL.findall(source)):
|
|
223
|
+
for base in folders:
|
|
224
|
+
candidate = posixpath.normpath(posixpath.join(base, literal.removeprefix("./")))
|
|
225
|
+
if candidate in known and candidate != test:
|
|
226
|
+
users[candidate].add(test)
|
|
227
|
+
break
|
|
228
|
+
return users
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
_TEST_CASES = {
|
|
232
|
+
".py": re.compile(r"^\s*(async\s+)?def\s+test\w*\s*\(|^\s*class\s+Test\w*|unittest\.TestCase", re.M),
|
|
233
|
+
".java": re.compile(r"@(Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate|RunWith|Suite)\b|"
|
|
234
|
+
r"\bstatic\s+\w*Test\s+suite\s*\(|\bextends\s+TestCase\b|\bvoid\s+test\w*\s*\("),
|
|
235
|
+
"script": re.compile(r"\b(it|test|describe|suite)(\.\w+)?\s*\(|\bDeno\.test\b|\bnode:test\b|\bassert\b"),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _support(root, path):
|
|
240
|
+
"""Test-kind file without test cases (conftest.py, fixtures, helpers): it carries the selection but is not run.
|
|
241
|
+
A test referenced by another test (shared Java test types) is still a test."""
|
|
242
|
+
if os.path.basename(path) == "conftest.py":
|
|
243
|
+
return True
|
|
244
|
+
ext = os.path.splitext(path)[1].lower()
|
|
245
|
+
pattern = _TEST_CASES.get(ext) or (_TEST_CASES["script"] if ext in TEST_SOURCES else None)
|
|
246
|
+
source = _read(os.path.join(root, path))
|
|
247
|
+
return bool(pattern and source and not pattern.search(source))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _tracked_tests_outside_graph(root, parsed, profile):
|
|
251
|
+
"""Code test files git tracks that the graph could not analyze (over the size limits, unreadable): their imports
|
|
252
|
+
are unknown, so they are selected instead of silently missing (a 1.4 MB test file covering 1,427 cases did)."""
|
|
253
|
+
listed = subprocess.run(["git", "ls-files", "-z"], cwd=root, capture_output=True, timeout=30)
|
|
254
|
+
if listed.returncode:
|
|
255
|
+
return []
|
|
256
|
+
tests = []
|
|
257
|
+
for rel in listed.stdout.decode("utf-8", "replace").split("\0"):
|
|
258
|
+
if rel and rel not in parsed and rel.lower().endswith(TEST_SOURCES) and os.path.basename(rel) != "conftest.py" \
|
|
259
|
+
and index_profile.kind(rel, profile) == "test" and os.path.isfile(os.path.join(root, rel)):
|
|
260
|
+
tests.append(rel)
|
|
261
|
+
return tests
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _label(symbol, by_id):
|
|
265
|
+
"""path::name of a touched function; an anonymous callback is named after the function that contains it."""
|
|
266
|
+
short = symbol["name"].split("(")[0].split(".")[-1]
|
|
267
|
+
if short in ("callback", "anonymous"):
|
|
268
|
+
parent = by_id.get(symbol.get("parent"))
|
|
269
|
+
while parent and parent["name"].split("(")[0].split(".")[-1] in ("callback", "anonymous"):
|
|
270
|
+
parent = by_id.get(parent.get("parent"))
|
|
271
|
+
if parent:
|
|
272
|
+
return f"{symbol['path']}::{parent['name']} (callback at line {symbol['start_line']})"
|
|
273
|
+
return f"{symbol['path']}::{symbol['name']}"
|
|
274
|
+
|
|
275
|
+
|
|
197
276
|
def affected(root, base="HEAD", limit=30, view_id=None):
|
|
198
277
|
"""Test files to run for the changes since base, most likely failures first, with the command to run them."""
|
|
199
278
|
if not isinstance(base, str) or not re.fullmatch(r"[\w./@^~{}-]{1,200}", base) or base.startswith("-"):
|
|
@@ -217,6 +296,8 @@ def affected(root, base="HEAD", limit=30, view_id=None):
|
|
|
217
296
|
callers[call["target"]].add(call["source"])
|
|
218
297
|
for conftest, users in _fixture_users(root, test_files).items():
|
|
219
298
|
importers[conftest] |= users
|
|
299
|
+
for target, users in _path_literal_users(root, test_files, {f["path"] for f in data.get("files") or []}).items():
|
|
300
|
+
importers[target] |= users
|
|
220
301
|
by_id = {s["id"]: s for s in data.get("symbols") or []}
|
|
221
302
|
run_all, not_analyzed, changed_code, changed_tests, conftests = [], [], [], [], []
|
|
222
303
|
for path, change in sorted(changes.items()):
|
|
@@ -260,9 +341,11 @@ def affected(root, base="HEAD", limit=30, view_id=None):
|
|
|
260
341
|
selected.setdefault(test, f"{conftest} changed")
|
|
261
342
|
for test in set(import_depth) | set(call_hops):
|
|
262
343
|
selected.setdefault(test, None)
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
344
|
+
selected = {test: reason for test, reason in selected.items() if not _support(root, test)}
|
|
345
|
+
if changed_code or conftests:
|
|
346
|
+
for test in _tracked_tests_outside_graph(root, parsed, profile):
|
|
347
|
+
selected.setdefault(test, "test file outside the import graph (too large or not analyzed): may cover "
|
|
348
|
+
"the change")
|
|
266
349
|
|
|
267
350
|
def why(test):
|
|
268
351
|
if selected[test]:
|
|
@@ -283,7 +366,7 @@ def affected(root, base="HEAD", limit=30, view_id=None):
|
|
|
283
366
|
"base": base,
|
|
284
367
|
"changed": {"code": changed_code[:limit], "tests": changed_tests[:limit],
|
|
285
368
|
"other": sorted(p for p in changes if p not in changed_code and p not in changed_tests)[:limit]},
|
|
286
|
-
"touched_functions": [
|
|
369
|
+
"touched_functions": [_label(s, by_id) for s in touched][:limit],
|
|
287
370
|
"tests": [{"path": t, "why": why(t)} for t in ordered[:limit]],
|
|
288
371
|
"counts": {"changed_files": len(changes), "tests": len(ordered), "test_files": len(test_files)},
|
|
289
372
|
"run_all": bool(run_all),
|
package/code_graph.py
CHANGED
|
@@ -23,7 +23,7 @@ import document_text
|
|
|
23
23
|
|
|
24
24
|
VERSION = 2
|
|
25
25
|
SOURCE_EXTENSIONS = ('.py','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')
|
|
26
|
-
MAX_OVERLAY_BYTES = 1024 * 1024
|
|
26
|
+
MAX_OVERLAY_BYTES = 5 * 1024 * 1024
|
|
27
27
|
MAX_FILES = 3000
|
|
28
28
|
MAX_TEXT = 24 * 1024 * 1024
|
|
29
29
|
MAX_SYMBOLS = 8000
|
package/code_impact.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
which tests reach it through static calls and which files import its module, each with the first line of its
|
|
3
3
|
docstring. Answers the question an agent asks before an edit without a chain of searches and file reads."""
|
|
4
4
|
import os
|
|
5
|
+
import re
|
|
5
6
|
from collections import defaultdict
|
|
6
7
|
|
|
7
8
|
import code_graph
|
|
@@ -17,7 +18,7 @@ NOTE = ("Static analysis of the indexed snapshot plus the files changed in the w
|
|
|
17
18
|
def _matches(symbols, name):
|
|
18
19
|
path, _sep, wanted = name.strip().rpartition("::")
|
|
19
20
|
path = path.replace("\\", "/").removeprefix("./")
|
|
20
|
-
pool = [s for s in symbols if s["path"] == path] if path else symbols
|
|
21
|
+
pool = [s for s in symbols if s["path"] == path or s["path"].endswith("/" + path)] if path else symbols
|
|
21
22
|
exact = [s for s in pool if s["id"] == wanted or s["name"] == wanted]
|
|
22
23
|
return exact or [s for s in pool if s["name"].split(".")[-1] == wanted]
|
|
23
24
|
|
|
@@ -49,6 +50,31 @@ def summaries(root, symbols, wanted_ids):
|
|
|
49
50
|
if s["id"] in wanted_ids and (s["path"], s["start_line"]) in docs}
|
|
50
51
|
|
|
51
52
|
|
|
53
|
+
_TEST_TITLE = re.compile(r"""\b(test|it|describe|suite)(?:\.\w+)?\s*\(\s*(['"`])(.+?)\2""")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _name_test_callbacks(root, by_id, sites):
|
|
57
|
+
"""Anonymous callbacks registered with test('title', fn), it(), describe() are named after their title, so a test
|
|
58
|
+
reads 'test "loads the config"' instead of 'callback'."""
|
|
59
|
+
lines_of = {}
|
|
60
|
+
for site in sites:
|
|
61
|
+
symbol = by_id.get(site.get("id")) or {}
|
|
62
|
+
if symbol.get("name", "").split(".")[-1] not in ("callback", "anonymous"):
|
|
63
|
+
continue
|
|
64
|
+
path = symbol["path"]
|
|
65
|
+
if path not in lines_of:
|
|
66
|
+
try:
|
|
67
|
+
with open(os.path.join(root, path), encoding="utf-8") as stream:
|
|
68
|
+
lines_of[path] = stream.read().splitlines()
|
|
69
|
+
except (OSError, UnicodeDecodeError):
|
|
70
|
+
lines_of[path] = []
|
|
71
|
+
start = symbol["start_line"]
|
|
72
|
+
text = " ".join(lines_of[path][max(start - 2, 0):start])
|
|
73
|
+
match = _TEST_TITLE.search(text)
|
|
74
|
+
if match:
|
|
75
|
+
site["function"] = f'{match.group(1)} "{match.group(3)[:80]}"'
|
|
76
|
+
|
|
77
|
+
|
|
52
78
|
def impact(root, symbol, view_id=None, depth=3, limit=30):
|
|
53
79
|
if not isinstance(symbol, str) or not symbol.strip():
|
|
54
80
|
raise ValueError("Pass symbol: a function, method (Class.method) or class name.")
|
|
@@ -92,6 +118,7 @@ def impact(root, symbol, view_id=None, depth=3, limit=30):
|
|
|
92
118
|
importers = sorted({d["source"] for d in data["dependencies"] if d.get("target") in files and d["source"] not in files})
|
|
93
119
|
ordered_tests = sorted(tests.values(), key=lambda t: (t["hops"], t["path"], t["line"]))
|
|
94
120
|
shown = direct[:limit] + outgoing[:limit] + ordered_tests[:limit]
|
|
121
|
+
_name_test_callbacks(root, by_id, shown)
|
|
95
122
|
docs = summaries(root, data["symbols"], targets | {site["id"] for site in shown})
|
|
96
123
|
for site in shown:
|
|
97
124
|
doc = docs.get(site.pop("id"))
|
package/config.py
CHANGED
|
@@ -33,6 +33,7 @@ DEFAULT_CONFIG = {
|
|
|
33
33
|
"hook_mode": "redirect",
|
|
34
34
|
"doc_mode": "remind",
|
|
35
35
|
"duplicate_mode": "warn",
|
|
36
|
+
"auto_update": True,
|
|
36
37
|
"contact": "",
|
|
37
38
|
"model_adapter": "",
|
|
38
39
|
"model_adapter_options": {},
|
|
@@ -66,6 +67,14 @@ def duplicate_mode(cfg=None):
|
|
|
66
67
|
return value
|
|
67
68
|
|
|
68
69
|
|
|
70
|
+
def auto_update(cfg=None):
|
|
71
|
+
"""Whether the tray installs a newer release by itself when it starts (once per version)."""
|
|
72
|
+
value = (cfg if cfg is not None else load_config()).get("auto_update", DEFAULT_CONFIG["auto_update"])
|
|
73
|
+
if not isinstance(value, bool):
|
|
74
|
+
raise ValueError(f"Invalid auto_update ({value!r}) in {CONFIG_PATH}: use true or false.")
|
|
75
|
+
return value
|
|
76
|
+
|
|
77
|
+
|
|
69
78
|
def hook_mode(cfg=None):
|
|
70
79
|
value = (cfg if cfg is not None else load_config()).get("hook_mode") or DEFAULT_CONFIG["hook_mode"]
|
|
71
80
|
if value not in HOOK_MODES:
|
package/duplicates.py
CHANGED
|
@@ -199,13 +199,16 @@ def _entry(path, name, start, end, file_lines, ext):
|
|
|
199
199
|
"hash": hashlib.sha1(normalized.encode("utf-8")).hexdigest(), **_features(body, ext, short)}
|
|
200
200
|
|
|
201
201
|
|
|
202
|
-
def _working_tree_functions(root, changes, profile):
|
|
203
|
-
"""
|
|
204
|
-
|
|
202
|
+
def _working_tree_functions(root, changes, profile, include_tests=False):
|
|
203
|
+
"""Functions of the files the index does not reflect yet, read from disk (production code, plus tests when
|
|
204
|
+
include_tests), with the same identity as indexed ones, so a copy written earlier in the session is caught and
|
|
205
|
+
a function removed since the last indexing is not reported."""
|
|
205
206
|
found = []
|
|
207
|
+
kinds = ("code", "test") if include_tests else ("code",)
|
|
206
208
|
for rel, status in changes.items():
|
|
207
209
|
ext = rel.rsplit(".", 1)[-1].lower() if "." in rel else ""
|
|
208
|
-
|
|
210
|
+
kind = index_profile.kind(rel, profile)
|
|
211
|
+
if status == "deleted" or ext not in _LANG or kind not in kinds:
|
|
209
212
|
continue
|
|
210
213
|
try:
|
|
211
214
|
with open(os.path.join(root, rel), encoding="utf-8") as stream:
|
|
@@ -215,12 +218,18 @@ def _working_tree_functions(root, changes, profile):
|
|
|
215
218
|
if _GENERATED.search(source[:4000]):
|
|
216
219
|
continue
|
|
217
220
|
lines = source.splitlines()
|
|
218
|
-
|
|
219
|
-
|
|
221
|
+
functions = doc_check.functions(rel, source)
|
|
222
|
+
occurrences = {}
|
|
223
|
+
for function in functions:
|
|
224
|
+
if function["end"] - function["start"] + 1 < MIN_LINES or any(
|
|
225
|
+
other is not function and other["start"] <= function["start"] and function["end"] <= other["end"]
|
|
226
|
+
and (other["start"], other["end"]) != (function["start"], function["end"]) for other in functions):
|
|
220
227
|
continue
|
|
221
228
|
entry = _entry(rel, function["name"], function["start"], function["end"], lines, ext)
|
|
222
229
|
if entry:
|
|
223
|
-
|
|
230
|
+
occurrence = occurrences[entry["hash"]] = occurrences.get(entry["hash"], 0) + 1
|
|
231
|
+
found.append({**entry, "kind": kind, "fp": hashlib.sha1(
|
|
232
|
+
f"{rel}\0{entry['hash']}\0{occurrence}".encode("utf-8")).hexdigest()})
|
|
224
233
|
return found
|
|
225
234
|
|
|
226
235
|
|
|
@@ -359,6 +368,13 @@ def find(root, embed, configured_model, min_similarity=DEFAULT_MIN_SIMILARITY, i
|
|
|
359
368
|
started = time.monotonic()
|
|
360
369
|
dismissed = dismissed or {}
|
|
361
370
|
functions, notes, view = _functions(root, include_tests)
|
|
371
|
+
if view.get("current"):
|
|
372
|
+
pending = code_graph.pending_changes(root, view["path"])
|
|
373
|
+
if pending:
|
|
374
|
+
profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile"))
|
|
375
|
+
functions = [f for f in functions if f["path"] not in pending] + _working_tree_functions(
|
|
376
|
+
root, pending, profile, include_tests)
|
|
377
|
+
notes.append(f"{len(pending)} file(s) changed since the last indexing were read from disk.")
|
|
362
378
|
groups = {}
|
|
363
379
|
for f in functions:
|
|
364
380
|
groups.setdefault(f["hash"], []).append(f)
|
package/hook_decision.py
CHANGED
|
@@ -42,6 +42,114 @@ def deny(reason):
|
|
|
42
42
|
"permissionDecisionReason": reason}}
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
# Claude Code built-in subagents whose tool list has no MCP tool (claude-code-guide: Glob, Grep, Read, WebFetch,
|
|
46
|
+
# WebSearch; statusline-setup: Read, Edit).
|
|
47
|
+
BUILTIN_AGENTS_WITHOUT_MCP = frozenset({"claude-code-guide", "statusline-setup"})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _frontmatter(path):
|
|
51
|
+
"""`name` and `tools` of an agent definition (.md with YAML frontmatter); tools is None when the field is absent
|
|
52
|
+
(the agent inherits every tool), else the list of tool names (inline `[a, b]`, `a, b` or a `- a` block)."""
|
|
53
|
+
with open(path, encoding="utf-8") as stream:
|
|
54
|
+
lines = stream.read().splitlines()
|
|
55
|
+
if not lines or lines[0].strip() != "---":
|
|
56
|
+
return None, None
|
|
57
|
+
name, tools, block = None, None, False
|
|
58
|
+
for line in lines[1:]:
|
|
59
|
+
if line.strip() == "---":
|
|
60
|
+
break
|
|
61
|
+
if block and line.lstrip().startswith("- "):
|
|
62
|
+
tools.append(line.strip()[2:].strip().strip("'\""))
|
|
63
|
+
continue
|
|
64
|
+
block = False
|
|
65
|
+
key, _sep, value = line.partition(":")
|
|
66
|
+
value = value.strip()
|
|
67
|
+
if key == "name":
|
|
68
|
+
name = value.strip("'\"")
|
|
69
|
+
elif key == "tools":
|
|
70
|
+
if value:
|
|
71
|
+
tools = [item.strip().strip("'\"") for item in value.strip("[]").split(",") if item.strip()]
|
|
72
|
+
else:
|
|
73
|
+
tools, block = [], True
|
|
74
|
+
return name, tools
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def agent_has_smart_tool(agent_type, cwd):
|
|
78
|
+
"""Whether the subagent running the call can call the Smart Tool MCP tools: denying a native tool to an agent that
|
|
79
|
+
has no replacement only makes it give up. Agents defined in .claude/agents (user or project) are read; unknown
|
|
80
|
+
agents keep the redirect."""
|
|
81
|
+
if not agent_type:
|
|
82
|
+
return True
|
|
83
|
+
if agent_type in BUILTIN_AGENTS_WITHOUT_MCP:
|
|
84
|
+
return False
|
|
85
|
+
for folder in (os.path.join(cwd or "", ".claude", "agents"), os.path.join(endpoint_sync.claude_dir(), "agents")):
|
|
86
|
+
try:
|
|
87
|
+
names = sorted(os.listdir(folder))
|
|
88
|
+
except OSError:
|
|
89
|
+
continue
|
|
90
|
+
for entry in names:
|
|
91
|
+
if not entry.endswith(".md"):
|
|
92
|
+
continue
|
|
93
|
+
try:
|
|
94
|
+
name, tools = _frontmatter(os.path.join(folder, entry))
|
|
95
|
+
except (OSError, UnicodeDecodeError):
|
|
96
|
+
continue
|
|
97
|
+
if (name or entry.split(".")[0]) != agent_type:
|
|
98
|
+
continue
|
|
99
|
+
return tools is None or any(tool == "*" or tool.startswith(f"mcp__{SMART_TOOL_SERVER}") for tool in tools)
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _search_root(tool_input, cwd):
|
|
104
|
+
"""project_root for smart_search: the registered project holding what the call searches (its path field, or the
|
|
105
|
+
first existing path of the command, after any `cd`), else that directory; the session directory when the call
|
|
106
|
+
names no path."""
|
|
107
|
+
raw = router._target_path(tool_input, cwd)
|
|
108
|
+
paths = [router._resolve_path(raw, cwd)] if raw else router._command_paths(tool_input.get("command"), cwd)
|
|
109
|
+
target = os.path.abspath(next((p for p in paths if p), None) or cwd or ".")
|
|
110
|
+
if os.path.isfile(target):
|
|
111
|
+
target = os.path.dirname(target)
|
|
112
|
+
key = os.path.normcase(target) + os.sep
|
|
113
|
+
roots = [p["root"] for p in project_store.all_projects()
|
|
114
|
+
if key.startswith(os.path.normcase(os.path.abspath(p["root"])).rstrip(os.sep) + os.sep)]
|
|
115
|
+
return max(roots, key=len) if roots else target
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _tool_name(client, tool):
|
|
119
|
+
return f"mcp__{SMART_TOOL_SERVER}__{tool}" if client == "claude" else f"the {tool} tool of the {SMART_TOOL_SERVER} MCP server"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _load_hint(client, tool):
|
|
123
|
+
"""Claude Code defers MCP tools behind tool search: a blocked agent may not have the schema loaded yet."""
|
|
124
|
+
if client != "claude":
|
|
125
|
+
return ""
|
|
126
|
+
return f' If it is not in your tool list yet, load it first with ToolSearch, query "select:mcp__{SMART_TOOL_SERVER}__{tool}".'
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def redirect_message(client, tool_name, tool_input, reason, cwd):
|
|
130
|
+
"""Deny text for a broad raw search: the exact tool and arguments to call, and that rewriting the same search in
|
|
131
|
+
Bash, python, node or PowerShell is the bypass the rule exists to stop."""
|
|
132
|
+
root = _search_root(tool_input, cwd).replace("\\", "/")
|
|
133
|
+
return (f"Blocked by Smart Tool, a routing rule set by the user, not a failure ({tool_name}: {clean_reason(reason)}). "
|
|
134
|
+
f'Run this search with {_tool_name(client, "smart_search")}: project_root="{root}", query_identifiers = what '
|
|
135
|
+
f"you are looking for in identifier terms (plus query_comments when the project has two languages)."
|
|
136
|
+
f'{_load_hint(client, "smart_search")} Do not repeat the same search through Bash, python, node or '
|
|
137
|
+
f"PowerShell: that bypasses the rule. Reading one known file, or searching inside one known file, is never "
|
|
138
|
+
f"blocked.")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def web_message(client, tool_name, url, why):
|
|
142
|
+
"""Deny text for native WebFetch/WebSearch: the Smart Tool call to make instead, with the URL filled in."""
|
|
143
|
+
if tool_name == "WebFetch":
|
|
144
|
+
call = f'{_tool_name(client, "web_fetch")} with url="{url}" and prompt = what you need from the page'
|
|
145
|
+
tool, fetchers = "web_fetch", "curl, wget, python or PowerShell"
|
|
146
|
+
else:
|
|
147
|
+
call = f"{_tool_name(client, 'web_search')} with query (and query_en in English)"
|
|
148
|
+
tool, fetchers = "web_search", "curl, a browser script or another search tool"
|
|
149
|
+
return (f"Blocked by Smart Tool, a routing rule set by the user, not a failure. Call {call}: {why}"
|
|
150
|
+
f"{_load_hint(client, tool)} Do not fetch it through {fetchers}: that bypasses the rule.")
|
|
151
|
+
|
|
152
|
+
|
|
45
153
|
def smart_tool_in_session(cwd):
|
|
46
154
|
"""Claude Code loads MCP servers from ~/.claude.json (user and per project) and the project's .mcp.json."""
|
|
47
155
|
with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
|
|
@@ -149,7 +257,8 @@ def _edit_review(payload, doc_mode, duplicate_mode):
|
|
|
149
257
|
|
|
150
258
|
def decide(payload, client, web_route):
|
|
151
259
|
"""Hook output for one PreToolUse payload: edits follow doc_mode (require | remind | off) and duplicate_mode
|
|
152
|
-
(warn | off), the other tools follow hook_mode (redirect | advise | off).
|
|
260
|
+
(warn | off), the other tools follow hook_mode (redirect | advise | off). A subagent whose tool list has no
|
|
261
|
+
Smart Tool MCP tool is never redirected: it has nothing to switch to."""
|
|
153
262
|
cfg = config.load_config()
|
|
154
263
|
if isinstance(payload, dict) and payload.get("tool_name") in EDIT_TOOLS:
|
|
155
264
|
doc_mode, duplicate_mode = config.doc_mode(cfg), config.duplicate_mode(cfg)
|
|
@@ -161,6 +270,9 @@ def decide(payload, client, web_route):
|
|
|
161
270
|
return {}
|
|
162
271
|
output = _route(payload, client, web_route, cfg)
|
|
163
272
|
reason = (output.get("hookSpecificOutput") or {}).get("permissionDecisionReason")
|
|
273
|
+
if reason and not agent_has_smart_tool(payload.get("agent_type") if payload.get("agent_id") else None,
|
|
274
|
+
payload.get("cwd")):
|
|
275
|
+
return {}
|
|
164
276
|
return advise(reason) if mode == "advise" and reason else output
|
|
165
277
|
|
|
166
278
|
|
|
@@ -178,8 +290,9 @@ def _route(payload, client, web_route, cfg):
|
|
|
178
290
|
present = False
|
|
179
291
|
if not present:
|
|
180
292
|
return {}
|
|
181
|
-
|
|
182
|
-
|
|
293
|
+
url = str(tool_input.get("url") or "")
|
|
294
|
+
decision = web_route(tool_name, url)
|
|
295
|
+
return deny(web_message(client, tool_name, url, clean_reason(decision.get("reason")))) if decision.get("redirect") else {}
|
|
183
296
|
router.CLIENT.set(client)
|
|
184
297
|
try:
|
|
185
298
|
decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
|
|
@@ -190,5 +303,5 @@ def _route(payload, client, web_route, cfg):
|
|
|
190
303
|
pass
|
|
191
304
|
return {"systemMessage": f"Smart Tool unavailable, routing skipped: {type(exc).__name__}"}
|
|
192
305
|
if decision == "redirect":
|
|
193
|
-
return deny(
|
|
306
|
+
return deny(redirect_message(client, tool_name, tool_input, reason, cwd))
|
|
194
307
|
return {}
|
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.6",
|
|
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/router.py
CHANGED
|
@@ -74,6 +74,7 @@ _HEAVY_DIR_NAMES = frozenset({
|
|
|
74
74
|
# dos de cima superestimaria o custo e enviesaria a decisão pra `redirect`.
|
|
75
75
|
_IGNORED_BY_DEFAULT_DIRS = frozenset({".git", "__pycache__", ".mypy_cache", ".pytest_cache"})
|
|
76
76
|
_PATH_FIELDS = ("path", "file_path", "notebook_path")
|
|
77
|
+
_RECURSIVE_RE = re.compile(r"\b(?:grep|egrep|fgrep)\s+(?:-\w+\s+)*-\w*[rR]|\b(?:rg|find|fd|tree)\s|\bls\s+-\w*R|-Recurse\b")
|
|
77
78
|
|
|
78
79
|
|
|
79
80
|
def _resolve_path(raw, cwd):
|
|
@@ -107,6 +108,9 @@ def _command_paths(command, cwd):
|
|
|
107
108
|
previous, base = "", cwd
|
|
108
109
|
for token in re.findall(r'"([^"]+)"|\'([^\']+)\'|(\S+)', command or ""):
|
|
109
110
|
raw = next((t for t in token if t), "")
|
|
111
|
+
if token[2]:
|
|
112
|
+
# `cd dir; grep x file`: the separator sticks to the unquoted word before it.
|
|
113
|
+
raw = raw.rstrip(";")
|
|
110
114
|
after_cd, previous = previous.lower() in ("cd", "pushd", "set-location", "sl"), raw
|
|
111
115
|
if after_cd:
|
|
112
116
|
# The directory of a `cd` is where the command runs (base for what follows), not what it searches.
|
|
@@ -167,6 +171,9 @@ def _target_facts(tool_name, tool_input, cwd):
|
|
|
167
171
|
raw = _target_path(tool_input, cwd)
|
|
168
172
|
if raw is None and tool_name == "Bash":
|
|
169
173
|
candidates = _command_paths(tool_input.get("command"), cwd)
|
|
174
|
+
if candidates and _RECURSIVE_RE.search(tool_input.get("command") or ""):
|
|
175
|
+
# `sed -n 1,9p a.py; grep -rn x src`: the recursive part is the cost, not the file read before it.
|
|
176
|
+
candidates = [c for c in candidates if os.path.isdir(c)] or candidates
|
|
170
177
|
if candidates:
|
|
171
178
|
raw = candidates[0]
|
|
172
179
|
scope_note = None
|
|
@@ -308,6 +315,22 @@ _READ_PRODUCER_RE = re.compile(
|
|
|
308
315
|
re.IGNORECASE,
|
|
309
316
|
)
|
|
310
317
|
_READ_TOOLS = frozenset({"Read", "NotebookRead"})
|
|
318
|
+
# Commands that change files, the repository or dependencies are never redirected (smart_search cannot do the change).
|
|
319
|
+
# Verbs are matched outside quoted strings, so a grep pattern like "pip install" is not one; file writes inside an
|
|
320
|
+
# inline script (node -e, python -c) are matched anywhere.
|
|
321
|
+
_QUOTED_RE = re.compile(r"""'[^']*'|"(?:\\.|[^"\\])*\"""")
|
|
322
|
+
_MUTATING_VERB_RE = re.compile(
|
|
323
|
+
r"(?:^|[;&|(]|\s)(?:rm|mv|cp|mkdir|rmdir|touch|chmod|tee)\s"
|
|
324
|
+
r"|\bgit\s+(?:-C\s+\S+\s+)?(?:checkout|commit|add|push|pull|reset|stash|restore|rm|mv|merge|rebase|apply|tag|clean|"
|
|
325
|
+
r"clone|switch|cherry-pick)\b|\bsed\s+-i"
|
|
326
|
+
r"|\b(?:npm|pnpm|yarn|pip|uv)\s+(?:install|i|add|run|publish|ci|test)\b"
|
|
327
|
+
)
|
|
328
|
+
_FILE_WRITE_RE = re.compile(r"writeFileSync|writeFile\(|write_text\(|Set-Content|Out-File")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _mutates(command):
|
|
332
|
+
"""Whether a shell command changes files, the repository or dependencies."""
|
|
333
|
+
return bool(_FILE_WRITE_RE.search(command) or _MUTATING_VERB_RE.search(_QUOTED_RE.sub("''", command)))
|
|
311
334
|
|
|
312
335
|
BREAKER_PATH = os.path.join(os.path.dirname(METRICS_PATH), "router-breaker.json")
|
|
313
336
|
BREAKER_FAILURES = 3
|
|
@@ -324,7 +347,9 @@ def _mechanical_decision(tool_name, tool_input):
|
|
|
324
347
|
`offset`/`limit` explícitos, onde só as linhas pedidas servem). Remedido em 2026-10-08
|
|
325
348
|
sobre 2488 Bash que foram ao modelo (mediana 1,5 s, 7,8% redirecionados): ignorar os
|
|
326
349
|
filtros depois de `|` (salvo quando o produtor lê código ou páginas) decide 439 deles
|
|
327
|
-
sem modelo (13,3 min em 58 h) e perde 1 dos 155 redirecionamentos.
|
|
350
|
+
sem modelo (13,3 min em 58 h) e perde 1 dos 155 redirecionamentos. Remedido em 2026-10-09 sobre 3294 Bash decididos
|
|
351
|
+
pelo modelo: comandos que alteram arquivos, o repositório ou dependências foram 12 dos 283 redirecionamentos (todos
|
|
352
|
+
errados: o agente contornava o bloqueio) e 566 liberações."""
|
|
328
353
|
if tool_name in _READ_TOOLS:
|
|
329
354
|
return "allow", "Reading a specific file is never replaced by semantic search."
|
|
330
355
|
if tool_name == "Bash":
|
|
@@ -333,6 +358,8 @@ def _mechanical_decision(tool_name, tool_input):
|
|
|
333
358
|
return "allow", "Bash without a readable command."
|
|
334
359
|
if not _SEARCH_VERB_RE.search(command) and not _READ_PRODUCER_RE.search(command):
|
|
335
360
|
return "allow", "The command neither searches nor reads source code."
|
|
361
|
+
if _mutates(command):
|
|
362
|
+
return "allow", "The command changes files, the repository or dependencies."
|
|
336
363
|
return None
|
|
337
364
|
|
|
338
365
|
|
package/smart_tool_daemon.py
CHANGED
|
@@ -300,6 +300,20 @@ def _smart_search_job_payload(job_id):
|
|
|
300
300
|
payload['view'] = job['view']
|
|
301
301
|
return payload
|
|
302
302
|
|
|
303
|
+
# Claude Code defers MCP tools behind tool search; the tools the hook redirects to (and their job polling) load
|
|
304
|
+
# upfront, so a blocked agent already has the replacement in its tool list.
|
|
305
|
+
ALWAYS_LOAD = {"anthropic/alwaysLoad": True}
|
|
306
|
+
SERVER_INSTRUCTIONS = (
|
|
307
|
+
"Smart Tool serves an indexed semantic search over the user's projects and a cheaper web reader. The user installed "
|
|
308
|
+
"a PreToolUse hook that blocks broad raw searches (recursive grep/find/ls, wide Glob/Grep) and native "
|
|
309
|
+
"WebFetch/WebSearch. A call blocked by Smart Tool is a routing rule, not a failure: make the call the block "
|
|
310
|
+
"message names (smart_search with project_root and query_identifiers, web_fetch with url and prompt, web_search "
|
|
311
|
+
"with query). Never retry the same search or download through Bash, python, node, PowerShell, curl or wget: that "
|
|
312
|
+
"bypasses the rule the user set. Reading one known file, or searching inside one known file, is never blocked. "
|
|
313
|
+
"Before changing a function, project_manage action=graph with symbol lists its callers and tests; after editing, "
|
|
314
|
+
"project_manage action=affected_tests lists the tests to run."
|
|
315
|
+
)
|
|
316
|
+
|
|
303
317
|
TOOLS = [
|
|
304
318
|
{
|
|
305
319
|
"name": "project_manage",
|
|
@@ -339,6 +353,7 @@ TOOLS = [
|
|
|
339
353
|
},
|
|
340
354
|
{
|
|
341
355
|
"name": "smart_search",
|
|
356
|
+
"_meta": ALWAYS_LOAD,
|
|
342
357
|
"description": (
|
|
343
358
|
"Semantic search (embed+rerank) over the project's index. Use instead of "
|
|
344
359
|
"raw grep/read when the search has no clear scope (e.g. a folder with node_modules). "
|
|
@@ -372,6 +387,7 @@ TOOLS = [
|
|
|
372
387
|
},
|
|
373
388
|
{
|
|
374
389
|
"name": "smart_search_result",
|
|
390
|
+
"_meta": ALWAYS_LOAD,
|
|
375
391
|
"description": (
|
|
376
392
|
"Checks the progress and result of a background smart_search. "
|
|
377
393
|
"Pass the job_id returned by smart_search; while pending, wait retry_after_s and check again."
|
|
@@ -386,6 +402,7 @@ TOOLS = [
|
|
|
386
402
|
},
|
|
387
403
|
{
|
|
388
404
|
"name": "web_search",
|
|
405
|
+
"_meta": ALWAYS_LOAD,
|
|
389
406
|
"description": (
|
|
390
407
|
"Web search with curated results (title/url/snippet) - use instead of "
|
|
391
408
|
"the native WebSearch: several sources in parallel, the query in two languages and a cache shared across sessions. Starts with free HTTP "
|
|
@@ -433,6 +450,7 @@ TOOLS = [
|
|
|
433
450
|
},
|
|
434
451
|
{
|
|
435
452
|
"name": "web_fetch",
|
|
453
|
+
"_meta": ALWAYS_LOAD,
|
|
436
454
|
"description": (
|
|
437
455
|
"Reads a known URL and returns only the answer to the prompt, written by the gateway's mini model - use instead of "
|
|
438
456
|
"the native WebFetch. The page is cached for 24 h: another prompt on the same URL does not download it again. "
|
|
@@ -449,6 +467,7 @@ TOOLS = [
|
|
|
449
467
|
},
|
|
450
468
|
{
|
|
451
469
|
"name": "web_search_result",
|
|
470
|
+
"_meta": ALWAYS_LOAD,
|
|
452
471
|
"description": (
|
|
453
472
|
"Checks the status/result of a deep research job "
|
|
454
473
|
"(web_search with depth='deep' or depth='research')."
|
|
@@ -968,8 +987,10 @@ def _perform_smart_search_isolated(arguments, job_id):
|
|
|
968
987
|
else ({}, None))
|
|
969
988
|
except Exception as exc:
|
|
970
989
|
symbol_index, symbol_note = None, f"Function analysis failed: {_project_error(exc)}"
|
|
990
|
+
notes = []
|
|
971
991
|
if symbol_index is None and any(group != "doc" for group in groups):
|
|
972
|
-
warnings
|
|
992
|
+
(warnings if symbol_note else notes).append(
|
|
993
|
+
symbol_note or "Per-chunk functions are being prepared; they will show on the next search.")
|
|
973
994
|
elif symbol_note or (symbol_index or {}).get("note"):
|
|
974
995
|
warnings.append(symbol_note or symbol_index["note"])
|
|
975
996
|
query_terms = _symbol_terms(" ".join(queries[group] for group in groups if group != "doc"))
|
|
@@ -1013,7 +1034,7 @@ def _perform_smart_search_isolated(arguments, job_id):
|
|
|
1013
1034
|
warning = " ".join(warnings)
|
|
1014
1035
|
if warning:
|
|
1015
1036
|
_update_job(job_id, warning=warning)
|
|
1016
|
-
return _grouped_yaml(index_profile.public(profile), blocks,
|
|
1037
|
+
return _grouped_yaml(index_profile.public(profile), blocks, " ".join(warnings + notes))
|
|
1017
1038
|
|
|
1018
1039
|
|
|
1019
1040
|
def _perform_project_index(arguments, job_id, preview=False):
|
|
@@ -1717,11 +1738,31 @@ def _project_action_locked(arguments):
|
|
|
1717
1738
|
return {"project": _project_payload(project_store.get(key))}
|
|
1718
1739
|
|
|
1719
1740
|
|
|
1741
|
+
def _for_agent(result):
|
|
1742
|
+
"""register/status for an agent: the project without the full result of every past job, the folder structure of
|
|
1743
|
+
the scope and the profile groups (one project returned 128 thousand characters, more than an MCP client shows);
|
|
1744
|
+
scope_summary keeps include/exclude. The projects screen keeps the full payload."""
|
|
1745
|
+
project = result.get("project") if isinstance(result, dict) else None
|
|
1746
|
+
if isinstance(project, dict) and isinstance(project.get("jobs"), list):
|
|
1747
|
+
project["jobs"] = [{key: job.get(key) for key in ("job_id", "status", "kind", "phase", "updated_at", "warning",
|
|
1748
|
+
"error") if job.get(key) not in (None, "")}
|
|
1749
|
+
for job in project["jobs"][:5]]
|
|
1750
|
+
if isinstance(project, dict):
|
|
1751
|
+
if isinstance(project.get("preview"), dict):
|
|
1752
|
+
project["preview"] = {key: project["preview"].get(key)
|
|
1753
|
+
for key in ("included_files", "eligible_files", "bytes", "skipped")}
|
|
1754
|
+
project.pop("scope", None)
|
|
1755
|
+
return result
|
|
1756
|
+
|
|
1757
|
+
|
|
1720
1758
|
def _handle_project_manage(arguments):
|
|
1721
1759
|
# MCP não abre janelas; o seletor nativo pertence apenas à interface local.
|
|
1722
1760
|
if isinstance(arguments, dict) and arguments.get("action") == "pick":
|
|
1723
1761
|
raise ValueError("Use register with project_root or pick the folder from the tray.")
|
|
1724
|
-
|
|
1762
|
+
result = _project_action(arguments)
|
|
1763
|
+
if isinstance(arguments, dict) and arguments.get("action") in ("register", "status"):
|
|
1764
|
+
result = _for_agent(result)
|
|
1765
|
+
return _to_json_sanitized(result)
|
|
1725
1766
|
|
|
1726
1767
|
|
|
1727
1768
|
def _enqueue_monitored_project(project_id, **options):
|
|
@@ -3221,10 +3262,9 @@ def _web_route(tool, url):
|
|
|
3221
3262
|
if problem:
|
|
3222
3263
|
return {"redirect": False, "reason": f"gateway unavailable: {problem}"}
|
|
3223
3264
|
if tool == "WebFetch":
|
|
3224
|
-
return {"redirect": True, "reason": "
|
|
3225
|
-
"
|
|
3226
|
-
return {"redirect": True, "reason": "
|
|
3227
|
-
"two languages and a cache shared across sessions."}
|
|
3265
|
+
return {"redirect": True, "reason": "the mini model reads the page, the answer comes back short and the page is "
|
|
3266
|
+
"cached for 24 h."}
|
|
3267
|
+
return {"redirect": True, "reason": "several sources, two languages and a cache shared across sessions."}
|
|
3228
3268
|
|
|
3229
3269
|
|
|
3230
3270
|
HOOK_MAX_BODY_BYTES = 4 * 1024 * 1024
|
|
@@ -3308,6 +3348,7 @@ def _dispatch(method, params, session_id):
|
|
|
3308
3348
|
"protocolVersion": PROTOCOL_VERSION,
|
|
3309
3349
|
"capabilities": {"tools": {}},
|
|
3310
3350
|
"serverInfo": {"name": "smart-tool", "version": version.VERSION},
|
|
3351
|
+
"instructions": SERVER_INSTRUCTIONS,
|
|
3311
3352
|
}
|
|
3312
3353
|
if method == "tools/list":
|
|
3313
3354
|
return {"tools": TOOLS}
|
package/tray.py
CHANGED
|
@@ -20,8 +20,10 @@ import pystray
|
|
|
20
20
|
|
|
21
21
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
22
|
import autostart
|
|
23
|
+
import config
|
|
23
24
|
import daemon_launcher
|
|
24
25
|
import update_check
|
|
26
|
+
import version
|
|
25
27
|
|
|
26
28
|
POLL_INTERVAL_S = 45
|
|
27
29
|
UPDATE_CHECK_INTERVAL_S = 24 * 3600
|
|
@@ -110,6 +112,9 @@ def _poll_loop(icon):
|
|
|
110
112
|
|
|
111
113
|
|
|
112
114
|
def _update_loop(icon):
|
|
115
|
+
"""Checks npm at start and every 24 h. At start (logon, before agent sessions use the daemon) a newer version is
|
|
116
|
+
installed right away when auto_update is on; later checks only notify and offer it in the menu."""
|
|
117
|
+
first = True
|
|
113
118
|
while True:
|
|
114
119
|
try:
|
|
115
120
|
latest = update_check.newer_version()
|
|
@@ -118,19 +123,56 @@ def _update_loop(icon):
|
|
|
118
123
|
if latest and latest != _state["update"]:
|
|
119
124
|
_state["update"] = latest
|
|
120
125
|
icon.update_menu()
|
|
121
|
-
|
|
126
|
+
if first and config.auto_update() and update_check.should_auto_update(latest):
|
|
127
|
+
icon.notify(f"Updating Smart Tool to {latest}...", "Smart Tool")
|
|
128
|
+
update_check.run_update(latest, automatic=True)
|
|
129
|
+
else:
|
|
130
|
+
icon.notify(f"Smart Tool {latest} is available: use \"Update to {latest}\" in this menu.", "Smart Tool")
|
|
131
|
+
first = False
|
|
122
132
|
time.sleep(UPDATE_CHECK_INTERVAL_S)
|
|
123
133
|
|
|
124
134
|
|
|
125
135
|
def _update_label(item):
|
|
126
|
-
return f"Update to {_state['update']}
|
|
136
|
+
return f"Update to {_state['update']}" if _state["update"] else "Check for updates"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_update_busy = threading.Lock()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _update_now(icon, item):
|
|
143
|
+
"""Menu action: installs the known newer version, or checks npm first and says when there is none."""
|
|
144
|
+
if not _update_busy.acquire(blocking=False):
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
def run():
|
|
148
|
+
try:
|
|
149
|
+
latest = _state["update"] or update_check.newer_version()
|
|
150
|
+
if not latest:
|
|
151
|
+
icon.notify(f"Smart Tool {version.VERSION} is the latest version.", "Smart Tool")
|
|
152
|
+
return
|
|
153
|
+
_state["update"] = latest
|
|
154
|
+
icon.update_menu()
|
|
155
|
+
update_check.run_update(latest, automatic=False)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
icon.notify(f"Could not update: {exc}", "Smart Tool")
|
|
158
|
+
finally:
|
|
159
|
+
_update_busy.release()
|
|
160
|
+
threading.Thread(target=run, daemon=True).start()
|
|
161
|
+
|
|
127
162
|
|
|
163
|
+
def _auto_update_checked(item):
|
|
164
|
+
try:
|
|
165
|
+
return config.auto_update()
|
|
166
|
+
except Exception:
|
|
167
|
+
return False
|
|
128
168
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
169
|
+
|
|
170
|
+
def _toggle_auto_update(icon, item):
|
|
171
|
+
try:
|
|
172
|
+
config.update_config(lambda cfg: cfg.__setitem__("auto_update", not config.auto_update(cfg)))
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
icon.notify(f"Could not change automatic updates: {exc}", "Smart Tool")
|
|
175
|
+
icon.update_menu()
|
|
134
176
|
|
|
135
177
|
|
|
136
178
|
def _open_setup(icon, item):
|
|
@@ -227,7 +269,8 @@ def main():
|
|
|
227
269
|
pystray.MenuItem("Index a folder…", _add_project),
|
|
228
270
|
pystray.MenuItem("Open settings", _open_setup),
|
|
229
271
|
pystray.MenuItem("Start with Windows", _toggle_autostart, checked=_autostart_checked),
|
|
230
|
-
pystray.MenuItem(_update_label,
|
|
272
|
+
pystray.MenuItem(_update_label, _update_now),
|
|
273
|
+
pystray.MenuItem("Update automatically", _toggle_auto_update, checked=_auto_update_checked),
|
|
231
274
|
pystray.MenuItem("Quit", _quit),
|
|
232
275
|
),
|
|
233
276
|
)
|
package/update_check.py
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
"""Newer Smart Tool release on npm
|
|
1
|
+
"""Newer Smart Tool release on npm and the update itself: the tray runs the same install command a user would, in a
|
|
2
|
+
visible console, at startup (once per version, when auto_update is on) or on demand from its menu."""
|
|
2
3
|
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
3
7
|
import urllib.request
|
|
4
8
|
|
|
9
|
+
import atomic_io
|
|
10
|
+
import paths
|
|
5
11
|
import version
|
|
6
12
|
|
|
7
13
|
PACKAGE = "@allansantos-dev/smart-tool"
|
|
8
14
|
REGISTRY_URL = "https://registry.npmjs.org/@allansantos-dev%2Fsmart-tool/latest"
|
|
9
|
-
UPDATE_COMMAND = f"npx {PACKAGE}@latest install"
|
|
15
|
+
UPDATE_COMMAND = f"npx --yes {PACKAGE}@latest install"
|
|
16
|
+
STATE_PATH = os.path.join(paths.DATA_DIR, "update-state.json")
|
|
10
17
|
|
|
11
18
|
|
|
12
19
|
def _parts(value):
|
|
@@ -20,3 +27,30 @@ def newer_version(timeout=10):
|
|
|
20
27
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
21
28
|
latest = json.load(response)["version"]
|
|
22
29
|
return latest if _parts(latest) > _parts(version.VERSION) else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def attempted():
|
|
33
|
+
"""Version the last automatic update went for, or None."""
|
|
34
|
+
try:
|
|
35
|
+
with open(STATE_PATH, encoding="utf-8") as stream:
|
|
36
|
+
return json.load(stream).get("attempted")
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def should_auto_update(latest):
|
|
42
|
+
"""An automatic update runs once per version: when it fails, the installer restores the previous version and
|
|
43
|
+
restarts the tray, which would otherwise try the same version again on every start."""
|
|
44
|
+
return bool(latest) and attempted() != latest
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_update(latest, automatic):
|
|
48
|
+
"""Starts the install command in its own visible console, detached from the tray (the installer stops and restarts
|
|
49
|
+
the tray). The console closes on success and stays open with the error on failure."""
|
|
50
|
+
if automatic:
|
|
51
|
+
os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
|
|
52
|
+
atomic_io.write_secret_text(STATE_PATH, json.dumps({"attempted": latest, "at": time.time()}))
|
|
53
|
+
flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
54
|
+
command = f'title Smart Tool update to {latest} && {UPDATE_COMMAND} || pause'
|
|
55
|
+
subprocess.Popen(["cmd.exe", "/c", command], creationflags=flags, close_fds=True,
|
|
56
|
+
cwd=os.path.expanduser("~"))
|
package/version.py
CHANGED