@allansantos-dev/smart-tool 0.9.6 → 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.
@@ -0,0 +1,232 @@
1
+ """Deterministic redirect rule of the PreToolUse hook for native code searches (Grep, Glob, Bash).
2
+
3
+ A content search over a directory of a project (grep -r, rg, git grep, the Grep tool on a folder) or over more than
4
+ LISTED_MAX files goes to smart_search; reading listed files, listings, Glob and commands that change files run. Only
5
+ the part of a Bash command that searches is measured, with its own paths: `cd` is carried, `$VAR` set in the command or
6
+ in the environment, `~` and globs are expanded, and a path still holding an unresolved variable is left alone.
7
+
8
+ Measured on 2026-10-09 against 2,789 real decisions of the LLM router it replaced (session cwd recovered from the
9
+ transcripts): the model redirected 315, this rule 463 (134 shared); in a sample of 40 redirected by the rule alone, 40
10
+ were content searches over a folder or many files; in a sample of 40 redirected by the model alone, ~36 were wrong
11
+ (one file, `node -e` over one file, `git show`, listings, commands changing files). Median 0.2 ms against 1.5 s.
12
+ """
13
+ import glob
14
+ import os
15
+ import re
16
+ import shlex
17
+ import time
18
+
19
+ import index_scope
20
+ import project_store
21
+
22
+ LISTED_MAX = 20
23
+ SCAN_MAX_FILES = 4000
24
+ SCAN_MAX_SECONDS = 0.25
25
+ # Trees the native tools walk into: their presence is part of the cost, they are counted.
26
+ HEAVY_DIRS = frozenset({"node_modules", ".venv", "venv", "vendor", "target", "dist", "build", "out", ".next", ".nuxt",
27
+ ".gradle", "Pods"})
28
+ SEARCHERS = frozenset({"grep", "egrep", "fgrep", "rg", "ripgrep", "ack", "ag", "findstr", "select-string", "sls"})
29
+ ALWAYS_RECURSIVE = frozenset({"rg", "ripgrep", "ack", "ag"})
30
+ LISTERS = frozenset({"find", "fd", "ls", "dir", "tree", "get-childitem", "gci"})
31
+ CD_VERBS = frozenset({"cd", "pushd", "set-location", "sl"})
32
+ PATTERN_FLAGS = frozenset({"-e", "--regexp", "-f", "--file"})
33
+ VALUE_FLAGS = frozenset({"-A", "-B", "-C", "-m", "--max-count", "--include", "--exclude", "--exclude-dir", "-g",
34
+ "--glob", "-t", "--type", "-T", "--type-not", "--max-depth", "-d", "--context",
35
+ "--after-context", "--before-context"})
36
+ RECURSIVE_FLAGS = frozenset({"-r", "-R", "--recursive", "-Recurse", "/s", "/S"})
37
+ _ASSIGN = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
38
+ _VARIABLE = re.compile(r"\$\{(\w+)\}|\$(\w+)")
39
+
40
+
41
+ def resolve_path(raw, base):
42
+ """Path normalized for the disk: relative to base, and the MSYS `/c/...` form agents write in a POSIX shell on
43
+ Windows turned into `C:/...`."""
44
+ if not raw:
45
+ return None
46
+ path = raw.strip().replace("\\", "/")
47
+ msys = re.fullmatch(r"/([A-Za-z])(/.*)?", path)
48
+ if msys:
49
+ path = f"{msys.group(1).upper()}:{msys.group(2) or '/'}"
50
+ if not os.path.isabs(path) and base:
51
+ path = os.path.join(base, path)
52
+ return os.path.normpath(path)
53
+
54
+
55
+ def count_files(path):
56
+ """(files, truncated) under a directory, skipping dot-directories, bounded by count and time."""
57
+ files, deadline = 0, time.monotonic() + SCAN_MAX_SECONDS
58
+ for _dirpath, dirnames, filenames in os.walk(path):
59
+ dirnames[:] = [d for d in dirnames if not d.startswith(".") and d != "__pycache__"]
60
+ files += len(filenames)
61
+ if files > SCAN_MAX_FILES or time.monotonic() > deadline:
62
+ return files, True
63
+ return files, False
64
+
65
+
66
+ def _in_project(target, cwd):
67
+ """A registered project holds target, or target is inside the session's directory."""
68
+ key = os.path.normcase(os.path.abspath(target)) + os.sep
69
+ if any(key.startswith(os.path.normcase(os.path.abspath(p["root"])).rstrip(os.sep) + os.sep)
70
+ for p in project_store.all_projects()):
71
+ return True
72
+ return bool(cwd) and index_scope._within_root(cwd, target)
73
+
74
+
75
+ def _has_files(target):
76
+ """Any file under the folder: the size limit measured made no difference (0 files: 463 redirects, 50: 400), and a
77
+ missed redirect costs more context than a needless one."""
78
+ files, truncated = count_files(target)
79
+ return truncated or files > 0
80
+
81
+
82
+ def segments(command):
83
+ """(text, after_single_pipe) of each part of a shell command split on ; && || | and newlines outside quotes."""
84
+ parts, current, quote, i, after_pipe = [], [], None, 0, False
85
+ while i < len(command):
86
+ ch = command[i]
87
+ if quote:
88
+ current.append(ch)
89
+ quote = None if ch == quote else quote
90
+ elif ch in "'\"":
91
+ quote = ch
92
+ current.append(ch)
93
+ elif command.startswith(("&&", "||"), i):
94
+ parts.append(("".join(current), after_pipe))
95
+ current, after_pipe = [], False
96
+ i += 2
97
+ continue
98
+ elif ch in ";\n|":
99
+ parts.append(("".join(current), after_pipe))
100
+ current, after_pipe = [], ch == "|"
101
+ else:
102
+ current.append(ch)
103
+ i += 1
104
+ parts.append(("".join(current), after_pipe))
105
+ return [(text.strip(), pipe) for text, pipe in parts if text.strip()]
106
+
107
+
108
+ def _expand(token, variables):
109
+ def substitute(match):
110
+ name = match.group(1) or match.group(2)
111
+ return variables.get(name, os.environ.get(name, match.group(0)))
112
+ return os.path.expanduser(_VARIABLE.sub(substitute, token))
113
+
114
+
115
+ def _paths(raw, base):
116
+ """Existing paths of a token (globs expanded), or None when it still holds an unresolved variable."""
117
+ if "$" in raw or "`" in raw:
118
+ return None
119
+ path = resolve_path(raw, base)
120
+ if any(ch in raw for ch in "*?["):
121
+ return [os.path.normpath(p) for p in glob.glob(path)]
122
+ return [path] if path and os.path.exists(path) else []
123
+
124
+
125
+ def _search_args(verb, tokens):
126
+ """Path arguments of a grep-like command (the pattern and flag values dropped) and whether it is recursive."""
127
+ recursive = verb in ALWAYS_RECURSIVE or any(
128
+ t in RECURSIVE_FLAGS or (re.fullmatch(r"-[a-zA-Z]+", t) and ("r" in t or "R" in t)) for t in tokens)
129
+ args, skip, pattern_given = [], False, False
130
+ for token in tokens:
131
+ if skip:
132
+ skip = False
133
+ elif token in PATTERN_FLAGS:
134
+ skip, pattern_given = True, True
135
+ elif token in VALUE_FLAGS:
136
+ skip = True
137
+ elif not token.startswith("-") and not (verb == "findstr" and token.startswith("/")):
138
+ args.append(token)
139
+ return (args if pattern_given else args[1:]), recursive
140
+
141
+
142
+ def _bash(command, cwd):
143
+ base, variables, verdict = cwd, {}, ("allow", "No content search over a folder.")
144
+ for text, after_pipe in segments(command):
145
+ try:
146
+ tokens = shlex.split(text, posix=True)
147
+ except ValueError:
148
+ tokens = text.split()
149
+ while tokens and _ASSIGN.match(tokens[0]):
150
+ name, value = _ASSIGN.match(tokens[0]).groups()
151
+ variables[name] = _expand(value, variables)
152
+ tokens = tokens[1:]
153
+ if not tokens:
154
+ continue
155
+ verb = tokens[0].lower()
156
+ if verb in CD_VERBS and len(tokens) > 1:
157
+ base = resolve_path(_expand(tokens[1], variables), base) or base
158
+ continue
159
+ xargs = verb == "xargs" and len(tokens) > 1 and tokens[1].lower() in SEARCHERS
160
+ if after_pipe and not xargs:
161
+ continue
162
+ if xargs:
163
+ if base and os.path.isdir(base) and _in_project(base, cwd) and _has_files(base):
164
+ return "redirect", base, "content search over the files of a listing"
165
+ continue
166
+ if verb == "git":
167
+ rest, repo = tokens[1:], base
168
+ while len(rest) > 1 and rest[0] == "-C":
169
+ repo = resolve_path(_expand(rest[1], variables), repo) or repo
170
+ rest = rest[2:]
171
+ if rest and rest[0] == "grep":
172
+ specs = rest[rest.index("--") + 1:] if "--" in rest else []
173
+ found = [_paths(_expand(raw, variables), repo) for raw in specs]
174
+ only_files = specs and all(f and all(os.path.isfile(p) for p in f) for f in found)
175
+ listed = sum(len(f) for f in found if f) if only_files else 0
176
+ if (not only_files or listed > LISTED_MAX) and repo and os.path.isdir(repo) and \
177
+ _in_project(repo, cwd) and _has_files(repo):
178
+ return "redirect", repo, "git grep over the repository"
179
+ continue
180
+ if verb in LISTERS:
181
+ recursive = verb in ("find", "fd", "tree") or "-recurse" in (t.lower() for t in tokens) or any(
182
+ re.fullmatch(r"-[a-zA-Z]*R[a-zA-Z]*", t) for t in tokens[1:])
183
+ first = next((t for t in tokens[1:] if not t.startswith("-")), ".")
184
+ for target in (_paths(_expand(first, variables), base) or []) if recursive else []:
185
+ if os.path.isdir(target) and _in_project(target, cwd) and count_files(target)[1]:
186
+ return "redirect", target, "recursive listing of a very large tree"
187
+ continue
188
+ if verb not in SEARCHERS:
189
+ continue
190
+ args, recursive = _search_args(verb, tokens[1:])
191
+ if not args:
192
+ if recursive and base and os.path.isdir(base) and _in_project(base, cwd) and _has_files(base):
193
+ return "redirect", base, "recursive content search of the working folder"
194
+ continue
195
+ listed = 0
196
+ for raw in args:
197
+ found = _paths(_expand(raw, variables), base)
198
+ if found is None:
199
+ verdict = ("allow", "The searched path uses a variable the hook cannot resolve.")
200
+ continue
201
+ for target in found:
202
+ if os.path.isdir(target):
203
+ if recursive and _in_project(target, cwd) and _has_files(target):
204
+ return "redirect", target, "recursive content search over a folder"
205
+ else:
206
+ listed += 1
207
+ if listed > LISTED_MAX:
208
+ return "redirect", base, f"content search over {listed} files"
209
+ if listed:
210
+ verdict = ("allow", "Search inside listed files.")
211
+ return verdict[0], None, verdict[1]
212
+
213
+
214
+ def decide(tool_name, tool_input, cwd):
215
+ """(decision, target, reason) for a Grep, Glob or Bash call: decision is redirect or allow, target the folder that
216
+ would be searched (None when allowed)."""
217
+ if tool_name == "Glob":
218
+ return "allow", None, "Glob lists file names; smart_search does not replace a listing."
219
+ if tool_name == "Grep":
220
+ target = resolve_path(tool_input.get("path") or cwd, cwd)
221
+ if not target or not os.path.exists(target):
222
+ return "allow", None, "The path does not exist: the native tool's error is the useful answer."
223
+ if os.path.isfile(target):
224
+ return "allow", None, "Search inside one file."
225
+ if not _in_project(target, cwd):
226
+ return "allow", None, "The folder is outside every project: no index covers it."
227
+ if not _has_files(target):
228
+ return "allow", None, "The folder has no files."
229
+ return "redirect", target, "content search over a folder"
230
+ if tool_name == "Bash":
231
+ return _bash(tool_input.get("command") or "", cwd)
232
+ return "allow", None, "Tool not redirected."
package/router.py CHANGED
@@ -1,242 +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
- _RECURSIVE_RE = re.compile(r"\b(?:grep|egrep|fgrep)\s+(?:-\w+\s+)*-\w*[rR]|\b(?:rg|find|fd|tree)\s|\bls\s+-\w*R|-Recurse\b")
78
-
79
-
80
- def _resolve_path(raw, cwd):
81
- """Path do `tool_input` normalizado para o disco. Relativo resolve contra o `cwd` do
82
- payload (não o do processo do hook, que é outro), e a forma MSYS `/c/...` — que o
83
- agente emite com frequência em shell POSIX no Windows — é convertida, senão
84
- `os.path.exists` responde `False` sobre um arquivo que existe."""
85
- if not raw:
86
- return None
87
- path = raw.strip().replace("\\", "/")
88
- msys = re.fullmatch(r"/([A-Za-z])(/.*)?", path)
89
- if msys:
90
- path = f"{msys.group(1).upper()}:{msys.group(2) or '/'}"
91
- if not os.path.isabs(path) and cwd:
92
- path = os.path.join(cwd, path)
93
- return os.path.normpath(path)
94
-
95
-
96
- def _target_path(tool_input, cwd):
97
- for field in _PATH_FIELDS:
98
- value = tool_input.get(field)
99
- if isinstance(value, str) and value.strip():
100
- return value
101
- return None
102
-
103
-
104
- def _command_paths(command, cwd):
105
- """Paths que aparecem como argumento do comando e existem em disco. Sem isto, um
106
- `grep -n foo arquivo.py` num monorepo seria medido como se varresse o repo todo."""
107
- found = []
108
- previous, base = "", cwd
109
- for token in re.findall(r'"([^"]+)"|\'([^\']+)\'|(\S+)', command or ""):
110
- raw = next((t for t in token if t), "")
111
- if token[2]:
112
- # `cd dir; grep x file`: the separator sticks to the unquoted word before it.
113
- raw = raw.rstrip(";")
114
- after_cd, previous = previous.lower() in ("cd", "pushd", "set-location", "sl"), raw
115
- if after_cd:
116
- # The directory of a `cd` is where the command runs (base for what follows), not what it searches.
117
- base = _resolve_path(raw, base) or base
118
- continue
119
- if not raw or raw.startswith("-"):
120
- continue
121
- resolved = _resolve_path(raw, base)
122
- if resolved and os.path.exists(resolved):
123
- found.append(resolved)
124
- return found
125
-
126
-
127
- def _scan_target(path):
128
- files = 0
129
- heavy = set()
130
- ignored = set()
131
- truncated = False
132
- deadline = time.monotonic() + _SCAN_MAX_SECONDS
133
- for dirpath, dirnames, filenames in os.walk(path):
134
- for name in list(dirnames):
135
- if name in _HEAVY_DIR_NAMES:
136
- heavy.add(name)
137
- dirnames.remove(name)
138
- elif name in _IGNORED_BY_DEFAULT_DIRS or name.startswith("."):
139
- ignored.add(name)
140
- dirnames.remove(name)
141
- for _ in filenames:
142
- files += 1
143
- # Testar o orçamento só entre diretórios deixaria um único diretório com
144
- # centenas de milhares de entradas passar por cima dele.
145
- if files > _SCAN_MAX_FILES or time.monotonic() > deadline:
146
- return {"files": files, "heavy_dirs": sorted(heavy),
147
- "ignored_by_default": sorted(ignored), "truncated": True}
148
- if time.monotonic() > deadline:
149
- truncated = True
150
- break
151
- return {"files": files, "heavy_dirs": sorted(heavy),
152
- "ignored_by_default": sorted(ignored), "truncated": truncated}
153
-
154
-
155
- def _index_facts(project_root):
156
- try:
157
- # A identidade é compartilhada; índices legados continuam visíveis até a migração.
158
- db = indexer.existing_db_path(project_root)
159
- if db and os.path.isfile(db):
160
- return {"indexed": True, "index_kib": os.path.getsize(db) // 1024}
161
- return {"indexed": False}
162
- except OSError:
163
- return {"indexed": False}
164
-
165
-
166
- def _target_facts(tool_name, tool_input, cwd):
167
- """Fatos medidos em disco sobre o alvo da chamada. Roda antes de toda tool call
168
- casada pelo hook, então é limitado por tempo e por contagem, não desce em diretório
169
- pesado e nunca sai da raiz do projeto. Sem isto o modelo julga custo só pelo texto do
170
- path."""
171
- raw = _target_path(tool_input, cwd)
172
- if raw is None and tool_name == "Bash":
173
- candidates = _command_paths(tool_input.get("command"), cwd)
174
- if candidates and _RECURSIVE_RE.search(tool_input.get("command") or ""):
175
- # `sed -n 1,9p a.py; grep -rn x src`: the recursive part is the cost, not the file read before it.
176
- candidates = [c for c in candidates if os.path.isdir(c)] or candidates
177
- if candidates:
178
- raw = candidates[0]
179
- scope_note = None
180
- if raw is None:
181
- raw, scope_note = cwd, "session cwd, not derived from the command"
182
- path = _resolve_path(raw, cwd)
183
- if not path:
184
- return {}
185
- facts = {"path": _display_path(path, cwd)}
186
- if scope_note:
187
- facts["scope"] = scope_note
188
- # Fora da raiz do projeto nada é medido: varrer `C:\` ou o perfil do usuário antes de
189
- # uma tool call custaria mais que a própria chamada, e a decisão seria `allow` igual.
190
- if cwd and not index_scope._within_root(cwd, path):
191
- facts["outside_project"] = True
192
- return facts
193
- try:
194
- if os.path.isfile(path):
195
- facts["is_file"] = True
196
- facts["kib"] = os.path.getsize(path) // 1024
197
- return facts
198
- if not os.path.isdir(path):
199
- # Só afirma ausência quando o path é inequívoco; adivinhar `missing` faria o
200
- # pré-filtro liberar a chamada com um motivo falso no dataset de calibração.
201
- facts["missing"] = True
202
- return facts
203
- facts.update(_scan_target(path))
204
- except OSError as exc:
205
- facts["scan_error"] = str(exc)
206
- return facts
207
- facts.update(_index_facts(cwd or path))
208
- return facts
209
-
210
-
211
- def _display_path(path, cwd):
212
- """Relativo ao projeto quando possível: o path absoluto carrega o nome do usuário do
213
- Windows e vai inteiro no prompt enviado ao gateway."""
214
- if cwd:
215
- try:
216
- rel = os.path.relpath(path, cwd)
217
- if not rel.startswith(".."):
218
- return rel.replace(os.sep, "/")
219
- except ValueError:
220
- pass
221
- return os.path.basename(path) or path
222
-
223
- _DECISION_TOOL = {
224
- "type": "function",
225
- "function": {
226
- "name": "submit_decision",
227
- "description": "Submit the routing decision for this tool call.",
228
- "parameters": {
229
- "type": "object",
230
- "properties": {
231
- "decision": {"type": "string", "enum": ["allow", "redirect"]},
232
- "reason": {"type": "string", "description": "Short justification, one sentence."},
233
- },
234
- "required": ["decision", "reason"],
235
- },
236
- },
237
- }
238
-
239
-
240
25
  METRICS_MAX_BYTES = 4 * 1024 * 1024
241
26
  _METRIC_MAX_FIELD_CHARS = 600
242
27
  _SECRET_ARG_RE = re.compile(
@@ -310,7 +95,7 @@ _SEARCH_VERB_RE = re.compile(
310
95
  )
311
96
  # Commands that read code or pages without a search verb at their start: their pipe filters still search content.
312
97
  _READ_PRODUCER_RE = re.compile(
313
- 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|"
314
99
  r"readFileSync|read_text\(|open\(",
315
100
  re.IGNORECASE,
316
101
  )
@@ -332,11 +117,6 @@ def _mutates(command):
332
117
  """Whether a shell command changes files, the repository or dependencies."""
333
118
  return bool(_FILE_WRITE_RE.search(command) or _MUTATING_VERB_RE.search(_QUOTED_RE.sub("''", command)))
334
119
 
335
- BREAKER_PATH = os.path.join(os.path.dirname(METRICS_PATH), "router-breaker.json")
336
- BREAKER_FAILURES = 3
337
- BREAKER_COOLDOWN_S = 300
338
-
339
-
340
120
  def _mechanical_decision(tool_name, tool_input):
341
121
  """`(decisão, motivo)` quando a resposta não depende de julgamento nem de tocar o
342
122
  disco, ou `None`.
@@ -363,141 +143,33 @@ def _mechanical_decision(tool_name, tool_input):
363
143
  return None
364
144
 
365
145
 
366
- def _mechanical_decision_from_facts(facts):
367
- if facts.get("missing"):
368
- return "allow", "The path does not exist: the native tool's error is the useful answer."
369
- if facts.get("is_file"):
370
- return "allow", "Target is a single file, already scoped."
371
- if facts.get("outside_project"):
372
- return "allow", "Target is outside the project root: no index covers it."
373
- return None
374
-
375
-
376
- def _breaker_state():
377
- try:
378
- with open(BREAKER_PATH, "r", encoding="utf-8") as f:
379
- state = json.load(f)
380
- return state if isinstance(state, dict) else {}
381
- except (OSError, json.JSONDecodeError):
382
- return {}
146
+ def _log_error(message):
147
+ print(f"[smart-tool] {message}", file=sys.stderr, flush=True)
383
148
 
384
149
 
385
- def _breaker_write(state):
386
- # tmp + replace: processos concorrentes gravam aqui (cada tool call é um processo
387
- # novo), e um `open("w")` truncado no meio faz o leitor ver arquivo vazio — ou seja,
388
- # breaker fechado justo quando ele deveria estar aberto. Sem `fsync`: perder a última
389
- # escrita aqui é inofensivo e este caminho roda antes de cada tool call.
390
- try:
391
- os.makedirs(os.path.dirname(BREAKER_PATH), exist_ok=True)
392
- tmp_path = f"{BREAKER_PATH}.tmp.{os.getpid()}"
393
- with open(tmp_path, "w", encoding="utf-8") as f:
394
- json.dump(state, f)
395
- os.replace(tmp_path, BREAKER_PATH)
396
- except OSError:
397
- pass
398
-
399
-
400
- def breaker_open_until():
401
- """Instante até quando o roteador está desligado por falhas recentes, ou 0.
402
-
403
- Gateway lento é o pior caso de custo: sem isto, cada tool call paga o timeout inteiro
404
- para terminar em `allow` de qualquer forma. O estado é compartilhado em arquivo porque
405
- cada chamada do hook é um processo novo — nada sobrevive em memória."""
406
- now = time.time()
407
- try:
408
- until = float(_breaker_state().get("open_until") or 0)
409
- except (TypeError, ValueError):
410
- return 0
411
- # Teto pelo próprio cooldown: um salto de relógio (resume de VM, correção de fuso) no
412
- # instante da gravação deixaria um `open_until` no futuro distante, e o roteador
413
- # ficaria desligado pra sempre — sem erro em lugar nenhum, porque com o breaker aberto
414
- # nunca há sucesso pra reabrir.
415
- if until - now > BREAKER_COOLDOWN_S:
416
- return 0
417
- return until if now < until else 0
418
-
419
-
420
- def note_failure():
421
- state = _breaker_state()
422
- now = time.time()
423
- # Janela de tempo em vez de contador: cada hook é um processo, e três falhas
424
- # simultâneas leriam todas `failures=0` e gravariam `1`, nunca alcançando o limite.
425
- # Falha de semana passada também não pode somar com a de hoje.
426
- recent = [t for t in state.get("failures", []) if isinstance(t, (int, float)) and now - t < BREAKER_COOLDOWN_S]
427
- recent.append(now)
428
- state["failures"] = recent[-BREAKER_FAILURES:]
429
- if len(recent) >= BREAKER_FAILURES:
430
- state["open_until"] = now + BREAKER_COOLDOWN_S
431
- state["failures"] = []
432
- _breaker_write(state)
433
-
434
-
435
- def note_success():
436
- state = _breaker_state()
437
- if not state:
438
- return
439
- # Um cooldown aberto por outro processo sobrevive: quem leu o estado antes da abertura
440
- # e teve sucesso não pode apagar a decisão de quem já viu o gateway falhar 3 vezes.
441
- open_until = breaker_open_until()
442
- _breaker_write({"open_until": open_until} if open_until else {})
443
-
444
-
445
- 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."""
446
154
  safe_input = tool_input if isinstance(tool_input, dict) else {}
447
-
448
- # Ordem importa: tudo que decide sem tocar disco vem antes do snapshot, e o breaker
449
- # vem antes dele também — em cooldown o desfecho é `allow` de qualquer forma, então
450
- # percorrer diretório ali seria I/O puro por nada.
451
155
  mechanical = _mechanical_decision(tool_name, safe_input)
452
156
  if mechanical:
453
157
  decision, reason = mechanical
454
158
  _log_decision(tool_name, tool_input, decision, reason, extra={"mechanical": True})
455
- return decision, reason
456
-
457
- if breaker_open_until():
458
- reason = "Router in cooldown after recent gateway failures."
459
- _log_decision(tool_name, tool_input, "allow", reason, extra={"breaker": True})
460
- return "allow", reason
461
-
462
- facts = _target_facts(tool_name, safe_input, cwd)
463
- mechanical = _mechanical_decision_from_facts(facts)
464
- if mechanical:
465
- decision, reason = mechanical
466
- _log_decision(tool_name, tool_input, decision, reason, extra={"mechanical": True})
467
- return decision, reason
468
-
469
- started = time.monotonic()
470
- try:
471
- # O orçamento inteiro do hook no host é de poucos segundos: obter token não pode
472
- # consumir mais que a própria decisão.
473
- token = model_client.get_token()
474
- response = model_client.fetch(
475
- "/v1/chat/completions", token, method="POST", timeout=DECISION_TIMEOUT_S,
476
- body={
477
- "model": router_model,
478
- "messages": [
479
- {"role": "system", "content": _SYSTEM_PROMPT},
480
- {"role": "user", "content": json.dumps({
481
- # Mesma redação do log: a linha de comando também vai pro gateway.
482
- "tool_name": tool_name, "tool_input": _redacted_input(safe_input),
483
- "target": facts,
484
- }, ensure_ascii=False)},
485
- ],
486
- "tools": [_DECISION_TOOL],
487
- "tool_choice": {"type": "function", "function": {"name": "submit_decision"}},
488
- "temperature": 0,
489
- },
490
- )
491
- tool_call = response["choices"][0]["message"]["tool_calls"][0]
492
- args = json.loads(tool_call["function"]["arguments"])
493
- except Exception:
494
- note_failure()
495
- raise
496
- note_success()
497
- decision = args.get("decision", "allow")
498
- reason = args.get("reason", "")
499
- _log_decision(tool_name, tool_input, decision, reason, extra={"ms": round((time.monotonic() - started) * 1000)})
500
- 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
501
173
 
502
174
 
503
175
  def _web_search_tier_system_prompt():