@allansantos-dev/smart-tool 0.9.5 → 0.9.7

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/router.py CHANGED
@@ -1,235 +1,27 @@
1
1
  #!/usr/bin/env python3
2
- """Roteador de tool calls do Smart Tool. V1 não tem classificador mecânico de "tem
3
- escopo": toda chamada de tool nativa casada pelo hook passa por aqui, que pergunta a um
4
- modelo pequeno se deve deixar passar ou redirecionar pra `smart_search`. Cada decisão é
5
- logada em `router-metrics.jsonl` pra orientar um classificador mais fino depois, com
6
- base em uso real — não numa suposição a priori.
7
- """
2
+ """Routing of the native tool calls the PreToolUse hook matches (Grep, Glob, Bash, Read). Deterministic: calls that
3
+ neither search nor read code, or that change files, run; accepted block patterns redirect; the rest follows
4
+ redirect_rule. Searches the rule lets run are reviewed in the background by the router model, which may propose new
5
+ block patterns (block_patterns). Every decision is logged in router-metrics.jsonl. Also holds the router-model
6
+ helpers of the web search (tier order and result assessment)."""
8
7
  import contextvars
9
8
  import json
10
9
  import os
11
10
  import paths
12
11
  import re
12
+ import sys
13
13
  import time
14
14
 
15
15
  import atomic_io
16
+ import block_patterns
16
17
  import model_client
17
- import index_scope
18
- import indexer
18
+ import redirect_rule
19
19
  import web_search_adapters
20
20
 
21
21
  # Per request: the daemon serves each hook call on its own thread, so a module global mixed up clients.
22
22
  CLIENT = contextvars.ContextVar("router_client", default=None)
23
23
  METRICS_PATH = os.path.join(paths.DATA_DIR, "router-metrics.jsonl")
24
24
 
25
- _SYSTEM_PROMPT = (
26
- "You decide whether a tool call (native: Grep, Glob, Read, or Bash running a "
27
- "search/read command such as grep/find/cat/ls -R/rg) should execute directly or "
28
- "be redirected to an indexed semantic search (smart_search), because it is "
29
- "potentially expensive or imprecise (e.g. no path filter, a large directory like "
30
- "node_modules, an unscoped recursive command, or a question better answered by "
31
- "semantic search than by regex/raw listing). For Bash, judge by the command's "
32
- "content (the tool_input 'command' field): only consider redirecting commands "
33
- "whose purpose is to search/read source code — never redirect commands with any "
34
- "other effect (build, git, install, running a script, etc.), even if grep/cat/find "
35
- "appear mid-pipeline.\n\n"
36
- "The 'target' object is measured on disk right now — prefer it over any guess you "
37
- "would make from the path string alone:\n"
38
- "- target.files / target.truncated: files found under the target, ignoring the "
39
- "directories listed in target.heavy_dirs and target.ignored_by_default. 'truncated': "
40
- "true means the scan hit its budget, so the tree is big.\n"
41
- "- target.heavy_dirs: dependency/build trees present under the target (node_modules, "
42
- ".venv, dist...). A raw recursive search may reach them, unless the project's ignore "
43
- "rules already exclude them.\n"
44
- "- target.ignored_by_default: directories the native search tool skips on its own "
45
- "(.git, __pycache__, dot-directories). Their presence is NOT a cost signal.\n"
46
- "- target.scope: present when the target could not be derived from the call itself, "
47
- "so the measurement describes the session directory and not what the command reads.\n"
48
- "- target.outside_project: the target is outside the project root; no index covers "
49
- "it, so never redirect.\n"
50
- "- target.indexed: whether a semantic index already exists for this project. When it "
51
- "is false, redirecting forces a cold index build inside the call (embedding every "
52
- "file, tens of seconds) — only worth it when the search really is broad.\n"
53
- "- target.missing: true when the path does not exist; never redirect those, the "
54
- "native tool's own error is the useful answer.\n\n"
55
- "Reading one specific file is never redirected. When in doubt, answer 'allow'. "
56
- "Call submit_decision with your answer."
57
- )
58
-
59
- # Toda chamada de Grep/Glob/Read/Bash do agente espera por esta decisão, e o desfecho de
60
- # falha é `allow` — então esperar mais só atrasa o agente sem mudar o resultado. O valor
61
- # pressupõe `router_model` sem cadeia de raciocínio: um modelo "thinking" gasta mais de mil
62
- # tokens numa decisão binária e estoura isto sempre, abrindo o breaker e deixando o
63
- # roteamento desligado de forma permanente.
64
- DECISION_TIMEOUT_S = 8
65
-
66
- _SCAN_MAX_FILES = 4000
67
- _SCAN_MAX_SECONDS = 0.25
68
- # Percorridos pela tool nativa: a presença deles é sinal de custo de verdade.
69
- _HEAVY_DIR_NAMES = frozenset({
70
- "node_modules", ".venv", "venv", "vendor", "target", "dist", "build", "out",
71
- ".next", ".nuxt", ".gradle", "Pods",
72
- })
73
- # Grep/Glob do Claude Code são ripgrep, que já pula estes por padrão — listá-los junto
74
- # dos de cima superestimaria o custo e enviesaria a decisão pra `redirect`.
75
- _IGNORED_BY_DEFAULT_DIRS = frozenset({".git", "__pycache__", ".mypy_cache", ".pytest_cache"})
76
- _PATH_FIELDS = ("path", "file_path", "notebook_path")
77
-
78
-
79
- def _resolve_path(raw, cwd):
80
- """Path do `tool_input` normalizado para o disco. Relativo resolve contra o `cwd` do
81
- payload (não o do processo do hook, que é outro), e a forma MSYS `/c/...` — que o
82
- agente emite com frequência em shell POSIX no Windows — é convertida, senão
83
- `os.path.exists` responde `False` sobre um arquivo que existe."""
84
- if not raw:
85
- return None
86
- path = raw.strip().replace("\\", "/")
87
- msys = re.fullmatch(r"/([A-Za-z])(/.*)?", path)
88
- if msys:
89
- path = f"{msys.group(1).upper()}:{msys.group(2) or '/'}"
90
- if not os.path.isabs(path) and cwd:
91
- path = os.path.join(cwd, path)
92
- return os.path.normpath(path)
93
-
94
-
95
- def _target_path(tool_input, cwd):
96
- for field in _PATH_FIELDS:
97
- value = tool_input.get(field)
98
- if isinstance(value, str) and value.strip():
99
- return value
100
- return None
101
-
102
-
103
- def _command_paths(command, cwd):
104
- """Paths que aparecem como argumento do comando e existem em disco. Sem isto, um
105
- `grep -n foo arquivo.py` num monorepo seria medido como se varresse o repo todo."""
106
- found = []
107
- previous, base = "", cwd
108
- for token in re.findall(r'"([^"]+)"|\'([^\']+)\'|(\S+)', command or ""):
109
- raw = next((t for t in token if t), "")
110
- after_cd, previous = previous.lower() in ("cd", "pushd", "set-location", "sl"), raw
111
- if after_cd:
112
- # The directory of a `cd` is where the command runs (base for what follows), not what it searches.
113
- base = _resolve_path(raw, base) or base
114
- continue
115
- if not raw or raw.startswith("-"):
116
- continue
117
- resolved = _resolve_path(raw, base)
118
- if resolved and os.path.exists(resolved):
119
- found.append(resolved)
120
- return found
121
-
122
-
123
- def _scan_target(path):
124
- files = 0
125
- heavy = set()
126
- ignored = set()
127
- truncated = False
128
- deadline = time.monotonic() + _SCAN_MAX_SECONDS
129
- for dirpath, dirnames, filenames in os.walk(path):
130
- for name in list(dirnames):
131
- if name in _HEAVY_DIR_NAMES:
132
- heavy.add(name)
133
- dirnames.remove(name)
134
- elif name in _IGNORED_BY_DEFAULT_DIRS or name.startswith("."):
135
- ignored.add(name)
136
- dirnames.remove(name)
137
- for _ in filenames:
138
- files += 1
139
- # Testar o orçamento só entre diretórios deixaria um único diretório com
140
- # centenas de milhares de entradas passar por cima dele.
141
- if files > _SCAN_MAX_FILES or time.monotonic() > deadline:
142
- return {"files": files, "heavy_dirs": sorted(heavy),
143
- "ignored_by_default": sorted(ignored), "truncated": True}
144
- if time.monotonic() > deadline:
145
- truncated = True
146
- break
147
- return {"files": files, "heavy_dirs": sorted(heavy),
148
- "ignored_by_default": sorted(ignored), "truncated": truncated}
149
-
150
-
151
- def _index_facts(project_root):
152
- try:
153
- # A identidade é compartilhada; índices legados continuam visíveis até a migração.
154
- db = indexer.existing_db_path(project_root)
155
- if db and os.path.isfile(db):
156
- return {"indexed": True, "index_kib": os.path.getsize(db) // 1024}
157
- return {"indexed": False}
158
- except OSError:
159
- return {"indexed": False}
160
-
161
-
162
- def _target_facts(tool_name, tool_input, cwd):
163
- """Fatos medidos em disco sobre o alvo da chamada. Roda antes de toda tool call
164
- casada pelo hook, então é limitado por tempo e por contagem, não desce em diretório
165
- pesado e nunca sai da raiz do projeto. Sem isto o modelo julga custo só pelo texto do
166
- path."""
167
- raw = _target_path(tool_input, cwd)
168
- if raw is None and tool_name == "Bash":
169
- candidates = _command_paths(tool_input.get("command"), cwd)
170
- if candidates:
171
- raw = candidates[0]
172
- scope_note = None
173
- if raw is None:
174
- raw, scope_note = cwd, "session cwd, not derived from the command"
175
- path = _resolve_path(raw, cwd)
176
- if not path:
177
- return {}
178
- facts = {"path": _display_path(path, cwd)}
179
- if scope_note:
180
- facts["scope"] = scope_note
181
- # Fora da raiz do projeto nada é medido: varrer `C:\` ou o perfil do usuário antes de
182
- # uma tool call custaria mais que a própria chamada, e a decisão seria `allow` igual.
183
- if cwd and not index_scope._within_root(cwd, path):
184
- facts["outside_project"] = True
185
- return facts
186
- try:
187
- if os.path.isfile(path):
188
- facts["is_file"] = True
189
- facts["kib"] = os.path.getsize(path) // 1024
190
- return facts
191
- if not os.path.isdir(path):
192
- # Só afirma ausência quando o path é inequívoco; adivinhar `missing` faria o
193
- # pré-filtro liberar a chamada com um motivo falso no dataset de calibração.
194
- facts["missing"] = True
195
- return facts
196
- facts.update(_scan_target(path))
197
- except OSError as exc:
198
- facts["scan_error"] = str(exc)
199
- return facts
200
- facts.update(_index_facts(cwd or path))
201
- return facts
202
-
203
-
204
- def _display_path(path, cwd):
205
- """Relativo ao projeto quando possível: o path absoluto carrega o nome do usuário do
206
- Windows e vai inteiro no prompt enviado ao gateway."""
207
- if cwd:
208
- try:
209
- rel = os.path.relpath(path, cwd)
210
- if not rel.startswith(".."):
211
- return rel.replace(os.sep, "/")
212
- except ValueError:
213
- pass
214
- return os.path.basename(path) or path
215
-
216
- _DECISION_TOOL = {
217
- "type": "function",
218
- "function": {
219
- "name": "submit_decision",
220
- "description": "Submit the routing decision for this tool call.",
221
- "parameters": {
222
- "type": "object",
223
- "properties": {
224
- "decision": {"type": "string", "enum": ["allow", "redirect"]},
225
- "reason": {"type": "string", "description": "Short justification, one sentence."},
226
- },
227
- "required": ["decision", "reason"],
228
- },
229
- },
230
- }
231
-
232
-
233
25
  METRICS_MAX_BYTES = 4 * 1024 * 1024
234
26
  _METRIC_MAX_FIELD_CHARS = 600
235
27
  _SECRET_ARG_RE = re.compile(
@@ -303,16 +95,27 @@ _SEARCH_VERB_RE = re.compile(
303
95
  )
304
96
  # Commands that read code or pages without a search verb at their start: their pipe filters still search content.
305
97
  _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|"
98
+ r"\bgit\s+(?:-C\s+\S+\s+)?(grep|ls-files|show|log\s+-p|diff)\b|\b(curl|wget|iwr|invoke-webrequest)\b|\bgh\s+api\b|"
307
99
  r"readFileSync|read_text\(|open\(",
308
100
  re.IGNORECASE,
309
101
  )
310
102
  _READ_TOOLS = frozenset({"Read", "NotebookRead"})
103
+ # Commands that change files, the repository or dependencies are never redirected (smart_search cannot do the change).
104
+ # Verbs are matched outside quoted strings, so a grep pattern like "pip install" is not one; file writes inside an
105
+ # inline script (node -e, python -c) are matched anywhere.
106
+ _QUOTED_RE = re.compile(r"""'[^']*'|"(?:\\.|[^"\\])*\"""")
107
+ _MUTATING_VERB_RE = re.compile(
108
+ r"(?:^|[;&|(]|\s)(?:rm|mv|cp|mkdir|rmdir|touch|chmod|tee)\s"
109
+ r"|\bgit\s+(?:-C\s+\S+\s+)?(?:checkout|commit|add|push|pull|reset|stash|restore|rm|mv|merge|rebase|apply|tag|clean|"
110
+ r"clone|switch|cherry-pick)\b|\bsed\s+-i"
111
+ r"|\b(?:npm|pnpm|yarn|pip|uv)\s+(?:install|i|add|run|publish|ci|test)\b"
112
+ )
113
+ _FILE_WRITE_RE = re.compile(r"writeFileSync|writeFile\(|write_text\(|Set-Content|Out-File")
311
114
 
312
- BREAKER_PATH = os.path.join(os.path.dirname(METRICS_PATH), "router-breaker.json")
313
- BREAKER_FAILURES = 3
314
- BREAKER_COOLDOWN_S = 300
315
115
 
116
+ def _mutates(command):
117
+ """Whether a shell command changes files, the repository or dependencies."""
118
+ return bool(_FILE_WRITE_RE.search(command) or _MUTATING_VERB_RE.search(_QUOTED_RE.sub("''", command)))
316
119
 
317
120
  def _mechanical_decision(tool_name, tool_input):
318
121
  """`(decisão, motivo)` quando a resposta não depende de julgamento nem de tocar o
@@ -324,7 +127,9 @@ def _mechanical_decision(tool_name, tool_input):
324
127
  `offset`/`limit` explícitos, onde só as linhas pedidas servem). Remedido em 2026-10-08
325
128
  sobre 2488 Bash que foram ao modelo (mediana 1,5 s, 7,8% redirecionados): ignorar os
326
129
  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."""
130
+ sem modelo (13,3 min em 58 h) e perde 1 dos 155 redirecionamentos. Remedido em 2026-10-09 sobre 3294 Bash decididos
131
+ pelo modelo: comandos que alteram arquivos, o repositório ou dependências foram 12 dos 283 redirecionamentos (todos
132
+ errados: o agente contornava o bloqueio) e 566 liberações."""
328
133
  if tool_name in _READ_TOOLS:
329
134
  return "allow", "Reading a specific file is never replaced by semantic search."
330
135
  if tool_name == "Bash":
@@ -333,144 +138,38 @@ def _mechanical_decision(tool_name, tool_input):
333
138
  return "allow", "Bash without a readable command."
334
139
  if not _SEARCH_VERB_RE.search(command) and not _READ_PRODUCER_RE.search(command):
335
140
  return "allow", "The command neither searches nor reads source code."
141
+ if _mutates(command):
142
+ return "allow", "The command changes files, the repository or dependencies."
336
143
  return None
337
144
 
338
145
 
339
- def _mechanical_decision_from_facts(facts):
340
- if facts.get("missing"):
341
- return "allow", "The path does not exist: the native tool's error is the useful answer."
342
- if facts.get("is_file"):
343
- return "allow", "Target is a single file, already scoped."
344
- if facts.get("outside_project"):
345
- return "allow", "Target is outside the project root: no index covers it."
346
- return None
347
-
348
-
349
- def _breaker_state():
350
- try:
351
- with open(BREAKER_PATH, "r", encoding="utf-8") as f:
352
- state = json.load(f)
353
- return state if isinstance(state, dict) else {}
354
- except (OSError, json.JSONDecodeError):
355
- return {}
356
-
357
-
358
- def _breaker_write(state):
359
- # tmp + replace: processos concorrentes gravam aqui (cada tool call é um processo
360
- # novo), e um `open("w")` truncado no meio faz o leitor ver arquivo vazio — ou seja,
361
- # breaker fechado justo quando ele deveria estar aberto. Sem `fsync`: perder a última
362
- # escrita aqui é inofensivo e este caminho roda antes de cada tool call.
363
- try:
364
- os.makedirs(os.path.dirname(BREAKER_PATH), exist_ok=True)
365
- tmp_path = f"{BREAKER_PATH}.tmp.{os.getpid()}"
366
- with open(tmp_path, "w", encoding="utf-8") as f:
367
- json.dump(state, f)
368
- os.replace(tmp_path, BREAKER_PATH)
369
- except OSError:
370
- pass
371
-
372
-
373
- def breaker_open_until():
374
- """Instante até quando o roteador está desligado por falhas recentes, ou 0.
375
-
376
- Gateway lento é o pior caso de custo: sem isto, cada tool call paga o timeout inteiro
377
- para terminar em `allow` de qualquer forma. O estado é compartilhado em arquivo porque
378
- cada chamada do hook é um processo novo — nada sobrevive em memória."""
379
- now = time.time()
380
- try:
381
- until = float(_breaker_state().get("open_until") or 0)
382
- except (TypeError, ValueError):
383
- return 0
384
- # Teto pelo próprio cooldown: um salto de relógio (resume de VM, correção de fuso) no
385
- # instante da gravação deixaria um `open_until` no futuro distante, e o roteador
386
- # ficaria desligado pra sempre — sem erro em lugar nenhum, porque com o breaker aberto
387
- # nunca há sucesso pra reabrir.
388
- if until - now > BREAKER_COOLDOWN_S:
389
- return 0
390
- return until if now < until else 0
391
-
392
-
393
- def note_failure():
394
- state = _breaker_state()
395
- now = time.time()
396
- # Janela de tempo em vez de contador: cada hook é um processo, e três falhas
397
- # simultâneas leriam todas `failures=0` e gravariam `1`, nunca alcançando o limite.
398
- # Falha de semana passada também não pode somar com a de hoje.
399
- recent = [t for t in state.get("failures", []) if isinstance(t, (int, float)) and now - t < BREAKER_COOLDOWN_S]
400
- recent.append(now)
401
- state["failures"] = recent[-BREAKER_FAILURES:]
402
- if len(recent) >= BREAKER_FAILURES:
403
- state["open_until"] = now + BREAKER_COOLDOWN_S
404
- state["failures"] = []
405
- _breaker_write(state)
406
-
146
+ def _log_error(message):
147
+ print(f"[smart-tool] {message}", file=sys.stderr, flush=True)
407
148
 
408
- def note_success():
409
- state = _breaker_state()
410
- if not state:
411
- return
412
- # Um cooldown aberto por outro processo sobrevive: quem leu o estado antes da abertura
413
- # e teve sucesso não pode apagar a decisão de quem já viu o gateway falhar 3 vezes.
414
- open_until = breaker_open_until()
415
- _breaker_write({"open_until": open_until} if open_until else {})
416
149
 
417
-
418
- def decide(tool_name, tool_input, router_model, cwd=None):
150
+ def decide(tool_name, tool_input, cwd=None):
151
+ """(decision, reason, target) for one hook call: decision is redirect or allow, target the folder the redirected
152
+ search would read (the session folder for an accepted pattern, None when allowed). An allowed search is queued for
153
+ the router model's review, which never delays the call."""
419
154
  safe_input = tool_input if isinstance(tool_input, dict) else {}
420
-
421
- # Ordem importa: tudo que decide sem tocar disco vem antes do snapshot, e o breaker
422
- # vem antes dele também — em cooldown o desfecho é `allow` de qualquer forma, então
423
- # percorrer diretório ali seria I/O puro por nada.
424
155
  mechanical = _mechanical_decision(tool_name, safe_input)
425
156
  if mechanical:
426
157
  decision, reason = mechanical
427
158
  _log_decision(tool_name, tool_input, decision, reason, extra={"mechanical": True})
428
- return decision, reason
429
-
430
- if breaker_open_until():
431
- reason = "Router in cooldown after recent gateway failures."
432
- _log_decision(tool_name, tool_input, "allow", reason, extra={"breaker": True})
433
- return "allow", reason
434
-
435
- facts = _target_facts(tool_name, safe_input, cwd)
436
- mechanical = _mechanical_decision_from_facts(facts)
437
- if mechanical:
438
- decision, reason = mechanical
439
- _log_decision(tool_name, tool_input, decision, reason, extra={"mechanical": True})
440
- return decision, reason
441
-
442
- started = time.monotonic()
443
- try:
444
- # O orçamento inteiro do hook no host é de poucos segundos: obter token não pode
445
- # consumir mais que a própria decisão.
446
- token = model_client.get_token()
447
- response = model_client.fetch(
448
- "/v1/chat/completions", token, method="POST", timeout=DECISION_TIMEOUT_S,
449
- body={
450
- "model": router_model,
451
- "messages": [
452
- {"role": "system", "content": _SYSTEM_PROMPT},
453
- {"role": "user", "content": json.dumps({
454
- # Mesma redação do log: a linha de comando também vai pro gateway.
455
- "tool_name": tool_name, "tool_input": _redacted_input(safe_input),
456
- "target": facts,
457
- }, ensure_ascii=False)},
458
- ],
459
- "tools": [_DECISION_TOOL],
460
- "tool_choice": {"type": "function", "function": {"name": "submit_decision"}},
461
- "temperature": 0,
462
- },
463
- )
464
- tool_call = response["choices"][0]["message"]["tool_calls"][0]
465
- args = json.loads(tool_call["function"]["arguments"])
466
- except Exception:
467
- note_failure()
468
- raise
469
- note_success()
470
- decision = args.get("decision", "allow")
471
- reason = args.get("reason", "")
472
- _log_decision(tool_name, tool_input, decision, reason, extra={"ms": round((time.monotonic() - started) * 1000)})
473
- return decision, reason
159
+ return decision, reason, None
160
+ learned = block_patterns.matching(tool_name, safe_input)
161
+ if learned:
162
+ reason = f"accepted block pattern: {learned['reason']}"
163
+ _log_decision(tool_name, tool_input, "redirect", reason, extra={"pattern": learned["id"]})
164
+ return "redirect", reason, cwd
165
+ decision, target, reason = redirect_rule.decide(tool_name, safe_input, cwd)
166
+ _log_decision(tool_name, tool_input, decision, reason, extra={"rule": True})
167
+ if decision == "allow":
168
+ try:
169
+ block_patterns.review_later(tool_name, safe_input, reason, METRICS_PATH, _redacted_input, _log_error)
170
+ except (OSError, ValueError) as exc:
171
+ _log_error(f"block pattern review not queued: {exc}")
172
+ return decision, reason, target
474
173
 
475
174
 
476
175
  def _web_search_tier_system_prompt():
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 });