@allansantos-dev/smart-tool 0.9.6 → 0.9.8

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/setup_ui.py CHANGED
@@ -19,6 +19,7 @@ import re
19
19
  import secrets
20
20
 
21
21
  import autostart
22
+ import block_patterns
22
23
  import client_hooks
23
24
  import config
24
25
  import daemon_launcher
@@ -185,19 +186,36 @@ def handle_integration_get():
185
186
  duplicate_mode, duplicate_error = config.duplicate_mode(), ""
186
187
  except ValueError as exc:
187
188
  duplicate_mode, duplicate_error = "", str(exc)
189
+ try:
190
+ proposals_mode, proposals_error = config.pattern_proposals(), ""
191
+ except ValueError as exc:
192
+ proposals_mode, proposals_error = "", str(exc)
193
+ try:
194
+ stored, patterns_error = block_patterns.load(), ""
195
+ except ValueError as exc:
196
+ stored, patterns_error = block_patterns._empty(), str(exc)
188
197
  return {"daemon_url": setup_origin(), "clients": clients, "hook_mode": mode, "hook_mode_error": mode_error,
189
198
  "hook_modes": list(config.HOOK_MODES), "doc_mode": doc_mode, "doc_mode_error": doc_error,
190
199
  "doc_modes": list(config.DOC_MODES), "duplicate_mode": duplicate_mode,
191
- "duplicate_mode_error": duplicate_error, "duplicate_modes": list(config.DUPLICATE_MODES)}
200
+ "duplicate_mode_error": duplicate_error, "duplicate_modes": list(config.DUPLICATE_MODES),
201
+ "pattern_proposals": proposals_mode, "pattern_proposals_error": proposals_error,
202
+ "block_patterns": {"proposals": stored["proposals"], "accepted": stored["accepted"],
203
+ "rejected": len(stored["rejected"]), "error": patterns_error}}
192
204
 
193
205
 
194
206
  def handle_integration_save(body):
195
207
  """`hook_mode` sets redirect/advise/off for every client; `register_mcp` runs the client's CLI; hook in two steps:
196
208
  `preview` shows the exact change, `install` writes it (with backup)."""
197
- if isinstance(body, dict) and body.get("action") in ("hook_mode", "doc_mode", "duplicate_mode"):
209
+ if isinstance(body, dict) and body.get("action") == "block_pattern":
210
+ try:
211
+ block_patterns.decide_proposal(str(body.get("id") or ""), body.get("decision"))
212
+ except (OSError, ValueError) as exc:
213
+ raise SetupError(str(exc))
214
+ return handle_integration_get()
215
+ if isinstance(body, dict) and body.get("action") in ("hook_mode", "doc_mode", "duplicate_mode", "pattern_proposals"):
198
216
  action = body["action"]
199
217
  allowed = {"hook_mode": config.HOOK_MODES, "doc_mode": config.DOC_MODES,
200
- "duplicate_mode": config.DUPLICATE_MODES}[action]
218
+ "duplicate_mode": config.DUPLICATE_MODES, "pattern_proposals": config.PATTERN_PROPOSAL_MODES}[action]
201
219
  if body.get("mode") not in allowed:
202
220
  raise SetupError(f"Invalid mode: use {', '.join(allowed)}.")
203
221
  try:
@@ -593,6 +611,14 @@ _PAGE_TEMPLATE = """<!doctype html>
593
611
  </select>
594
612
  <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
613
  <div class="actions"><button type="button" id="btn-save-duplicate-mode">Save duplicates</button></div>
614
+ <div class="field" style="margin-top:16px"><label for="sel-pattern-proposals">Block patterns proposed by a model</label>
615
+ <select id="sel-pattern-proposals">
616
+ <option value="on">On: searches the rule lets run are reviewed in the background and may become proposals (default)</option>
617
+ <option value="off">Off: no review</option>
618
+ </select>
619
+ <span class="hint">The hook decides with a fixed rule: content searches over a project folder go to smart_search. The scope model (else the router model) never delays a call: it reviews, afterwards, bulk reads the rule could not measure and proposes patterns, each checked against the logged calls. Nothing blocks until you accept it here.</span></div>
620
+ <div class="actions"><button type="button" id="btn-save-pattern-proposals">Save proposals setting</button></div>
621
+ <div id="patterns-list"></div>
596
622
  <div class="msg" id="agents-msg" role="status"></div>
597
623
  <div id="agents-list"></div>
598
624
  </section>
@@ -882,6 +908,9 @@ async function loadAgents() {
882
908
  if (data.hook_mode_error) setMsg("agents-msg", "error", data.hook_mode_error);
883
909
  if (data.doc_mode_error) setMsg("agents-msg", "error", data.doc_mode_error);
884
910
  if (data.duplicate_mode_error) setMsg("agents-msg", "error", data.duplicate_mode_error);
911
+ if (document.activeElement !== $("sel-pattern-proposals")) $("sel-pattern-proposals").value = data.pattern_proposals;
912
+ if (data.pattern_proposals_error) setMsg("agents-msg", "error", data.pattern_proposals_error);
913
+ renderPatterns(data.block_patterns);
885
914
  const list = $("agents-list");
886
915
  list.textContent = "";
887
916
  for (const item of data.clients) {
@@ -944,7 +973,59 @@ async function loadAgents() {
944
973
  setMsg("agents-msg", "error", "Agent status unavailable: " + error.message);
945
974
  }
946
975
  }
976
+ function renderPatterns(patterns) {
977
+ const box = $("patterns-list");
978
+ box.textContent = "";
979
+ if (patterns.error) { const p = document.createElement("p"); p.className = "msg error"; p.textContent = patterns.error; box.appendChild(p); return; }
980
+ const groups = [["proposals", "Proposed", [["accept", "Accept"], ["reject", "Reject"]]], ["accepted", "Blocking", [["remove", "Remove"]]]];
981
+ for (const [key, title, actions] of groups) {
982
+ if (!patterns[key].length) continue;
983
+ const head = document.createElement("h3");
984
+ head.textContent = title + " (" + patterns[key].length + ")";
985
+ box.appendChild(head);
986
+ for (const item of patterns[key]) {
987
+ const row = document.createElement("div");
988
+ row.className = "provider";
989
+ const code = document.createElement("code");
990
+ code.textContent = item.tool + ": " + item.pattern;
991
+ const why = document.createElement("p");
992
+ const seen = item.history_total ? " Would have blocked " + item.history_matches + " of " + item.history_total + " logged " + item.tool + " calls." : "";
993
+ why.textContent = item.reason + seen;
994
+ const example = document.createElement("p");
995
+ example.className = "hint";
996
+ example.textContent = "Example: " + item.example;
997
+ row.append(code, why, example);
998
+ const buttons = document.createElement("div");
999
+ buttons.className = "actions";
1000
+ for (const [decision, label] of actions) {
1001
+ const button = document.createElement("button");
1002
+ button.type = "button";
1003
+ button.textContent = label;
1004
+ button.addEventListener("click", async () => {
1005
+ try {
1006
+ renderPatterns((await post("/setup/integration", { action: "block_pattern", id: item.id, decision })).block_patterns);
1007
+ setMsg("agents-msg", "ok", label + ": " + item.pattern);
1008
+ } catch (error) {
1009
+ setMsg("agents-msg", "error", error.message);
1010
+ }
1011
+ });
1012
+ buttons.appendChild(button);
1013
+ }
1014
+ row.appendChild(buttons);
1015
+ box.appendChild(row);
1016
+ }
1017
+ }
1018
+ if (patterns.rejected) { const p = document.createElement("p"); p.className = "hint"; p.textContent = patterns.rejected + " rejected proposal(s) are not offered again."; box.appendChild(p); }
1019
+ }
947
1020
  loadAgents();
1021
+ $("btn-save-pattern-proposals").addEventListener("click", async () => {
1022
+ try {
1023
+ await post("/setup/integration", { action: "pattern_proposals", mode: $("sel-pattern-proposals").value });
1024
+ setMsg("agents-msg", "ok", "Proposals setting saved.");
1025
+ } catch (error) {
1026
+ setMsg("agents-msg", "error", error.message);
1027
+ }
1028
+ });
948
1029
  $("btn-save-doc-mode").addEventListener("click", async () => {
949
1030
  try {
950
1031
  await post("/setup/integration", { action: "doc_mode", mode: $("sel-doc-mode").value });
@@ -169,6 +169,10 @@ _jobs = {}
169
169
  _JOBS_LOCK = threading.Lock()
170
170
  _MAX_JOBS_RETAINED = 200
171
171
  SMART_SEARCH_SYNC_WAIT_S = 3
172
+ # smart_search_result waits for the job this long before answering pending: answering at once had an agent poll the
173
+ # same job 7 times in 16 s and then give up on Smart Tool (claude -p session, 2026-10-09; job durations median 6 s,
174
+ # p75 10.7 s).
175
+ SMART_SEARCH_RESULT_WAIT_S = 15
172
176
  SMART_SEARCH_MAX_PENDING = 8
173
177
  SMART_SEARCH_EXECUTOR = project_scheduler.ProjectScheduler(workers=3)
174
178
  _PROJECT_LOCKS = {}
@@ -305,8 +309,8 @@ def _smart_search_job_payload(job_id):
305
309
  ALWAYS_LOAD = {"anthropic/alwaysLoad": True}
306
310
  SERVER_INSTRUCTIONS = (
307
311
  "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 "
312
+ "a PreToolUse hook that blocks content searches over a project folder (grep -r, rg, git grep, Grep on a folder) "
313
+ "and native WebFetch/WebSearch. A call blocked by Smart Tool is a routing rule, not a failure: make the call the block "
310
314
  "message names (smart_search with project_root and query_identifiers, web_fetch with url and prompt, web_search "
311
315
  "with query). Never retry the same search or download through Bash, python, node, PowerShell, curl or wget: that "
312
316
  "bypasses the rule the user set. Reading one known file, or searching inside one known file, is never blocked. "
@@ -807,6 +811,11 @@ def _prepare_project_index(arguments, job_id, cfg, token, query_vector=None, pre
807
811
  checkpoint()
808
812
  _update_job(job_id, phase="scope")
809
813
  scope = index_scope.load_scope(root)
814
+ if scope is None and not arguments.get("force_scope"):
815
+ inherited = index_scope.sibling_scope(root)
816
+ if inherited is not None and not index_scope.needs_rescan(root, inherited):
817
+ index_scope.save_scope(root, inherited)
818
+ scope = inherited
810
819
  scope_model = cfg.get("scope_model") or cfg.get("router_model")
811
820
  if arguments.get("force_scope") or (project.get('scope_dirty') and not (scope or {}).get('manual')) or scope is None or index_scope.needs_rescan(root, scope):
812
821
  problem = _gateway_problem() if scope_model else None
@@ -1342,6 +1351,10 @@ def _handle_smart_search_result(arguments):
1342
1351
  if not job_id:
1343
1352
  raise ValueError("smart_search_result requires 'job_id'")
1344
1353
  payload = _smart_search_job_payload(job_id)
1354
+ deadline = time.monotonic() + SMART_SEARCH_RESULT_WAIT_S
1355
+ while payload["status"] == "pending" and time.monotonic() < deadline:
1356
+ time.sleep(0.25)
1357
+ payload = _smart_search_job_payload(job_id)
1345
1358
  _mark_notice_delivered(job_id)
1346
1359
  return _to_json_sanitized(payload)
1347
1360
 
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.6"
4
+ VERSION = "0.9.8"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7