@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 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
- Run the same command to update; `~/.smart-tool` is kept. It downloads [uv](https://docs.astral.sh/uv/) (checksum
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
- """Production functions of the files changed in the working tree, read from disk, so a copy of a function written
204
- earlier in the same session is caught before the index catches up."""
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
- if status == "deleted" or ext not in _LANG or index_profile.kind(rel, profile) != "code":
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
- for function in doc_check.functions(rel, source):
219
- if function["end"] - function["start"] + 1 < MIN_LINES:
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
- found.append({**entry, "kind": "code"})
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
- decision = web_route(tool_name, str(tool_input.get("url") or ""))
182
- return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
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(f"Use smart_search instead of raw {tool_name}: {clean_reason(reason)}")
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.5",
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
 
@@ -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": "Use the web_fetch tool from the smart-tool MCP (url + prompt): the mini model reads "
3247
- "the page, the answer comes back short and the page is cached for 24 h."}
3248
- return {"redirect": True, "reason": "Use the web_search tool from the smart-tool MCP (query + query_en): several sources, "
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
- icon.notify(f"Smart Tool {latest} is available. Update with: {update_check.UPDATE_COMMAND}", "Smart Tool")
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']} (copy command)"
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
- def _copy_update_command(icon, item):
130
- import subprocess
131
- subprocess.run(["clip.exe"], input=update_check.UPDATE_COMMAND.encode("ascii"), check=True,
132
- creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
133
- icon.notify(f"Copied: {update_check.UPDATE_COMMAND}. Paste it in a terminal.", "Smart Tool")
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, _copy_update_command, visible=lambda item: bool(_state["update"])),
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, for the tray notice. Updating is running the install command again."""
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
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.5"
4
+ VERSION = "0.9.6"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7