@allansantos-dev/smart-tool 0.9.2 → 0.9.4

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/hook_decision.py CHANGED
@@ -10,11 +10,22 @@ no Claude Code, e nenhuma decisão aqui é sobre periculosidade, só sobre custo
10
10
  import json
11
11
  import os
12
12
 
13
+ import code_graph
14
+ import code_impact
13
15
  import config
16
+ import doc_check
17
+ import duplicates
18
+ import edit_preview
19
+ import index_profile
20
+ import index_scope
14
21
  import endpoint_sync
22
+ import project_store
15
23
  import router
16
24
 
17
25
  WEB_TOOLS = ("WebSearch", "WebFetch")
26
+ EDIT_TOOLS = ("Edit", "MultiEdit", "Write", "apply_patch")
27
+ MAX_DOC_FINDINGS = 5
28
+ MAX_DUPLICATE_FINDINGS = 3
18
29
  SMART_TOOL_SERVER = "smart-tool"
19
30
  REASON_MAX_CHARS = 200
20
31
 
@@ -55,9 +66,96 @@ def advise(reason):
55
66
  "additionalContext": f"Smart Tool tip (the call was not blocked): {reason}"}}
56
67
 
57
68
 
69
+ def _project_of(path):
70
+ target = os.path.normcase(os.path.abspath(path))
71
+ roots = [p["root"] for p in project_store.all_projects()
72
+ if target.startswith(os.path.normcase(os.path.abspath(p["root"])) + os.sep)]
73
+ return max(roots, key=len) if roots else None
74
+
75
+
76
+ def _impact_line(root, rel, name):
77
+ """Callers and tests from the code graph, only when its analysis is already cached: the hook never waits for one."""
78
+ if not root or code_graph.cached_symbols(root)[0] is None:
79
+ return ""
80
+ try:
81
+ found = code_impact.impact(root, f"{rel}::{name}", limit=3)
82
+ except ValueError:
83
+ return ""
84
+ counts = found["counts"]
85
+ tests = ", ".join(f"{t['function']} ({t['path']})" for t in found["tests"])
86
+ calls = f"; tests calling it: {tests}" if tests else ""
87
+ return f" {counts['callers']} caller(s){calls}; project_manage affected_tests lists every test to run after the edit."
88
+
89
+
90
+ def _doc_lines(findings):
91
+ lines = []
92
+ for root, rel, item in findings[:MAX_DOC_FINDINGS]:
93
+ if item["issue"] == "missing":
94
+ text = f"{item['name']} ({rel}:{item['start']}) has no docstring: write one (purpose, parameters, return)."
95
+ else:
96
+ text = (f"{item['name']} ({rel}:{item['start']}) changed its signature: update its docstring if the "
97
+ f"contract changed.")
98
+ lines.append(text + _impact_line(root, rel, item["name"]))
99
+ if len(findings) > MAX_DOC_FINDINGS:
100
+ lines.append(f"{len(findings) - MAX_DOC_FINDINGS} more function(s) in the same situation.")
101
+ return "Smart Tool docs: " + " ".join(lines)
102
+
103
+
104
+ def _duplicate_lines(findings):
105
+ lines = []
106
+ for rel, item in findings[:MAX_DUPLICATE_FINDINGS]:
107
+ existing = item["existing"]
108
+ where = f"{existing['name']} in {existing['path']}:{existing['lines']}"
109
+ if item["type"] == "exact":
110
+ lines.append(f"{item['name']} ({rel}:{item['lines']}) has the same body as {where}: reuse it instead of a copy.")
111
+ else:
112
+ lines.append(f"{item['name']} ({rel}:{item['lines']}) is near-identical to {where} (score {item['score']}, "
113
+ f"local names ignored): check whether it can be reused.")
114
+ if len(findings) > MAX_DUPLICATE_FINDINGS:
115
+ lines.append(f"{len(findings) - MAX_DUPLICATE_FINDINGS} more copied function(s).")
116
+ return ("Smart Tool duplicates: " + " ".join(lines)
117
+ + " If the copy is intentional (moving the function, a deliberate fork), go ahead.")
118
+
119
+
120
+ def _edit_review(payload, doc_mode, duplicate_mode):
121
+ """Checks of an edit before it runs. Docstrings follow doc_mode, in code files of registered projects only (tests,
122
+ docs and files outside a project are left out, as in the docs coverage): missing ones deny the edit in require mode and become a reminder in remind mode; a
123
+ documented function whose signature changed always gets a reminder. Copies of indexed functions follow
124
+ duplicate_mode and only add context, never deny."""
125
+ doc_findings, duplicate_findings, failures = [], [], []
126
+ for path, before, after in edit_preview.preview(payload.get("tool_name"), payload.get("tool_input"), payload.get("cwd")):
127
+ if not doc_check.supported(path):
128
+ continue
129
+ root = _project_of(path)
130
+ rel = os.path.relpath(path, root).replace(os.sep, "/") if root else os.path.basename(path)
131
+ edited = doc_check.written(path, before, after)
132
+ profile = index_profile.current((index_scope.load_scope(root) or {}).get("profile")) if root else None
133
+ if doc_mode != "off" and root and index_profile.kind(rel, profile) == "code":
134
+ doc_findings += [(root, rel, item) for item in doc_check.review(path, before, after, edited)]
135
+ if duplicate_mode != "off" and root and edited["touched"]:
136
+ try:
137
+ duplicate_findings += [(rel, item) for item in duplicates.on_write(root, path, before, after, edited)]
138
+ except (ValueError, RuntimeError) as exc:
139
+ failures.append(f"Smart Tool duplicates check skipped: {exc}")
140
+ parts = ([_doc_lines(doc_findings)] if doc_findings else []) + \
141
+ ([_duplicate_lines(duplicate_findings)] if duplicate_findings else []) + failures
142
+ if not parts:
143
+ return {}
144
+ message = " ".join(parts)
145
+ if doc_mode == "require" and any(item["issue"] == "missing" for _root, _rel, item in doc_findings):
146
+ return deny(message + " Redo the edit with the docstring.")
147
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": message}}
148
+
149
+
58
150
  def decide(payload, client, web_route):
59
- """Hook output for one PreToolUse payload, following config hook_mode (redirect | advise | off)."""
151
+ """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)."""
60
153
  cfg = config.load_config()
154
+ if isinstance(payload, dict) and payload.get("tool_name") in EDIT_TOOLS:
155
+ doc_mode, duplicate_mode = config.doc_mode(cfg), config.duplicate_mode(cfg)
156
+ if doc_mode == "off" and duplicate_mode == "off":
157
+ return {}
158
+ return _edit_review(payload, doc_mode, duplicate_mode)
61
159
  mode = config.hook_mode(cfg)
62
160
  if mode == "off" or not isinstance(payload, dict):
63
161
  return {}
@@ -82,7 +180,7 @@ def _route(payload, client, web_route, cfg):
82
180
  return {}
83
181
  decision = web_route(tool_name, str(tool_input.get("url") or ""))
84
182
  return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
85
- router.CLIENT = client
183
+ router.CLIENT.set(client)
86
184
  try:
87
185
  decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
88
186
  except Exception as exc:
package/index_profile.py CHANGED
@@ -17,7 +17,7 @@ DOC_DATA = {"html", "htm", "json", "csv", "xml", "yaml", "yml", "svg"}
17
17
  _NAME_DOC = re.compile(r"(^|/)(readme|changelog|license|licence|contributing|install|history|authors)(\.[^/]*)?$", re.I)
18
18
  _DIR_DOC = re.compile(r"(^|/)(docs?|adrs?|documentation)/", re.I)
19
19
  _TEST = re.compile(r"(^|/)(tests?|__tests__|spec|specs|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)test_[^/]+\.py$|"
20
- r"(^|/)conftest\.py$|_test\.(py|go)$|\.(test|spec)\.[cm]?[jt]sx?$|(^|/)[^/]*[a-z0-9](?-i:Tests?)\.java$|"
20
+ r"(^|/)conftest\.py$|_test\.(py|go)$|\.(test|spec)(-d)?\.[cm]?[jt]sx?$|(^|/)[^/]*[a-z0-9](?-i:Tests?)\.java$|"
21
21
  r"(^|/)src/test/|(^|/)test[-_][^/]*\.[cm]?[jt]sx?$|[-_]test\.[cm]?[jt]sx?$", re.I)
22
22
  _TEST_PROSE = re.compile(r"(^|/)(tests?|__tests__|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)src/test/", re.I)
23
23
  _TEXT_LINE = re.compile(r"[A-Za-zÀ-ú]{3,}\s+[A-Za-zÀ-ú]{3,}")
package/index_scope.py CHANGED
@@ -415,6 +415,22 @@ def _exclude_matcher(exclude):
415
415
  return matches
416
416
 
417
417
 
418
+ def in_scope(scope, rel_path):
419
+ """Whether resolve_included_files would list rel_path: under an include base, outside the exclusions and outside
420
+ hidden folders."""
421
+ rel_path = rel_path.replace("\\", "/")
422
+ include = scope.get("include") or [""]
423
+ matcher = _exclude_matcher(set(scope.get("exclude") or []) | set(scope.get("user_exclude") or []) |
424
+ _ALWAYS_EXCLUDE | _ALWAYS_EXCLUDE_FILES)
425
+ parts = rel_path.split("/")
426
+ if matcher(rel_path) or any(part.startswith(".") for part in parts[:-1]):
427
+ return False
428
+ if any(base in ("", ".") for base in include):
429
+ return True
430
+ return len(parts) == 1 or any(rel_path == base.strip("/") or rel_path.startswith(base.strip("/") + "/")
431
+ for base in include)
432
+
433
+
418
434
  def resolve_included_files(root, scope, cancel_check=None):
419
435
  if not valid_scope(scope):
420
436
  raise ValueError("Invalid scope; request a new project analysis.")
package/indexer.py CHANGED
@@ -841,6 +841,12 @@ def search(root, query_vector=None, top_k=20, query=None, model_id=None, include
841
841
  return [(scores[chunk_id], *by_id[chunk_id][1:5]) for chunk_id in ids[:top_k]]
842
842
 
843
843
 
844
+ def graph_cache_path(view_path):
845
+ """Where code_graph keeps the saved analysis of one index view (graph-cache/<view>.json.gz next to it)."""
846
+ return os.path.join(os.path.dirname(view_path), "graph-cache",
847
+ os.path.basename(view_path).removesuffix(".sqlite3") + ".json.gz")
848
+
849
+
844
850
  def remove_index(root):
845
851
  """Remove somente arquivos de índice da raiz conhecida, nunca arquivos do projeto."""
846
852
  import glob
@@ -855,6 +861,10 @@ def remove_index(root):
855
861
  except PermissionError as exc:
856
862
  raise RuntimeError("The index is open in another process. Close the external reader and try removing it again.") from exc
857
863
  removed.append(safe)
864
+ try:
865
+ os.remove(graph_cache_path(safe))
866
+ except FileNotFoundError:
867
+ pass
858
868
  invalidate_cache(root)
859
869
  return removed
860
870
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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
@@ -5,6 +5,7 @@ modelo pequeno se deve deixar passar ou redirecionar pra `smart_search`. Cada de
5
5
  logada em `router-metrics.jsonl` pra orientar um classificador mais fino depois, com
6
6
  base em uso real — não numa suposição a priori.
7
7
  """
8
+ import contextvars
8
9
  import json
9
10
  import os
10
11
  import paths
@@ -17,7 +18,8 @@ import index_scope
17
18
  import indexer
18
19
  import web_search_adapters
19
20
 
20
- CLIENT = None
21
+ # Per request: the daemon serves each hook call on its own thread, so a module global mixed up clients.
22
+ CLIENT = contextvars.ContextVar("router_client", default=None)
21
23
  METRICS_PATH = os.path.join(paths.DATA_DIR, "router-metrics.jsonl")
22
24
 
23
25
  _SYSTEM_PROMPT = (
@@ -266,7 +268,7 @@ def _log_decision(tool_name, tool_input, decision, reason, extra=None):
266
268
  "tool_input": _redacted_input(tool_input),
267
269
  "decision": decision,
268
270
  "reason": reason,
269
- **({"client": CLIENT} if CLIENT else {}),
271
+ **({"client": CLIENT.get()} if CLIENT.get() else {}),
270
272
  }
271
273
  record.update(extra or {})
272
274
  try:
@@ -290,18 +292,21 @@ def log_unavailable(tool_name, tool_input, exc):
290
292
  )
291
293
 
292
294
 
293
- # Verbo ancorado no início do comando ou de um segmento (`|`, `;`, `&&`, `$(`): casar por
294
- # substring solta faria qualquer palavra inglesa dentro de um heredoc Python contar como
295
- # busca. A ausência de verbo é prova mecânica de que o `command` não busca código.
295
+ # Search/read verbs count only where they start a command: at the beginning, after `;`, `&&`, `||`, `$(`, a backtick,
296
+ # a loop/if keyword or a shell invoker (`bash -c`, `powershell -Command`, `cmd /c`). After a single `|` they filter the
297
+ # output of the command before them (`python x.py | tail -5`), which reads no source code.
296
298
  _SEARCH_VERB_RE = re.compile(
297
- # Início, separador de segmento, ou logo depois de um invocador de shell
298
- # (`powershell -Command`, `bash -c`, `cmd /c`), que é como o verbo real costuma
299
- # aparecer no meio de um comando em máquina Windows.
300
- r"(?:^|[|;&]|\$\(|`|-c\s|-Command\s|/c\s|/k\s)\s*(?:sudo\s+|command\s+)?"
299
+ r"(?:^|[;&]|\|\||\$\(|`|-c\s|-Command\s|/c\s|/k\s)\s*[\"']?\s*(?:(?:do|then|else)\s+)?(?:sudo\s+|command\s+)?"
301
300
  r"(grep|egrep|fgrep|rg|ripgrep|find|findstr|fd|cat|type|head|tail|less|more|"
302
301
  r"ls|dir|awk|sed|ack|ag|select-string|sls|get-content|gc|get-childitem|gci)\b",
303
302
  re.IGNORECASE,
304
303
  )
304
+ # Commands that read code or pages without a search verb at their start: their pipe filters still search content.
305
+ _READ_PRODUCER_RE = re.compile(
306
+ r"\bgit\s+(grep|ls-files|show|log\s+-p|diff)\b|\b(curl|wget|iwr|invoke-webrequest)\b|\bgh\s+api\b|"
307
+ r"readFileSync|read_text\(|open\(",
308
+ re.IGNORECASE,
309
+ )
305
310
  _READ_TOOLS = frozenset({"Read", "NotebookRead"})
306
311
 
307
312
  BREAKER_PATH = os.path.join(os.path.dirname(METRICS_PATH), "router-breaker.json")
@@ -316,14 +321,17 @@ def _mechanical_decision(tool_name, tool_input):
316
321
  Medido em 1973 decisões reais: 97,8% eram `allow`, e 1512 delas (77%) caíam nestes
317
322
  casos — cada uma custando uma ida ao gateway antes de toda tool call do agente. Os
318
323
  dois únicos `Read` redirecionados nesse histórico eram decisões erradas (um deles com
319
- `offset`/`limit` explícitos, onde só as linhas pedidas servem)."""
324
+ `offset`/`limit` explícitos, onde só as linhas pedidas servem). Remedido em 2026-10-08
325
+ sobre 2488 Bash que foram ao modelo (mediana 1,5 s, 7,8% redirecionados): ignorar os
326
+ 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."""
320
328
  if tool_name in _READ_TOOLS:
321
329
  return "allow", "Reading a specific file is never replaced by semantic search."
322
330
  if tool_name == "Bash":
323
331
  command = tool_input.get("command")
324
332
  if not isinstance(command, str) or not command.strip():
325
333
  return "allow", "Bash without a readable command."
326
- if not _SEARCH_VERB_RE.search(command):
334
+ if not _SEARCH_VERB_RE.search(command) and not _READ_PRODUCER_RE.search(command):
327
335
  return "allow", "The command neither searches nor reads source code."
328
336
  return None
329
337
 
package/setup_ui.py CHANGED
@@ -177,18 +177,31 @@ def handle_integration_get():
177
177
  mode, mode_error = config.hook_mode(), ""
178
178
  except ValueError as exc:
179
179
  mode, mode_error = "", str(exc)
180
+ try:
181
+ doc_mode, doc_error = config.doc_mode(), ""
182
+ except ValueError as exc:
183
+ doc_mode, doc_error = "", str(exc)
184
+ try:
185
+ duplicate_mode, duplicate_error = config.duplicate_mode(), ""
186
+ except ValueError as exc:
187
+ duplicate_mode, duplicate_error = "", str(exc)
180
188
  return {"daemon_url": setup_origin(), "clients": clients, "hook_mode": mode, "hook_mode_error": mode_error,
181
- "hook_modes": list(config.HOOK_MODES)}
189
+ "hook_modes": list(config.HOOK_MODES), "doc_mode": doc_mode, "doc_mode_error": doc_error,
190
+ "doc_modes": list(config.DOC_MODES), "duplicate_mode": duplicate_mode,
191
+ "duplicate_mode_error": duplicate_error, "duplicate_modes": list(config.DUPLICATE_MODES)}
182
192
 
183
193
 
184
194
  def handle_integration_save(body):
185
195
  """`hook_mode` sets redirect/advise/off for every client; `register_mcp` runs the client's CLI; hook in two steps:
186
196
  `preview` shows the exact change, `install` writes it (with backup)."""
187
- if isinstance(body, dict) and body.get("action") == "hook_mode":
188
- if body.get("mode") not in config.HOOK_MODES:
189
- raise SetupError("Invalid mode: use redirect, advise or off.")
197
+ if isinstance(body, dict) and body.get("action") in ("hook_mode", "doc_mode", "duplicate_mode"):
198
+ action = body["action"]
199
+ allowed = {"hook_mode": config.HOOK_MODES, "doc_mode": config.DOC_MODES,
200
+ "duplicate_mode": config.DUPLICATE_MODES}[action]
201
+ if body.get("mode") not in allowed:
202
+ raise SetupError(f"Invalid mode: use {', '.join(allowed)}.")
190
203
  try:
191
- config.update_config(lambda cfg: cfg.update(hook_mode=body["mode"]))
204
+ config.update_config(lambda cfg: cfg.update({action: body["mode"]}))
192
205
  except (OSError, config.ConfigCorruptedError) as exc:
193
206
  raise SetupError(str(exc))
194
207
  return handle_integration_get()
@@ -565,6 +578,21 @@ _PAGE_TEMPLATE = """<!doctype html>
565
578
  </select>
566
579
  <span class="hint" id="hook-mode-hint">Applies to every client on the next tool call; nothing is reinstalled.</span></div>
567
580
  <div class="actions"><button type="button" id="btn-save-hook-mode">Save behavior</button></div>
581
+ <div class="field" style="margin-top:16px"><label for="sel-doc-mode">Documentation on edit</label>
582
+ <select id="sel-doc-mode">
583
+ <option value="remind">Remind: the edit runs; the agent is told which touched functions lack a docstring (default)</option>
584
+ <option value="require">Require: an edit that leaves a touched function without a docstring is blocked until it adds one</option>
585
+ <option value="off">Off: no documentation checks</option>
586
+ </select>
587
+ <span class="hint">Only public functions the edit touches (private, nested and override functions are exempt, as in pydocstyle, eslint-plugin-jsdoc and Checkstyle). A documented function is mentioned again only when its signature changes; body-only edits stay silent.</span></div>
588
+ <div class="actions"><button type="button" id="btn-save-doc-mode">Save documentation</button></div>
589
+ <div class="field" style="margin-top:16px"><label for="sel-duplicate-mode">Duplicate functions on edit</label>
590
+ <select id="sel-duplicate-mode">
591
+ <option value="warn">Warn: the edit runs; the agent is told when a written function copies one already in the project (default)</option>
592
+ <option value="off">Off: no duplicate checks on edit</option>
593
+ </select>
594
+ <span class="hint">Compares functions the edit writes with the indexed code: identical bodies, and near-identical ones with only local names changed. Never blocks.</span></div>
595
+ <div class="actions"><button type="button" id="btn-save-duplicate-mode">Save duplicates</button></div>
568
596
  <div class="msg" id="agents-msg" role="status"></div>
569
597
  <div id="agents-list"></div>
570
598
  </section>
@@ -849,7 +877,11 @@ async function loadAgents() {
849
877
  const data = await resp.json();
850
878
  if (!resp.ok || data.error) throw new Error(data.error);
851
879
  if (document.activeElement !== $("sel-hook-mode")) $("sel-hook-mode").value = data.hook_mode;
880
+ if (document.activeElement !== $("sel-doc-mode")) $("sel-doc-mode").value = data.doc_mode;
881
+ if (document.activeElement !== $("sel-duplicate-mode")) $("sel-duplicate-mode").value = data.duplicate_mode;
852
882
  if (data.hook_mode_error) setMsg("agents-msg", "error", data.hook_mode_error);
883
+ if (data.doc_mode_error) setMsg("agents-msg", "error", data.doc_mode_error);
884
+ if (data.duplicate_mode_error) setMsg("agents-msg", "error", data.duplicate_mode_error);
853
885
  const list = $("agents-list");
854
886
  list.textContent = "";
855
887
  for (const item of data.clients) {
@@ -913,6 +945,22 @@ async function loadAgents() {
913
945
  }
914
946
  }
915
947
  loadAgents();
948
+ $("btn-save-doc-mode").addEventListener("click", async () => {
949
+ try {
950
+ await post("/setup/integration", { action: "doc_mode", mode: $("sel-doc-mode").value });
951
+ setMsg("agents-msg", "ok", "Documentation setting saved; it applies on the next edit.");
952
+ } catch (error) {
953
+ setMsg("agents-msg", "error", error.message);
954
+ }
955
+ });
956
+ $("btn-save-duplicate-mode").addEventListener("click", async () => {
957
+ try {
958
+ await post("/setup/integration", { action: "duplicate_mode", mode: $("sel-duplicate-mode").value });
959
+ setMsg("agents-msg", "ok", "Duplicate setting saved; it applies on the next edit.");
960
+ } catch (error) {
961
+ setMsg("agents-msg", "error", error.message);
962
+ }
963
+ });
916
964
  $("btn-save-hook-mode").addEventListener("click", async () => {
917
965
  try {
918
966
  await post("/setup/integration", { action: "hook_mode", mode: $("sel-hook-mode").value });