@allansantos-dev/smart-tool 0.9.2 → 0.9.3

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.
@@ -0,0 +1,92 @@
1
+ """The files an agent edit would produce, computed before the edit runs: Claude Code Edit, MultiEdit and Write, and
2
+ Codex apply_patch (*** Begin Patch / Update File / Add File). Returns (absolute path, text before or None, text after)
3
+ for each file it can rebuild; an edit it cannot rebuild yields nothing, so a hook built on it never blocks wrongly."""
4
+ import os
5
+
6
+
7
+ def _read(path):
8
+ try:
9
+ with open(path, encoding="utf-8") as stream:
10
+ return stream.read()
11
+ except (OSError, UnicodeDecodeError):
12
+ return None
13
+
14
+
15
+ def _replace(text, old, new, every):
16
+ if not old or old not in text:
17
+ return None
18
+ return text.replace(old, new) if every else text.replace(old, new, 1)
19
+
20
+
21
+ def _claude(tool, data):
22
+ path = data.get("file_path")
23
+ if not isinstance(path, str) or not os.path.isabs(path):
24
+ return []
25
+ before = _read(path)
26
+ if tool == "Write":
27
+ content = data.get("content")
28
+ return [(path, before, content)] if isinstance(content, str) else []
29
+ if before is None:
30
+ return []
31
+ edits = data.get("edits") if tool == "MultiEdit" else [data]
32
+ after = before
33
+ for edit in edits or []:
34
+ after = _replace(after, edit.get("old_string"), edit.get("new_string", ""), edit.get("replace_all") is True)
35
+ if after is None:
36
+ return []
37
+ return [(path, before, after)]
38
+
39
+
40
+ def _apply_hunks(text, hunks):
41
+ lines = text.splitlines()
42
+ for old, new in hunks:
43
+ if not old:
44
+ lines.extend(new)
45
+ continue
46
+ at = next((i for i in range(len(lines) - len(old) + 1) if lines[i:i + len(old)] == old), None)
47
+ if at is None:
48
+ return None
49
+ lines[at:at + len(old)] = new
50
+ return "\n".join(lines) + ("\n" if text.endswith("\n") or not text else "")
51
+
52
+
53
+ def _codex(patch, cwd):
54
+ if not isinstance(patch, str) or "*** Begin Patch" not in patch:
55
+ return []
56
+ files, current = [], None
57
+ for raw in patch.splitlines():
58
+ if raw.startswith(("*** Update File: ", "*** Add File: ")):
59
+ current = {"action": raw.split(":", 1)[0][4:], "path": raw.split(":", 1)[1].strip(), "hunks": [[[], []]]}
60
+ files.append(current)
61
+ elif raw.startswith(("*** Delete File: ", "*** End Patch", "*** Move to: ")) or current is None:
62
+ current = None if raw.startswith(("*** Delete File: ", "*** End Patch")) else current
63
+ elif raw.startswith("@@"):
64
+ current["hunks"].append([[], []])
65
+ elif raw.startswith("+"):
66
+ current["hunks"][-1][1].append(raw[1:])
67
+ elif raw.startswith("-"):
68
+ current["hunks"][-1][0].append(raw[1:])
69
+ elif raw.startswith(" ") or raw == "":
70
+ current["hunks"][-1][0].append(raw[1:])
71
+ current["hunks"][-1][1].append(raw[1:])
72
+ results = []
73
+ for entry in files:
74
+ path = entry["path"] if os.path.isabs(entry["path"]) else os.path.join(cwd or os.getcwd(), entry["path"])
75
+ hunks = [hunk for hunk in entry["hunks"] if hunk[0] or hunk[1]]
76
+ if entry["action"] == "Add File":
77
+ results.append((path, None, "\n".join(line for hunk in hunks for line in hunk[1]) + "\n"))
78
+ continue
79
+ before = _read(path)
80
+ after = _apply_hunks(before, hunks) if before is not None else None
81
+ if after is not None:
82
+ results.append((path, before, after))
83
+ return results
84
+
85
+
86
+ def preview(tool_name, tool_input, cwd=None):
87
+ data = tool_input if isinstance(tool_input, dict) else {}
88
+ if tool_name in ("Edit", "MultiEdit", "Write") and "file_path" in data:
89
+ return _claude(tool_name, data)
90
+ if tool_name in ("apply_patch", "Edit", "Write") and "command" in data:
91
+ return _codex(data.get("command"), cwd)
92
+ return []
package/hook_decision.py CHANGED
@@ -10,11 +10,20 @@ 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
14
19
  import endpoint_sync
20
+ import project_store
15
21
  import router
16
22
 
17
23
  WEB_TOOLS = ("WebSearch", "WebFetch")
24
+ EDIT_TOOLS = ("Edit", "MultiEdit", "Write", "apply_patch")
25
+ MAX_DOC_FINDINGS = 5
26
+ MAX_DUPLICATE_FINDINGS = 3
18
27
  SMART_TOOL_SERVER = "smart-tool"
19
28
  REASON_MAX_CHARS = 200
20
29
 
@@ -55,9 +64,94 @@ def advise(reason):
55
64
  "additionalContext": f"Smart Tool tip (the call was not blocked): {reason}"}}
56
65
 
57
66
 
67
+ def _project_of(path):
68
+ target = os.path.normcase(os.path.abspath(path))
69
+ roots = [p["root"] for p in project_store.all_projects()
70
+ if target.startswith(os.path.normcase(os.path.abspath(p["root"])) + os.sep)]
71
+ return max(roots, key=len) if roots else None
72
+
73
+
74
+ def _impact_line(root, rel, name):
75
+ """Callers and tests from the code graph, only when its analysis is already cached: the hook never waits for one."""
76
+ if not root or code_graph.cached_symbols(root)[0] is None:
77
+ return ""
78
+ try:
79
+ found = code_impact.impact(root, f"{rel}::{name}", limit=3)
80
+ except ValueError:
81
+ return ""
82
+ counts = found["counts"]
83
+ tests = ", ".join(f"{t['function']} ({t['path']})" for t in found["tests"])
84
+ calls = f"; tests calling it: {tests}" if tests else ""
85
+ return f" {counts['callers']} caller(s){calls}; project_manage affected_tests lists every test to run after the edit."
86
+
87
+
88
+ def _doc_lines(findings):
89
+ lines = []
90
+ for root, rel, item in findings[:MAX_DOC_FINDINGS]:
91
+ if item["issue"] == "missing":
92
+ text = f"{item['name']} ({rel}:{item['start']}) has no docstring: write one (purpose, parameters, return)."
93
+ else:
94
+ text = (f"{item['name']} ({rel}:{item['start']}) changed its signature: update its docstring if the "
95
+ f"contract changed.")
96
+ lines.append(text + _impact_line(root, rel, item["name"]))
97
+ if len(findings) > MAX_DOC_FINDINGS:
98
+ lines.append(f"{len(findings) - MAX_DOC_FINDINGS} more function(s) in the same situation.")
99
+ return "Smart Tool docs: " + " ".join(lines)
100
+
101
+
102
+ def _duplicate_lines(findings):
103
+ lines = []
104
+ for rel, item in findings[:MAX_DUPLICATE_FINDINGS]:
105
+ existing = item["existing"]
106
+ where = f"{existing['name']} in {existing['path']}:{existing['lines']}"
107
+ if item["type"] == "exact":
108
+ lines.append(f"{item['name']} ({rel}:{item['lines']}) has the same body as {where}: reuse it instead of a copy.")
109
+ else:
110
+ lines.append(f"{item['name']} ({rel}:{item['lines']}) is near-identical to {where} (score {item['score']}, "
111
+ f"local names ignored): check whether it can be reused.")
112
+ if len(findings) > MAX_DUPLICATE_FINDINGS:
113
+ lines.append(f"{len(findings) - MAX_DUPLICATE_FINDINGS} more copied function(s).")
114
+ return ("Smart Tool duplicates: " + " ".join(lines)
115
+ + " If the copy is intentional (moving the function, a deliberate fork), go ahead.")
116
+
117
+
118
+ def _edit_review(payload, doc_mode, duplicate_mode):
119
+ """Checks of an edit before it runs. Docstrings follow doc_mode: missing ones deny the edit in require mode and
120
+ become a reminder in remind mode; a documented function whose signature changed always gets a reminder. Copies of
121
+ indexed functions follow duplicate_mode and only add context, never deny."""
122
+ doc_findings, duplicate_findings, failures = [], [], []
123
+ for path, before, after in edit_preview.preview(payload.get("tool_name"), payload.get("tool_input"), payload.get("cwd")):
124
+ if not doc_check.supported(path):
125
+ continue
126
+ root = _project_of(path)
127
+ rel = os.path.relpath(path, root).replace(os.sep, "/") if root else os.path.basename(path)
128
+ edited = doc_check.written(path, before, after)
129
+ if doc_mode != "off":
130
+ doc_findings += [(root, rel, item) for item in doc_check.review(path, before, after, edited)]
131
+ if duplicate_mode != "off" and root and edited["touched"]:
132
+ try:
133
+ duplicate_findings += [(rel, item) for item in duplicates.on_write(root, path, before, after, edited)]
134
+ except (ValueError, RuntimeError) as exc:
135
+ failures.append(f"Smart Tool duplicates check skipped: {exc}")
136
+ parts = ([_doc_lines(doc_findings)] if doc_findings else []) + \
137
+ ([_duplicate_lines(duplicate_findings)] if duplicate_findings else []) + failures
138
+ if not parts:
139
+ return {}
140
+ message = " ".join(parts)
141
+ if doc_mode == "require" and any(item["issue"] == "missing" for _root, _rel, item in doc_findings):
142
+ return deny(message + " Redo the edit with the docstring.")
143
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": message}}
144
+
145
+
58
146
  def decide(payload, client, web_route):
59
- """Hook output for one PreToolUse payload, following config hook_mode (redirect | advise | off)."""
147
+ """Hook output for one PreToolUse payload: edits follow doc_mode (require | remind | off) and duplicate_mode
148
+ (warn | off), the other tools follow hook_mode (redirect | advise | off)."""
60
149
  cfg = config.load_config()
150
+ if isinstance(payload, dict) and payload.get("tool_name") in EDIT_TOOLS:
151
+ doc_mode, duplicate_mode = config.doc_mode(cfg), config.duplicate_mode(cfg)
152
+ if doc_mode == "off" and duplicate_mode == "off":
153
+ return {}
154
+ return _edit_review(payload, doc_mode, duplicate_mode)
61
155
  mode = config.hook_mode(cfg)
62
156
  if mode == "off" or not isinstance(payload, dict):
63
157
  return {}
@@ -82,7 +176,7 @@ def _route(payload, client, web_route, cfg):
82
176
  return {}
83
177
  decision = web_route(tool_name, str(tool_input.get("url") or ""))
84
178
  return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
85
- router.CLIENT = client
179
+ router.CLIENT.set(client)
86
180
  try:
87
181
  decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
88
182
  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/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.3",
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:
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 });
@@ -48,6 +48,7 @@ import index_views
48
48
  import index_inventory
49
49
  import usage_meter
50
50
  import document_text
51
+ import embedding_cache
51
52
  import model_defaults
52
53
  import project_identity
53
54
  import project_store
@@ -291,7 +292,7 @@ def _smart_search_job_payload(job_id):
291
292
  else:
292
293
  payload["error"] = job.get("error") or "Search did not complete."
293
294
  payload["resumable"] = bool(job.get("arguments"))
294
- if job.get("stats"):
295
+ if job.get("stats") and job["stats"] != payload.get("result"):
295
296
  payload["stats"] = job["stats"]
296
297
  if job.get("failure_phase"):
297
298
  payload["failure_phase"] = job["failure_phase"]
@@ -302,10 +303,10 @@ def _smart_search_job_payload(job_id):
302
303
  TOOLS = [
303
304
  {
304
305
  "name": "project_manage",
305
- "description": "Manages folders and indexing. list/status show project_id, storage, scope and jobs. graph reads the indexed view: coverage, references and static calls in Java, Angular, Python and JS/TS/JSX/TSX; includes HTML/CSS/Markdown references and TXT/DOCX coverage; with file_path, narrows to that file's symbols, imports and calls. inspect lists the files of a view_id; with file_path, returns only that file's chunks. usage shows consumption and reuse; duplicates lists duplicated functions (identical, near-identical and semantic bodies, min_similarity default 0.90; below that most pairs measured were false positives), excluding tests unless include_tests=true; fix real duplicates; only dismiss with duplicates_dismiss (finding_id, reason=false_positive|intentional, note) what is not a duplicate or is a copy kept on purpose: the finding stays hidden until the code changes, and duplicates_restore reopens it; integration shows the MCP client, hook and tool usage metrics; install_hook_preview shows the change that would install the hook redirecting Grep/Read to smart_search and WebSearch/WebFetch to web_search/web_fetch in this session's client and returns a plan_id; install_hook writes it with that plan_id and confirm=true, with a backup, only after the user approves the change; search_limits saves the project's default top_k per block ({code, test, doc}, each 1-20, total at most 20); storage shows disk usage and views; pin_view protects a view; cleanup_preview and cleanup_commit remove selected views after confirmation; compare compares two stored views, without checkout or embeddings. register adds a folder; preview checks the scope; index/rebuild update; pause/cancel keep checkpoints; resume continues; policy picks on_search/eager; scope adjusts the scope; remove deletes only the local index. Jobs via smart_search_result.",
306
+ "description": "Manages folders and indexing. Every action takes project_root (the folder) or project_id. list gives a short line per project; status shows storage, scope and jobs. graph with symbol (function, Class.method, class name, or path::name when the name repeats) answers what an edit touches: definition, callers with line, calls, tests that reach it through static calls (depth hops, default 3) and files importing it; use it before changing a function to know what to update. affected_tests answers which tests to run after editing: every test file importing a changed file (git diff against base, default HEAD, untracked included) plus changed tests, likeliest failures first, with the command to run them and run_all when config or unanalyzed code changed. graph without symbol reads the indexed view: coverage, references and static calls in Java, Angular, Python and JS/TS/JSX/TSX; includes HTML/CSS/Markdown references and TXT/DOCX coverage; with file_path, narrows to that file's symbols, imports and calls (lists capped by limit). inspect lists the files of a view_id; with file_path, returns only that file's chunks. usage shows consumption and reuse; docs lists public functions without a docstring (Python docstring, JSDoc, Javadoc; private, nested and override functions excluded) with coverage percent, excluding tests unless include_tests=true, for documenting a project that started without it; duplicates lists duplicated functions (identical, near-identical and semantic bodies, min_similarity default 0.90; below that most pairs measured were false positives), excluding tests unless include_tests=true; fix real duplicates; only dismiss with duplicates_dismiss (finding_id, reason=false_positive|intentional, note) what is not a duplicate or is a copy kept on purpose: the finding stays hidden until the code changes, and duplicates_restore reopens it; integration shows the MCP client, hook and tool usage metrics; install_hook_preview shows the change that would install the hook redirecting Grep/Read to smart_search and WebSearch/WebFetch to web_search/web_fetch in this session's client and returns a plan_id; install_hook writes it with that plan_id and confirm=true, with a backup, only after the user approves the change; search_limits saves the project's default top_k per block ({code, test, doc}, each 1-20, total at most 20); storage shows disk usage and views; pin_view protects a view; cleanup_preview and cleanup_commit remove selected views after confirmation; compare compares two stored views, without checkout or embeddings. register adds a folder; preview checks the scope; index/rebuild update; pause/cancel keep checkpoints; resume continues; policy picks on_search/eager; scope adjusts the scope; remove deletes only the local index. Jobs via smart_search_result.",
306
307
  "inputSchema": {
307
308
  "type": "object", "properties": {
308
- "action": {"type": "string", "enum": ["list", "status", "register", "preview", "index", "rebuild", "pause", "cancel", "resume", "watch", "scope", "remove", "relocate", "probe", "policy", "inspect", "graph", "usage", "storage", "pin_view", "cleanup_preview", "cleanup_commit", "compare", "search_limits", "integration", "install_hook_preview", "install_hook", "duplicates", "duplicates_dismiss", "duplicates_restore"]},
309
+ "action": {"type": "string", "enum": ["list", "status", "register", "preview", "index", "rebuild", "pause", "cancel", "resume", "watch", "scope", "remove", "relocate", "probe", "policy", "inspect", "graph", "usage", "storage", "pin_view", "cleanup_preview", "cleanup_commit", "compare", "search_limits", "integration", "install_hook_preview", "install_hook", "duplicates", "duplicates_dismiss", "duplicates_restore", "docs", "affected_tests"]},
309
310
  "finding_id": {"type": "string"},
310
311
  "reason": {"type": "string", "enum": ["false_positive", "intentional"]},
311
312
  "note": {"type": "string"},
@@ -324,6 +325,9 @@ TOOLS = [
324
325
  "left_view":{"type":"string"},"right_view":{"type":"string"},"relations":{"type":"boolean"},
325
326
  "update_mode": {"type": "string", "enum": ["on_search", "eager"]},
326
327
  "view_id": {"type": "string"}, "file_path": {"type": "string"},
328
+ "base": {"type": "string", "default": "HEAD", "description": "affected_tests: git revision the changes are compared with (HEAD = uncommitted work; main or origin/main for a branch)"},
329
+ "symbol": {"type": "string", "description": "graph: function, Class.method, class name or path::name to get its callers, calls, tests and importers"},
330
+ "depth": {"type": "integer", "minimum": 1, "maximum": 4, "default": 3, "description": "graph with symbol: call hops searched for tests"},
327
331
  "project_root": {"type": "string"}, "project_id": {"type": "string"},
328
332
  "job_id": {"type": "string"}, "watch": {"type": "boolean"},
329
333
  "manual": {"type": "boolean"}, "force_scope": {"type": "boolean"},
@@ -1414,27 +1418,69 @@ def _find_duplicates(root, arguments, dismissed):
1414
1418
  return duplicates.find(root, embed, configured_model, float(similarity), include_tests, limit, dismissed, include_dismissed)
1415
1419
 
1416
1420
 
1421
+ def _with_project_id(arguments):
1422
+ """Agents know the folder, not the id: project_root resolves to the registered project's project_id."""
1423
+ root = arguments.get("project_root")
1424
+ if arguments.get("project_id") or not isinstance(root, str) or arguments.get("action") in ("register", "relocate"):
1425
+ return arguments
1426
+ wanted = project_identity.canonical_root(root)
1427
+ match = next((p for p in project_store.all_projects() if project_identity.canonical_root(p["root"]) == wanted), None)
1428
+ if not match:
1429
+ raise ValueError(f"No registered project at {root}; register it first (action=register).")
1430
+ return {**arguments, "project_id": match["id"]}
1431
+
1432
+
1433
+ def _project_summary(project):
1434
+ data = _project_payload(project)
1435
+ index = data.get("index") or {}
1436
+ jobs = data.get("jobs") or []
1437
+ return {"project_id": data["id"], "name": data.get("name"), "root": data["root"], "status": data.get("status"),
1438
+ "enabled": data.get("enabled"), "paused": data.get("paused"), "update_mode": data.get("update_mode"),
1439
+ "files": index.get("files"), "chunks": index.get("chunks"), "last_indexed": data.get("last_indexed"),
1440
+ "view": (data.get("view") or {}).get("label"), "active_jobs": len(data.get("active_jobs") or []),
1441
+ "last_job": {k: jobs[0].get(k) for k in ("job_id", "status", "kind")} if jobs else None,
1442
+ "last_error": (data.get("last_error") or None) and str(data["last_error"])[:200]}
1443
+
1444
+
1417
1445
  def _project_action(arguments):
1418
1446
  if not isinstance(arguments, dict):
1419
1447
  raise ValueError("Pass the action in a JSON object.")
1448
+ arguments = _with_project_id(arguments)
1420
1449
  action = arguments.get("action", "list")
1421
1450
  if action == "probe":
1422
1451
  return _probe_index_models()
1423
- if action in ('graph','compare','duplicates','duplicates_dismiss','duplicates_restore'):
1452
+ if action in ('graph','compare','duplicates','duplicates_dismiss','duplicates_restore','docs','affected_tests'):
1424
1453
  # Análise somente leitura pode demorar; não prende o lock global de ações.
1425
1454
  key=arguments.get('project_id')
1426
1455
  if not isinstance(key,str) or not re.fullmatch(r'[a-f0-9]{16}',key):
1427
- raise ValueError('Pass a valid project_id.')
1456
+ raise ValueError('Pass project_root (the folder) or a valid project_id.')
1428
1457
  project=project_store.get(key)
1429
1458
  if not project:
1430
1459
  raise ValueError('Project not registered.')
1431
1460
  if action=='duplicates':
1432
1461
  return _find_duplicates(project['root'], arguments, project.get('dismissed_duplicates') or {})
1462
+ if action=='docs':
1463
+ import doc_check
1464
+ include_tests=arguments.get('include_tests',False)
1465
+ if type(include_tests) is not bool:
1466
+ raise ValueError('include_tests must be true or false.')
1467
+ limit=_parse_bounded_int(arguments.get('limit'),default=30,min_v=1,max_v=200,field_name='limit')
1468
+ return doc_check.coverage(project['root'],include_tests,limit,arguments.get('view_id'))
1469
+ if action=='affected_tests':
1470
+ import affected_tests
1471
+ limit=_parse_bounded_int(arguments.get('limit'),default=30,min_v=1,max_v=200,field_name='limit')
1472
+ return affected_tests.affected(project['root'],arguments.get('base','HEAD'),limit,arguments.get('view_id'))
1433
1473
  if action in ('duplicates_dismiss','duplicates_restore'):
1434
1474
  return _change_dismissal(key, project, arguments, action == 'duplicates_dismiss')
1435
1475
  if action=='graph':
1436
1476
  import code_graph
1437
- return code_graph.build(project['root'],arguments.get('view_id'),file_path=arguments.get('file_path'))
1477
+ import code_impact
1478
+ limit=_parse_bounded_int(arguments.get('limit'),default=30,min_v=1,max_v=200,field_name='limit')
1479
+ if arguments.get('symbol') is not None:
1480
+ return code_impact.impact(project['root'],arguments['symbol'],arguments.get('view_id'),
1481
+ arguments.get('depth',3),limit)
1482
+ graph=code_graph.build(project['root'],arguments.get('view_id'),file_path=arguments.get('file_path'))
1483
+ return code_impact.for_agent(graph,limit) if arguments.get('file_path') else graph
1438
1484
  import view_compare
1439
1485
  return view_compare.compare(project['root'],arguments.get('left_view'),arguments.get('right_view'),
1440
1486
  arguments.get('file_path'),arguments.get('relations',True))
@@ -1513,6 +1559,9 @@ def _project_action_locked(arguments):
1513
1559
  _metric("install_hook", target=result["client"], changed=result["changed"])
1514
1560
  return result
1515
1561
  if action == "list":
1562
+ if not arguments.get("full"):
1563
+ return {"projects": [_project_summary(p) for p in project_store.all_projects()],
1564
+ "detail": "Use action=status with project_id or project_root for scope, jobs and index details."}
1516
1565
  return {"projects": [_project_payload(p) for p in project_store.all_projects()],
1517
1566
  "storage_dir": indexer.INDEX_DIR,
1518
1567
  "gateway": dict(_GATEWAY_STATUS),
@@ -1526,7 +1575,7 @@ def _project_action_locked(arguments):
1526
1575
  return {"project": _project_payload(project)}
1527
1576
  key = arguments.get("project_id")
1528
1577
  if not isinstance(key, str) or not re.fullmatch(r"[a-f0-9]{16}", key):
1529
- raise ValueError("Pass a valid project_id; use the list action to look it up.")
1578
+ raise ValueError("Pass project_root (the folder) or a valid project_id.")
1530
1579
  project = project_store.get(key)
1531
1580
  if not project:
1532
1581
  raise ValueError("Project not registered.")
@@ -1995,10 +2044,8 @@ def _fan_out_web_search(query, num, budget_s=None, site_domains=()):
1995
2044
  try:
1996
2045
  fut.result()
1997
2046
  _record_tier_health(tier_name, success=True)
1998
- except (TierSkipped, browser_service_client.BrowserSkipped):
1999
- pass
2000
- except Exception:
2001
- _record_tier_health(tier_name, success=False)
2047
+ except Exception as exc:
2048
+ _record_tier_failure(tier_name, exc)
2002
2049
  return _cb
2003
2050
 
2004
2051
  for fut, tier_name in futures.items():
@@ -2243,8 +2290,7 @@ def _search_web_once(query, num, scope, deadline=None, assess_semantic=False,
2243
2290
  if not raw_results:
2244
2291
  raise RuntimeError("zero results in the requested domain")
2245
2292
  except Exception as exc:
2246
- if not isinstance(exc, TierSkipped):
2247
- _record_tier_health(tier_name, success=False)
2293
+ _record_tier_failure(tier_name, exc)
2248
2294
  errors.append(_sanitize_text(f"{tier_name}: {type(exc).__name__}: {exc}"))
2249
2295
  continue
2250
2296
  _record_tier_health(tier_name, success=True)
@@ -2278,8 +2324,7 @@ def _search_web_once(query, num, scope, deadline=None, assess_semantic=False,
2278
2324
  if not raw_results:
2279
2325
  raise RuntimeError("zero results in the requested domain")
2280
2326
  except Exception as exc:
2281
- if not isinstance(exc, (TierSkipped, browser_service_client.BrowserSkipped)):
2282
- _record_tier_health(tier_name, success=False)
2327
+ _record_tier_failure(tier_name, exc)
2283
2328
  errors.append(_sanitize_text(f"{tier_name}: {type(exc).__name__}: {exc}"))
2284
2329
  continue
2285
2330
  _record_tier_health(tier_name, success=True)
@@ -2588,13 +2633,26 @@ def _annotate_quick_results(rows, classification, cache=None):
2588
2633
  return result
2589
2634
 
2590
2635
 
2591
- def _record_tier_health(tier_name, success):
2636
+ def _record_tier_health(tier_name, success, outcome=None):
2592
2637
  try:
2593
- web_search_health.record(tier_name, success)
2638
+ web_search_health.record(tier_name, success, outcome)
2594
2639
  except Exception:
2595
2640
  pass # telemetria é best-effort — nunca pode derrubar uma busca já resolvida
2596
2641
 
2597
2642
 
2643
+ def _record_tier_failure(tier_name, exc):
2644
+ """Health entry for a failed tier call, keeping a 429, a captcha and a quota pause apart from other errors."""
2645
+ if isinstance(exc, (TierSkipped, browser_service_client.BrowserSkipped)):
2646
+ outcome = "paused"
2647
+ elif isinstance(exc, WebProviderRateLimited):
2648
+ outcome = "rate_limited"
2649
+ elif isinstance(exc, browser_service_client.BrowserCaptcha):
2650
+ outcome = "captcha"
2651
+ else:
2652
+ outcome = "failed"
2653
+ _record_tier_health(tier_name, False, outcome)
2654
+
2655
+
2598
2656
  _is_safe_crawl_target = web_fetch.is_safe_target
2599
2657
 
2600
2658
 
@@ -3073,6 +3131,7 @@ def _tool_result(text, is_error=False):
3073
3131
 
3074
3132
  WEB_FETCH_TIMEOUT_S = 90
3075
3133
  WEB_FETCH_RENDER_TIMEOUT_S = 60
3134
+ WEB_FETCH_VECTOR_ROOT = os.path.join(paths.DATA_DIR, "web-fetch")
3076
3135
 
3077
3136
 
3078
3137
  def _render_page(url, deadline):
@@ -3085,6 +3144,30 @@ def _render_page(url, deadline):
3085
3144
  return (pages[0].get("markdown") or "") if pages else ""
3086
3145
 
3087
3146
 
3147
+ def _web_fetch_embed(texts, deadline):
3148
+ """Vectors for the pieces of a long page and the prompt, with the pieces cached per embedding model. Fails loudly
3149
+ without an embedding model: a long page is never cut back to its start in silence."""
3150
+ model = local_embedder.resolve(config.load_config().get("embedding_model"), None)
3151
+ if not model or model == indexer.LEXICAL_MODEL:
3152
+ raise RuntimeError("This page is longer than 60,000 characters and choosing its relevant parts needs an "
3153
+ "embedding_model, which is not configured")
3154
+ try:
3155
+ token = model_client.get_token()
3156
+ probe = _embed(model, texts[-1:], token, deadline=deadline)
3157
+ cache = embedding_cache.VectorCache(WEB_FETCH_VECTOR_ROOT, indexer.INDEX_DIR, {
3158
+ "purpose": "web_fetch_pieces", "model": model, "dimensions": len(probe[0])})
3159
+ try:
3160
+ pieces = cache.embed(texts[:-1], lambda batch: _embed(model, batch, token, deadline=deadline),
3161
+ indexer.validate_vector, split=lambda unique: list(_embed_batches(unique)),
3162
+ workers=indexer.EMBED_WORKERS)
3163
+ finally:
3164
+ cache.db.close()
3165
+ except Exception as exc:
3166
+ raise RuntimeError(f"This page is longer than 60,000 characters and the embedding model ({model}) that picks "
3167
+ f"its relevant parts failed: {type(exc).__name__}: {exc}"[:400]) from None
3168
+ return pieces + probe
3169
+
3170
+
3088
3171
  def _web_fetch_unavailable():
3089
3172
  cfg = config.load_config()
3090
3173
  if not cfg.get("router_model"):
@@ -3105,18 +3188,22 @@ def _handle_web_fetch(arguments):
3105
3188
  raise RuntimeError(f"Gateway model unavailable ({problem}).")
3106
3189
  deadline = started + WEB_FETCH_TIMEOUT_S
3107
3190
  page = web_fetch.read_page(url, deadline, _render_page)
3108
- text = web_fetch.answer(config.load_config()["router_model"], prompt, page, deadline)
3191
+ selected, pieces = web_fetch.select(page["text"], prompt, lambda texts: _web_fetch_embed(texts, deadline))
3192
+ text = web_fetch.answer(config.load_config()["router_model"], prompt, {**page, "text": selected}, deadline)
3109
3193
  except Exception as exc:
3110
3194
  web_fetch.mark_failed(str(url))
3111
3195
  _metric("web_fetch", error=type(exc).__name__, elapsed_s=round(time.monotonic() - started, 2))
3112
3196
  raise RuntimeError(f"web_fetch failed: {str(exc).rstrip('.')}. The native WebFetch is allowed for this URL for "
3113
3197
  f"{web_fetch.FAILED_TTL_S // 60} min.") from None
3114
3198
  _metric("web_fetch", cached=page["cached"], rendered=page["rendered"], render_error=page.get("render_error"),
3115
- page_chars=len(page["text"]),
3199
+ page_chars=page["chars"], read_chars=len(selected), pieces=pieces,
3116
3200
  answer_chars=len(text), elapsed_s=round(time.monotonic() - started, 2))
3117
3201
  origin = "from cache" if page["cached"] else "rendered in the browser" if page["rendered"] else "downloaded now"
3118
3202
  read_at = datetime.datetime.fromtimestamp(page["fetched_at"]).strftime("%Y-%m-%d %H:%M")
3119
- cut = "; page cut to the first 60,000 characters" if page["truncated"] else ""
3203
+ cut = (f"; page of {page['chars']:,} characters: the {pieces} parts most related to the request were read"
3204
+ if pieces else "")
3205
+ if page["truncated"]:
3206
+ cut += f"; only the first {web_fetch.PAGE_STORE_MAX_CHARS:,} characters were kept"
3120
3207
  return _sanitize_text(f"Source: {page['final_url']} (read at {read_at}, {origin}{cut})\n\n{text}")
3121
3208
 
3122
3209
 
@@ -3395,7 +3482,7 @@ class MCPHandler(BaseHTTPRequestHandler):
3395
3482
  if self.path == "/setup/projects":
3396
3483
  try:
3397
3484
  setup_ui.require_token(self.headers)
3398
- self._write_json(200, _project_action({"action": "list"}))
3485
+ self._write_json(200, _project_action({"action": "list", "full": True}))
3399
3486
  except setup_ui.SetupError as exc:
3400
3487
  self._write_json(403, {"error": str(exc)})
3401
3488
  except Exception as exc:
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.2"
4
+ VERSION = "0.9.3"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7