@allansantos-dev/smart-tool 0.9.0 → 0.9.2

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +50 -23
  2. package/NOTICE +32 -32
  3. package/README.md +117 -116
  4. package/atomic_io.py +94 -94
  5. package/bin/smart-tool.mjs +5 -1
  6. package/client_hooks.py +192 -192
  7. package/code_graph_java.mjs +49 -49
  8. package/code_graph_js.cjs +135 -135
  9. package/duplicates.py +313 -313
  10. package/embedding_cache.py +99 -99
  11. package/endpoint_sync.py +179 -179
  12. package/gateway.py +220 -220
  13. package/hook_decision.py +96 -96
  14. package/index_inventory.py +75 -75
  15. package/indexer.py +878 -878
  16. package/install.py +113 -11
  17. package/install_runtime.py +101 -35
  18. package/integration_metrics.py +82 -82
  19. package/local_embedder.py +172 -172
  20. package/model_defaults.py +70 -70
  21. package/package.json +35 -35
  22. package/project_insights_ui.py +38 -38
  23. package/project_monitor.py +204 -204
  24. package/project_store.py +220 -220
  25. package/projects_ui.py +146 -146
  26. package/requirements.txt +8 -8
  27. package/research_cache.py +171 -171
  28. package/research_metrics.py +110 -110
  29. package/router.py +582 -582
  30. package/secret_store.py +121 -121
  31. package/setup_ui.py +1011 -986
  32. package/smart_tool_daemon.py +3604 -3604
  33. package/version.py +1 -1
  34. package/web_adapters/browser/fetch_camoufox.py +69 -0
  35. package/web_adapters/node/ddg-search.mjs +89 -89
  36. package/web_adapters/node/open-websearch-adapter.mjs +77 -77
  37. package/web_adapters/node/package.json +12 -12
  38. package/web_adapters/node/wikipedia-search.mjs +44 -44
  39. package/web_search_adapters.py +193 -193
  40. package/web_search_external.py +288 -288
  41. package/web_search_health.py +88 -88
package/gateway.py CHANGED
@@ -1,220 +1,220 @@
1
- """Model gateway through adapters: how Smart Tool reaches embedding, chat and (optionally) rerank models.
2
-
3
- Pattern copied from OpenCode providers (implementation + options, credentials kept apart; opencode.ai/docs/providers)
4
- and LiteLLM custom handlers (a fixed set of methods registered by name; docs.litellm.ai custom_llm_server).
5
-
6
- An adapter is a Python module with:
7
- NAME unique id (str)
8
- LABEL name shown in the setup screen
9
- FIELDS sequence of {"name", "label", optional "secret", "required", "placeholder", "hint"}
10
- request(options, route, body=None, timeout=15) -> (json, headers)
11
- route is OpenAI-style: "models", "model/info", "embeddings", "chat/completions", "rerank"; body None = GET.
12
- HTTP errors propagate as urllib.error.HTTPError.
13
- verify(options) -> None, raising RuntimeError with a reason a person can act on.
14
-
15
- Built-in adapters live in model_adapters/; any .py in ADAPTERS_DIR is loaded as a user adapter. Non-secret options
16
- stay in config.json; secret fields go to the DPAPI key store.
17
- """
18
- import importlib.util
19
- import os
20
- import threading
21
- import time
22
-
23
- import config
24
- import secret_store
25
- from model_adapters import openai_compatible
26
-
27
- ADAPTERS_DIR = os.path.join(config.CONFIG_DIR, "adapters")
28
- BUILTIN = (openai_compatible,)
29
- STATUS_TTL_S = 30
30
- _lock = threading.Lock()
31
- _loaded = {"key": None, "adapters": {}, "errors": []}
32
- _status = {"at": 0.0, "key": None, "value": None}
33
-
34
-
35
- def _check(module, origin):
36
- for attribute in ("NAME", "LABEL", "FIELDS"):
37
- if not getattr(module, attribute, None):
38
- raise ValueError(f"{origin}: missing {attribute}")
39
- for function in ("request", "verify"):
40
- if not callable(getattr(module, function, None)):
41
- raise ValueError(f"{origin}: missing function {function}()")
42
- for field in module.FIELDS:
43
- if not isinstance(field, dict) or not field.get("name") or not field.get("label"):
44
- raise ValueError(f"{origin}: each FIELDS entry needs name and label")
45
- return module
46
-
47
-
48
- def adapters():
49
- """({name: module}, [load errors]); user adapters are re-imported only when their files change."""
50
- errors, stamped = [], []
51
- try:
52
- names = sorted(name for name in os.listdir(ADAPTERS_DIR) if name.endswith(".py"))
53
- except FileNotFoundError:
54
- names = []
55
- except OSError as exc:
56
- names, errors = [], [f"{ADAPTERS_DIR}: {type(exc).__name__}: {exc}"]
57
- for name in names:
58
- path = os.path.join(ADAPTERS_DIR, name)
59
- try:
60
- stamped.append((path, os.path.getmtime(path)))
61
- except OSError:
62
- continue
63
- key = (tuple(stamped), tuple(errors))
64
- with _lock:
65
- if _loaded["key"] == key:
66
- return dict(_loaded["adapters"]), list(_loaded["errors"])
67
- # Imported outside the lock: an adapter that calls back into gateway at import time must not deadlock the daemon.
68
- found = {module.NAME: module for module in BUILTIN}
69
- for path, _mtime in stamped:
70
- try:
71
- spec = importlib.util.spec_from_file_location(
72
- "smart_tool_adapter_" + os.path.splitext(os.path.basename(path))[0], path)
73
- module = importlib.util.module_from_spec(spec)
74
- spec.loader.exec_module(module)
75
- _check(module, path)
76
- if module.NAME in found:
77
- raise ValueError(f"{path}: NAME {module.NAME!r} already exists")
78
- found[module.NAME] = module
79
- except Exception as exc:
80
- errors.append(f"{os.path.basename(path)}: {type(exc).__name__}: {exc}")
81
- with _lock:
82
- _loaded.update(key=key, adapters=found, errors=errors)
83
- return dict(found), list(errors)
84
-
85
-
86
- def _secret_name(adapter, field):
87
- return f"adapter:{adapter}:{field}"
88
-
89
-
90
- def selected():
91
- """(adapter module or None, options with secrets in clear). Unknown adapter in config fails loudly."""
92
- cfg = config.load_config()
93
- name = cfg.get("model_adapter") or ""
94
- if not name:
95
- return None, {}
96
- found, errors = adapters()
97
- if name not in found:
98
- raise RuntimeError(f"Model adapter {name!r} not found" + (f" ({'; '.join(errors)})" if errors else ""))
99
- module = found[name]
100
- options = dict((cfg.get("model_adapter_options") or {}).get(name) or {})
101
- for field in module.FIELDS:
102
- if field.get("secret"):
103
- options[field["name"]] = config.get_provider_api_key(_secret_name(name, field["name"]))
104
- return module, options
105
-
106
-
107
- def describe():
108
- """Setup screen view: adapters with fields, stored non-secret values and secret state; never the secrets."""
109
- cfg = config.load_config()
110
- found, errors = adapters()
111
- stored = cfg.get("model_adapter_options") or {}
112
- listing = []
113
- for name, module in found.items():
114
- fields = []
115
- for field in module.FIELDS:
116
- entry = {key: field.get(key) for key in ("name", "label", "secret", "required", "placeholder", "hint")}
117
- if field.get("secret"):
118
- entry["state"] = config.provider_key_status(_secret_name(name, field["name"]))
119
- else:
120
- entry["value"] = (stored.get(name) or {}).get(field["name"], "")
121
- fields.append(entry)
122
- listing.append({"name": name, "label": module.LABEL, "builtin": module in BUILTIN, "fields": fields})
123
- return {"selected": cfg.get("model_adapter") or "", "adapters": listing, "errors": errors, "dir": ADAPTERS_DIR}
124
-
125
-
126
- def save(name, values):
127
- """Verifies with the adapter before storing. Secret field absent/None keeps the stored value; "" removes it.
128
- name "" disconnects."""
129
- if not name:
130
- config.update_config(lambda cfg: cfg.update(model_adapter=""))
131
- forget_status()
132
- return
133
- found, _errors = adapters()
134
- if name not in found:
135
- raise ValueError(f"Adapter {name!r} does not exist.")
136
- module, values = found[name], values or {}
137
- plain, secrets, options = {}, {}, {}
138
- for field in module.FIELDS:
139
- key = field["name"]
140
- value = values.get(key)
141
- if field.get("secret"):
142
- if value is None:
143
- try:
144
- value = config.get_provider_api_key(_secret_name(name, key))
145
- except secret_store.SecretProtectionError:
146
- raise ValueError(f"The saved value of {field['label']} cannot be opened in this Windows profile; "
147
- "paste it again.") from None
148
- else:
149
- secrets[key] = str(value).strip()
150
- value = secrets[key]
151
- if value:
152
- config.check_provider_key_format(value)
153
- else:
154
- value = str(value or "").strip()
155
- plain[key] = value
156
- if field.get("required") and not value:
157
- raise ValueError(f"Fill in {field['label']}.")
158
- options[key] = value
159
- module.verify(options)
160
-
161
- def mutate(cfg):
162
- stored = dict(cfg.get("model_adapter_options") or {})
163
- stored[name] = plain
164
- cfg.update(model_adapter=name, model_adapter_options=stored)
165
- config.update_config(mutate)
166
- for key, value in secrets.items():
167
- config.set_provider_api_key(_secret_name(name, key), value)
168
- forget_status()
169
-
170
-
171
- def remove_secret(name, field):
172
- """Deletes one stored secret without testing the connection (a revoked key must be removable)."""
173
- found, _errors = adapters()
174
- module = found.get(name)
175
- if module is None or not any(f["name"] == field and f.get("secret") for f in module.FIELDS):
176
- raise ValueError(f"{name}/{field} is not a secret adapter field.")
177
- config.set_provider_api_key(_secret_name(name, field), "")
178
- forget_status()
179
-
180
-
181
- def forget_status():
182
- with _lock:
183
- _status.update(at=0.0, key=None, value=None)
184
-
185
-
186
- def status():
187
- """{"status": "ready" | "not_configured" | "error", "adapter", "label", "error"}, checked every STATUS_TTL_S."""
188
- try:
189
- module, options = selected()
190
- except Exception as exc:
191
- return {"status": "error", "adapter": config.load_config().get("model_adapter"), "label": "",
192
- "error": f"{type(exc).__name__}: {exc}"}
193
- if module is None:
194
- return {"status": "not_configured", "adapter": "", "label": "",
195
- "error": "No model gateway configured."}
196
- key = (module.NAME, tuple(sorted(options.items())))
197
- with _lock:
198
- if _status["key"] == key and time.monotonic() - _status["at"] < STATUS_TTL_S:
199
- return dict(_status["value"])
200
- try:
201
- module.verify(options)
202
- value = {"status": "ready", "adapter": module.NAME, "label": module.LABEL, "error": ""}
203
- except Exception as exc:
204
- value = {"status": "error", "adapter": module.NAME, "label": module.LABEL, "error": str(exc)}
205
- with _lock:
206
- _status.update(at=time.monotonic(), key=key, value=value)
207
- return dict(value)
208
-
209
-
210
- def fingerprint():
211
- """Changes whenever the adapter or its options change (cache key for the model catalog)."""
212
- module, options = selected()
213
- return (module.NAME if module else "", tuple(sorted(options.items())))
214
-
215
-
216
- def request(route, body=None, timeout=15):
217
- module, options = selected()
218
- if module is None:
219
- raise RuntimeError("No model gateway configured: choose an adapter in the setup screen.")
220
- return module.request(options, route, body=body, timeout=timeout)
1
+ """Model gateway through adapters: how Smart Tool reaches embedding, chat and (optionally) rerank models.
2
+
3
+ Pattern copied from OpenCode providers (implementation + options, credentials kept apart; opencode.ai/docs/providers)
4
+ and LiteLLM custom handlers (a fixed set of methods registered by name; docs.litellm.ai custom_llm_server).
5
+
6
+ An adapter is a Python module with:
7
+ NAME unique id (str)
8
+ LABEL name shown in the setup screen
9
+ FIELDS sequence of {"name", "label", optional "secret", "required", "placeholder", "hint"}
10
+ request(options, route, body=None, timeout=15) -> (json, headers)
11
+ route is OpenAI-style: "models", "model/info", "embeddings", "chat/completions", "rerank"; body None = GET.
12
+ HTTP errors propagate as urllib.error.HTTPError.
13
+ verify(options) -> None, raising RuntimeError with a reason a person can act on.
14
+
15
+ Built-in adapters live in model_adapters/; any .py in ADAPTERS_DIR is loaded as a user adapter. Non-secret options
16
+ stay in config.json; secret fields go to the DPAPI key store.
17
+ """
18
+ import importlib.util
19
+ import os
20
+ import threading
21
+ import time
22
+
23
+ import config
24
+ import secret_store
25
+ from model_adapters import openai_compatible
26
+
27
+ ADAPTERS_DIR = os.path.join(config.CONFIG_DIR, "adapters")
28
+ BUILTIN = (openai_compatible,)
29
+ STATUS_TTL_S = 30
30
+ _lock = threading.Lock()
31
+ _loaded = {"key": None, "adapters": {}, "errors": []}
32
+ _status = {"at": 0.0, "key": None, "value": None}
33
+
34
+
35
+ def _check(module, origin):
36
+ for attribute in ("NAME", "LABEL", "FIELDS"):
37
+ if not getattr(module, attribute, None):
38
+ raise ValueError(f"{origin}: missing {attribute}")
39
+ for function in ("request", "verify"):
40
+ if not callable(getattr(module, function, None)):
41
+ raise ValueError(f"{origin}: missing function {function}()")
42
+ for field in module.FIELDS:
43
+ if not isinstance(field, dict) or not field.get("name") or not field.get("label"):
44
+ raise ValueError(f"{origin}: each FIELDS entry needs name and label")
45
+ return module
46
+
47
+
48
+ def adapters():
49
+ """({name: module}, [load errors]); user adapters are re-imported only when their files change."""
50
+ errors, stamped = [], []
51
+ try:
52
+ names = sorted(name for name in os.listdir(ADAPTERS_DIR) if name.endswith(".py"))
53
+ except FileNotFoundError:
54
+ names = []
55
+ except OSError as exc:
56
+ names, errors = [], [f"{ADAPTERS_DIR}: {type(exc).__name__}: {exc}"]
57
+ for name in names:
58
+ path = os.path.join(ADAPTERS_DIR, name)
59
+ try:
60
+ stamped.append((path, os.path.getmtime(path)))
61
+ except OSError:
62
+ continue
63
+ key = (tuple(stamped), tuple(errors))
64
+ with _lock:
65
+ if _loaded["key"] == key:
66
+ return dict(_loaded["adapters"]), list(_loaded["errors"])
67
+ # Imported outside the lock: an adapter that calls back into gateway at import time must not deadlock the daemon.
68
+ found = {module.NAME: module for module in BUILTIN}
69
+ for path, _mtime in stamped:
70
+ try:
71
+ spec = importlib.util.spec_from_file_location(
72
+ "smart_tool_adapter_" + os.path.splitext(os.path.basename(path))[0], path)
73
+ module = importlib.util.module_from_spec(spec)
74
+ spec.loader.exec_module(module)
75
+ _check(module, path)
76
+ if module.NAME in found:
77
+ raise ValueError(f"{path}: NAME {module.NAME!r} already exists")
78
+ found[module.NAME] = module
79
+ except Exception as exc:
80
+ errors.append(f"{os.path.basename(path)}: {type(exc).__name__}: {exc}")
81
+ with _lock:
82
+ _loaded.update(key=key, adapters=found, errors=errors)
83
+ return dict(found), list(errors)
84
+
85
+
86
+ def _secret_name(adapter, field):
87
+ return f"adapter:{adapter}:{field}"
88
+
89
+
90
+ def selected():
91
+ """(adapter module or None, options with secrets in clear). Unknown adapter in config fails loudly."""
92
+ cfg = config.load_config()
93
+ name = cfg.get("model_adapter") or ""
94
+ if not name:
95
+ return None, {}
96
+ found, errors = adapters()
97
+ if name not in found:
98
+ raise RuntimeError(f"Model adapter {name!r} not found" + (f" ({'; '.join(errors)})" if errors else ""))
99
+ module = found[name]
100
+ options = dict((cfg.get("model_adapter_options") or {}).get(name) or {})
101
+ for field in module.FIELDS:
102
+ if field.get("secret"):
103
+ options[field["name"]] = config.get_provider_api_key(_secret_name(name, field["name"]))
104
+ return module, options
105
+
106
+
107
+ def describe():
108
+ """Setup screen view: adapters with fields, stored non-secret values and secret state; never the secrets."""
109
+ cfg = config.load_config()
110
+ found, errors = adapters()
111
+ stored = cfg.get("model_adapter_options") or {}
112
+ listing = []
113
+ for name, module in found.items():
114
+ fields = []
115
+ for field in module.FIELDS:
116
+ entry = {key: field.get(key) for key in ("name", "label", "secret", "required", "placeholder", "hint")}
117
+ if field.get("secret"):
118
+ entry["state"] = config.provider_key_status(_secret_name(name, field["name"]))
119
+ else:
120
+ entry["value"] = (stored.get(name) or {}).get(field["name"], "")
121
+ fields.append(entry)
122
+ listing.append({"name": name, "label": module.LABEL, "builtin": module in BUILTIN, "fields": fields})
123
+ return {"selected": cfg.get("model_adapter") or "", "adapters": listing, "errors": errors, "dir": ADAPTERS_DIR}
124
+
125
+
126
+ def save(name, values):
127
+ """Verifies with the adapter before storing. Secret field absent/None keeps the stored value; "" removes it.
128
+ name "" disconnects."""
129
+ if not name:
130
+ config.update_config(lambda cfg: cfg.update(model_adapter=""))
131
+ forget_status()
132
+ return
133
+ found, _errors = adapters()
134
+ if name not in found:
135
+ raise ValueError(f"Adapter {name!r} does not exist.")
136
+ module, values = found[name], values or {}
137
+ plain, secrets, options = {}, {}, {}
138
+ for field in module.FIELDS:
139
+ key = field["name"]
140
+ value = values.get(key)
141
+ if field.get("secret"):
142
+ if value is None:
143
+ try:
144
+ value = config.get_provider_api_key(_secret_name(name, key))
145
+ except secret_store.SecretProtectionError:
146
+ raise ValueError(f"The saved value of {field['label']} cannot be opened in this Windows profile; "
147
+ "paste it again.") from None
148
+ else:
149
+ secrets[key] = str(value).strip()
150
+ value = secrets[key]
151
+ if value:
152
+ config.check_provider_key_format(value)
153
+ else:
154
+ value = str(value or "").strip()
155
+ plain[key] = value
156
+ if field.get("required") and not value:
157
+ raise ValueError(f"Fill in {field['label']}.")
158
+ options[key] = value
159
+ module.verify(options)
160
+
161
+ def mutate(cfg):
162
+ stored = dict(cfg.get("model_adapter_options") or {})
163
+ stored[name] = plain
164
+ cfg.update(model_adapter=name, model_adapter_options=stored)
165
+ config.update_config(mutate)
166
+ for key, value in secrets.items():
167
+ config.set_provider_api_key(_secret_name(name, key), value)
168
+ forget_status()
169
+
170
+
171
+ def remove_secret(name, field):
172
+ """Deletes one stored secret without testing the connection (a revoked key must be removable)."""
173
+ found, _errors = adapters()
174
+ module = found.get(name)
175
+ if module is None or not any(f["name"] == field and f.get("secret") for f in module.FIELDS):
176
+ raise ValueError(f"{name}/{field} is not a secret adapter field.")
177
+ config.set_provider_api_key(_secret_name(name, field), "")
178
+ forget_status()
179
+
180
+
181
+ def forget_status():
182
+ with _lock:
183
+ _status.update(at=0.0, key=None, value=None)
184
+
185
+
186
+ def status():
187
+ """{"status": "ready" | "not_configured" | "error", "adapter", "label", "error"}, checked every STATUS_TTL_S."""
188
+ try:
189
+ module, options = selected()
190
+ except Exception as exc:
191
+ return {"status": "error", "adapter": config.load_config().get("model_adapter"), "label": "",
192
+ "error": f"{type(exc).__name__}: {exc}"}
193
+ if module is None:
194
+ return {"status": "not_configured", "adapter": "", "label": "",
195
+ "error": "No model gateway configured."}
196
+ key = (module.NAME, tuple(sorted(options.items())))
197
+ with _lock:
198
+ if _status["key"] == key and time.monotonic() - _status["at"] < STATUS_TTL_S:
199
+ return dict(_status["value"])
200
+ try:
201
+ module.verify(options)
202
+ value = {"status": "ready", "adapter": module.NAME, "label": module.LABEL, "error": ""}
203
+ except Exception as exc:
204
+ value = {"status": "error", "adapter": module.NAME, "label": module.LABEL, "error": str(exc)}
205
+ with _lock:
206
+ _status.update(at=time.monotonic(), key=key, value=value)
207
+ return dict(value)
208
+
209
+
210
+ def fingerprint():
211
+ """Changes whenever the adapter or its options change (cache key for the model catalog)."""
212
+ module, options = selected()
213
+ return (module.NAME if module else "", tuple(sorted(options.items())))
214
+
215
+
216
+ def request(route, body=None, timeout=15):
217
+ module, options = selected()
218
+ if module is None:
219
+ raise RuntimeError("No model gateway configured: choose an adapter in the setup screen.")
220
+ return module.request(options, route, body=body, timeout=timeout)
package/hook_decision.py CHANGED
@@ -1,96 +1,96 @@
1
- """Decisão do PreToolUse do Smart Tool, tomada dentro do daemon já aquecido.
2
-
3
- O Claude Code chama a rota POST /hooks/pretool como hook `type: "http"` (sem processo por chamada); o Codex usa o
4
- hook de comando `hooks/pretool_router.py`, que só repassa o payload para a mesma rota.
5
-
6
- INVARIANTE: a saída só pode ser `{}` (neutra) ou `deny`. `permissionDecision: "allow"` pula a confirmação do usuário
7
- no Claude Code, e nenhuma decisão aqui é sobre periculosidade, só sobre custo de contexto. Diagnóstico vai em
8
- `systemMessage`, que não altera permissão.
9
- """
10
- import json
11
- import os
12
-
13
- import config
14
- import endpoint_sync
15
- import router
16
-
17
- WEB_TOOLS = ("WebSearch", "WebFetch")
18
- SMART_TOOL_SERVER = "smart-tool"
19
- REASON_MAX_CHARS = 200
20
-
21
-
22
- def clean_reason(reason):
23
- """The reason is free LLM text fed to the host model: one line, bounded (repository content reaches the router
24
- prompt through tool_input)."""
25
- text = " ".join(str(reason or "").split())
26
- return text[:REASON_MAX_CHARS] or "call broad enough to be worth an indexed search"
27
-
28
-
29
- def deny(reason):
30
- return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
31
- "permissionDecisionReason": reason}}
32
-
33
-
34
- def smart_tool_in_session(cwd):
35
- """Claude Code loads MCP servers from ~/.claude.json (user and per project) and the project's .mcp.json."""
36
- with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
37
- data = json.load(stream)
38
- key = os.path.normcase(os.path.normpath(cwd or ""))
39
- project = next((value for path, value in (data.get("projects") or {}).items()
40
- if os.path.normcase(os.path.normpath(path)) == key), {})
41
- if SMART_TOOL_SERVER in (project.get("disabledMcpServers") or []):
42
- return False
43
- if SMART_TOOL_SERVER in (data.get("mcpServers") or {}) or SMART_TOOL_SERVER in (project.get("mcpServers") or {}):
44
- return True
45
- try:
46
- with open(os.path.join(cwd or "", ".mcp.json"), encoding="utf-8") as stream:
47
- return SMART_TOOL_SERVER in (json.load(stream).get("mcpServers") or {})
48
- except (OSError, ValueError):
49
- return False
50
-
51
-
52
- def advise(reason):
53
- """Same routing advice without blocking: the agent sees it next to the tool result and decides next time."""
54
- return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
55
- "additionalContext": f"Smart Tool tip (the call was not blocked): {reason}"}}
56
-
57
-
58
- def decide(payload, client, web_route):
59
- """Hook output for one PreToolUse payload, following config hook_mode (redirect | advise | off)."""
60
- cfg = config.load_config()
61
- mode = config.hook_mode(cfg)
62
- if mode == "off" or not isinstance(payload, dict):
63
- return {}
64
- output = _route(payload, client, web_route, cfg)
65
- reason = (output.get("hookSpecificOutput") or {}).get("permissionDecisionReason")
66
- return advise(reason) if mode == "advise" and reason else output
67
-
68
-
69
- def _route(payload, client, web_route, cfg):
70
- tool_name = payload.get("tool_name", "")
71
- tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
72
- cwd = payload.get("cwd")
73
- router_model = cfg.get("router_model")
74
- if not router_model:
75
- return {}
76
- if tool_name in WEB_TOOLS:
77
- try:
78
- present = smart_tool_in_session(cwd)
79
- except (OSError, ValueError):
80
- present = False
81
- if not present:
82
- return {}
83
- decision = web_route(tool_name, str(tool_input.get("url") or ""))
84
- return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
85
- router.CLIENT = client
86
- try:
87
- decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
88
- except Exception as exc:
89
- try:
90
- router.log_unavailable(tool_name, tool_input, exc)
91
- except Exception:
92
- pass
93
- return {"systemMessage": f"Smart Tool unavailable, routing skipped: {type(exc).__name__}"}
94
- if decision == "redirect":
95
- return deny(f"Use smart_search instead of raw {tool_name}: {clean_reason(reason)}")
96
- return {}
1
+ """Decisão do PreToolUse do Smart Tool, tomada dentro do daemon já aquecido.
2
+
3
+ O Claude Code chama a rota POST /hooks/pretool como hook `type: "http"` (sem processo por chamada); o Codex usa o
4
+ hook de comando `hooks/pretool_router.py`, que só repassa o payload para a mesma rota.
5
+
6
+ INVARIANTE: a saída só pode ser `{}` (neutra) ou `deny`. `permissionDecision: "allow"` pula a confirmação do usuário
7
+ no Claude Code, e nenhuma decisão aqui é sobre periculosidade, só sobre custo de contexto. Diagnóstico vai em
8
+ `systemMessage`, que não altera permissão.
9
+ """
10
+ import json
11
+ import os
12
+
13
+ import config
14
+ import endpoint_sync
15
+ import router
16
+
17
+ WEB_TOOLS = ("WebSearch", "WebFetch")
18
+ SMART_TOOL_SERVER = "smart-tool"
19
+ REASON_MAX_CHARS = 200
20
+
21
+
22
+ def clean_reason(reason):
23
+ """The reason is free LLM text fed to the host model: one line, bounded (repository content reaches the router
24
+ prompt through tool_input)."""
25
+ text = " ".join(str(reason or "").split())
26
+ return text[:REASON_MAX_CHARS] or "call broad enough to be worth an indexed search"
27
+
28
+
29
+ def deny(reason):
30
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
31
+ "permissionDecisionReason": reason}}
32
+
33
+
34
+ def smart_tool_in_session(cwd):
35
+ """Claude Code loads MCP servers from ~/.claude.json (user and per project) and the project's .mcp.json."""
36
+ with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
37
+ data = json.load(stream)
38
+ key = os.path.normcase(os.path.normpath(cwd or ""))
39
+ project = next((value for path, value in (data.get("projects") or {}).items()
40
+ if os.path.normcase(os.path.normpath(path)) == key), {})
41
+ if SMART_TOOL_SERVER in (project.get("disabledMcpServers") or []):
42
+ return False
43
+ if SMART_TOOL_SERVER in (data.get("mcpServers") or {}) or SMART_TOOL_SERVER in (project.get("mcpServers") or {}):
44
+ return True
45
+ try:
46
+ with open(os.path.join(cwd or "", ".mcp.json"), encoding="utf-8") as stream:
47
+ return SMART_TOOL_SERVER in (json.load(stream).get("mcpServers") or {})
48
+ except (OSError, ValueError):
49
+ return False
50
+
51
+
52
+ def advise(reason):
53
+ """Same routing advice without blocking: the agent sees it next to the tool result and decides next time."""
54
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
55
+ "additionalContext": f"Smart Tool tip (the call was not blocked): {reason}"}}
56
+
57
+
58
+ def decide(payload, client, web_route):
59
+ """Hook output for one PreToolUse payload, following config hook_mode (redirect | advise | off)."""
60
+ cfg = config.load_config()
61
+ mode = config.hook_mode(cfg)
62
+ if mode == "off" or not isinstance(payload, dict):
63
+ return {}
64
+ output = _route(payload, client, web_route, cfg)
65
+ reason = (output.get("hookSpecificOutput") or {}).get("permissionDecisionReason")
66
+ return advise(reason) if mode == "advise" and reason else output
67
+
68
+
69
+ def _route(payload, client, web_route, cfg):
70
+ tool_name = payload.get("tool_name", "")
71
+ tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
72
+ cwd = payload.get("cwd")
73
+ router_model = cfg.get("router_model")
74
+ if not router_model:
75
+ return {}
76
+ if tool_name in WEB_TOOLS:
77
+ try:
78
+ present = smart_tool_in_session(cwd)
79
+ except (OSError, ValueError):
80
+ present = False
81
+ if not present:
82
+ return {}
83
+ decision = web_route(tool_name, str(tool_input.get("url") or ""))
84
+ return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
85
+ router.CLIENT = client
86
+ try:
87
+ decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
88
+ except Exception as exc:
89
+ try:
90
+ router.log_unavailable(tool_name, tool_input, exc)
91
+ except Exception:
92
+ pass
93
+ return {"systemMessage": f"Smart Tool unavailable, routing skipped: {type(exc).__name__}"}
94
+ if decision == "redirect":
95
+ return deny(f"Use smart_search instead of raw {tool_name}: {clean_reason(reason)}")
96
+ return {}