@allansantos-dev/smart-tool 0.9.5 → 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 +39 -0
- package/README.md +3 -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 +23 -4
- package/tray.py +51 -8
- package/update_check.py +36 -2
- package/version.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,44 @@
|
|
|
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
|
+
|
|
3
42
|
## 0.9.5 - beta
|
|
4
43
|
|
|
5
44
|
Reported by the claude-code-boss session on its first day using 0.9.4.
|
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/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')."
|
|
@@ -3243,10 +3262,9 @@ def _web_route(tool, url):
|
|
|
3243
3262
|
if problem:
|
|
3244
3263
|
return {"redirect": False, "reason": f"gateway unavailable: {problem}"}
|
|
3245
3264
|
if tool == "WebFetch":
|
|
3246
|
-
return {"redirect": True, "reason": "
|
|
3247
|
-
"
|
|
3248
|
-
return {"redirect": True, "reason": "
|
|
3249
|
-
"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."}
|
|
3250
3268
|
|
|
3251
3269
|
|
|
3252
3270
|
HOOK_MAX_BODY_BYTES = 4 * 1024 * 1024
|
|
@@ -3330,6 +3348,7 @@ def _dispatch(method, params, session_id):
|
|
|
3330
3348
|
"protocolVersion": PROTOCOL_VERSION,
|
|
3331
3349
|
"capabilities": {"tools": {}},
|
|
3332
3350
|
"serverInfo": {"name": "smart-tool", "version": version.VERSION},
|
|
3351
|
+
"instructions": SERVER_INSTRUCTIONS,
|
|
3333
3352
|
}
|
|
3334
3353
|
if method == "tools/list":
|
|
3335
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