@allansantos-dev/smart-tool 0.9.32 → 0.9.34
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/CHANGELOG.md +18 -0
- package/README.md +24 -7
- package/affected_tests.py +8 -2
- package/code_graph.py +4 -3
- package/code_graph_go.py +85 -0
- package/config.py +3 -0
- package/index_profile.py +4 -2
- package/model_defaults.py +10 -2
- package/model_health.py +1 -2
- package/model_upgrade.py +1 -1
- package/package.json +1 -1
- package/setup_audit.py +106 -8
- package/version.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.34 - beta
|
|
4
|
+
|
|
5
|
+
- The shell rewrite of a redirected search coexists with other PreToolUse hooks on Bash. Hooks of one call run in
|
|
6
|
+
parallel on the same original input and the host documents no order between their `updatedInput`, so the Smart Tool
|
|
7
|
+
rewrite is a constant (`true` / `$null`), carries no permission decision, applies only to a content search and leaves
|
|
8
|
+
an already rewritten call alone; whichever rewrite wins, the PostToolUse still swaps the output for the answer.
|
|
9
|
+
Rule and evidence in the README and `docs/research/2026-10-19-hook-redirect-transparente.md`; test
|
|
10
|
+
`test_the_shell_rewrite_coexists_with_other_hooks_that_rewrite_the_same_call`.
|
|
11
|
+
|
|
12
|
+
## 0.9.33 - beta
|
|
13
|
+
|
|
14
|
+
- Recommended models prefer the Vertex route. When the gateway lists a model on several routes, `model_defaults` now
|
|
15
|
+
picks `vertex_ai.*` first, any other route next and `bedrock.*` only when nothing else serves the model (Bedrock was
|
|
16
|
+
chosen before because ids were taken in alphabetical order). `DEFAULTS_VERSION` is 2, so users who already hold the
|
|
17
|
+
Bedrock ids get the "New recommended models" offer again and decide whether to apply it.
|
|
18
|
+
- `tests/vertex_route_probe.py` asks the live gateway: every recommended chat id must resolve (Anthropic ones on
|
|
19
|
+
Vertex) and answer a real request. Result of 2026-10-26 in `docs/research/2026-10-26-rota-vertex-nas-recomendacoes.md`.
|
|
20
|
+
|
|
3
21
|
## 0.9.32 - beta
|
|
4
22
|
|
|
5
23
|
- Users who updated are told about the new recommended models. `model_upgrade` compares the saved `router_model`,
|
package/README.md
CHANGED
|
@@ -16,7 +16,7 @@ registered as the `playwright` server where this machine lets a browser be drive
|
|
|
16
16
|
An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
|
|
17
17
|
are cheaper, or just tells the agent what Smart Tool would do.
|
|
18
18
|
|
|
19
|
-
**Status:** 0.9.
|
|
19
|
+
**Status:** 0.9.34 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
|
|
20
20
|
|
|
21
21
|
## How it works
|
|
22
22
|
|
|
@@ -111,8 +111,18 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
111
111
|
URL ending where the API starts, like the OpenAI SDK `base_url` (`https://api.openai.com/v1`,
|
|
112
112
|
`https://openrouter.ai/api/v1`, `http://localhost:11434/v1`), and an optional API key. Saving tests `/models` first.
|
|
113
113
|
2. **Models**: embedding, rerank (optional; without it results use the hybrid order), the router model (small, no
|
|
114
|
-
reasoning: web search tier order and result checks), the index classification model (also reviews block patterns)
|
|
115
|
-
accepts works; the catalog is
|
|
114
|
+
reasoning: web search tier order and result checks), the index classification model (also reviews block patterns), the
|
|
115
|
+
scout model (the short answer of `smart_scout`) and the research model. Any id the gateway accepts works; the catalog is
|
|
116
|
+
shown as suggestions. Empty roles are filled with the recommended models the gateway offers.
|
|
117
|
+
|
|
118
|
+
When a new version recommends other models than the ones you use, a Windows notification (**Apply now**, **Don't
|
|
119
|
+
show again**) and the **New recommended models** card on this page offer them: nothing changes until you apply, the
|
|
120
|
+
models you had are kept, and **Switch back to my previous models** undoes it. The **Model failures (last 24 h)** table
|
|
121
|
+
lists every failed gateway call by role, model and tool; a model that fails half of its calls (5 or more) also raises a
|
|
122
|
+
notification and a tray alert. A failure inside a tool call also comes back as a warning line in the tool answer;
|
|
123
|
+
calls made by a hook or by background work (index scope and profile, block-pattern proposals) have no answer to
|
|
124
|
+
carry it, so they show only in the table, the log and the notification. The local embedder is not in that table or in
|
|
125
|
+
the usage meter: it is a sidecar on your machine with no price, and its state is the **Local models** section.
|
|
116
126
|
3. **Agents**: register the MCP server in Claude Code or Codex (runs their official `mcp add` command), install the
|
|
117
127
|
hook and choose its behavior under **Agent rules**. A hook written by an older Smart Tool shows as outdated, with
|
|
118
128
|
what an update changes; updating previews the entry and rewrites only Smart Tool's, after a backup.
|
|
@@ -136,7 +146,12 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
136
146
|
(without `allowed_domains`/`blocked_domains`). In Claude Code a PostToolUse entry swaps the output (WebFetch and
|
|
137
147
|
WebSearch still run natively, their result is replaced); a shell search whose answer is ready within 10 s runs as a
|
|
138
148
|
no-op instead, so a grep with no match does not end in exit 1. In Codex the shell search is rewritten to print the
|
|
139
|
-
answer from a file.
|
|
149
|
+
answer from a file. Coexistence with other PreToolUse hooks on Bash: they run in parallel on the same original
|
|
150
|
+
input and the host documents no order between their `updatedInput`. The Smart Tool rewrite is a constant (`true` /
|
|
151
|
+
`$null`, whatever the search), carries no permission decision (a rival `deny`/`ask` still wins) and only applies to a
|
|
152
|
+
content search, so a hook that rewrites other commands never meets it; if a rival rewrite of the same search wins,
|
|
153
|
+
its command runs and the PostToolUse still swaps the output for the answer. Two other hooks rewriting the same command
|
|
154
|
+
must be resolved in the plugin that owns them (the setup audit lists them). When the Smart Tool call fails or is late, the native call and its output stay as they are; a
|
|
140
155
|
deny, with the exact call to make, is left only for a search whose query cannot be read from the command. The
|
|
141
156
|
decision is a fixed rule, under a millisecond and the same every time: a content search over a project folder (`grep -r`,
|
|
142
157
|
`rg`, `git grep`, the Grep tool on a folder) or over more than 20 listed files goes to `smart_search`, in Bash and
|
|
@@ -177,11 +192,13 @@ Redirects) and Maintenance (Setup audit, Startup).
|
|
|
177
192
|
plus changed tests and tests using a pytest fixture whose `conftest.py` imports the changed code, ordered with the
|
|
178
193
|
likeliest failures first, and the command to run them. Changed files are read from disk, so new files and imports
|
|
179
194
|
count before the project is reindexed. `run_all` is true, with
|
|
180
|
-
the reason, when a configuration or lockfile changed or a changed code file is outside the import graph.
|
|
195
|
+
the reason, when a configuration or lockfile changed or a changed code file is outside the import graph. Go is read
|
|
196
|
+
by package: a file that imports a package depends on every file of it, so a changed package selects the tests of every
|
|
197
|
+
package that imports it (`go test ./pkg ...`); the Go and Rust documentation and duplicate hooks stay silent.
|
|
181
198
|
|
|
182
199
|
`project_manage` with `action=setup_audit` and a `project_root` (or `python setup_audit.py --project <folder>`)
|
|
183
|
-
reports what Claude Code and Codex actually load for that folder (settings layers, enabled plugins
|
|
184
|
-
servers, skills, CLAUDE.md and AGENTS.md) and where they collide: several hooks deciding on the same tool in
|
|
200
|
+
reports what Claude Code and Codex actually load for that folder (settings layers, enabled plugins of both,
|
|
201
|
+
hooks, MCP servers, skills, CLAUDE.md and AGENTS.md) and where they collide: several hooks deciding on the same tool in
|
|
185
202
|
parallel, hook scripts that do not exist or hide their errors, hooks calling an MCP server that is not configured,
|
|
186
203
|
MCP servers that do not start or offer the same tools twice (a gateway re-exposing a server also registered
|
|
187
204
|
directly), skills sharing a name or over the description budget, an AGENTS.md only Codex reads, broken `@imports`
|
package/affected_tests.py
CHANGED
|
@@ -22,7 +22,7 @@ RUN_ALL = re.compile(
|
|
|
22
22
|
r"bun\.lockb?|pyproject\.toml|setup\.cfg|setup\.py|pytest\.ini|tox\.ini|requirements[^/]*\.txt|poetry\.lock|"
|
|
23
23
|
r"uv\.lock|Pipfile(\.lock)?|tsconfig[^/]*\.json|jsconfig\.json|(jest|vitest|vite|babel)\.config\.[^/]+|"
|
|
24
24
|
r"\.babelrc|\.mocharc[^/]*|pom\.xml|build\.gradle(\.kts)?|settings\.gradle(\.kts)?|gradle\.properties|"
|
|
25
|
-
r"\.env[^/]
|
|
25
|
+
r"\.env[^/]*|go\.(mod|sum|work(\.sum)?))$")
|
|
26
26
|
CI_CONFIG = re.compile(r"(^|/)(\.github|\.circleci|\.buildkite)/|(^|/)(\.gitlab-ci\.ya?ml|azure-pipelines\.ya?ml|"
|
|
27
27
|
r"Jenkinsfile|\.travis\.ya?ml|bitbucket-pipelines\.ya?ml|\.gitignore|\.gitattributes|"
|
|
28
28
|
r"\.editorconfig|\.mailmap|CODEOWNERS|LICENSE(\.[^/]*)?|\.npmignore)$")
|
|
@@ -31,7 +31,7 @@ NOTE = ("Selection from the indexed import graph: tests that load code through s
|
|
|
31
31
|
"suite before committing; run_all is true when a changed file can change every test (config, lockfile, "
|
|
32
32
|
"unanalyzed code). Changed files and files committed after the last indexing are read from disk, so new "
|
|
33
33
|
"files and imports count before reindexing; pytest fixtures link a test to the conftest.py that defines them.")
|
|
34
|
-
TEST_SOURCES = (".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".java")
|
|
34
|
+
TEST_SOURCES = (".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".java", ".go")
|
|
35
35
|
_PATH_LITERAL = re.compile(r"""['"`]((?:\.{0,2}/)?[\w.-]+(?:/[\w.-]+)*\.(?:py|js|mjs|cjs|ts|mts|cts|java))['"`]""")
|
|
36
36
|
_STRING_LITERAL = re.compile(r"""'([^'\n]*)'|"([^"\n]*)\"""")
|
|
37
37
|
_IMPORT_STATEMENT = re.compile(r"(?:^|;)\s*(?:import|from)\s+([A-Za-z_]\w*(?:\.\w+)*)")
|
|
@@ -132,6 +132,9 @@ def _commands(root, tests):
|
|
|
132
132
|
if runner is None and re.search(rf"\bnode\s+(\./)?{re.escape(local)}\b", scripts):
|
|
133
133
|
runner = "node-script"
|
|
134
134
|
groups[(folder or "", runner)].append(path)
|
|
135
|
+
elif lower.endswith(".go"):
|
|
136
|
+
folder, name = _nearest(root, path, ("go.mod",))
|
|
137
|
+
groups[(folder or "", "go" if name else None)].append(path)
|
|
135
138
|
elif lower.endswith(".java"):
|
|
136
139
|
folder, name = _nearest(root, path, ("pom.xml", "build.gradle", "build.gradle.kts"))
|
|
137
140
|
groups[(folder or "", "maven" if name == "pom.xml" else "gradle" if name else None)].append(path)
|
|
@@ -147,6 +150,8 @@ def _commands(root, tests):
|
|
|
147
150
|
if runner == "maven":
|
|
148
151
|
names = ",".join(sorted({os.path.basename(f).rsplit(".", 1)[0] for f in files}))
|
|
149
152
|
line = f"mvn test -Dtest={names} -Dsurefire.failIfNoSpecifiedTests=false"
|
|
153
|
+
elif runner == "go":
|
|
154
|
+
line = "go test " + " ".join(sorted({"./" + posixpath.dirname(f) if posixpath.dirname(f) else "." for f in local}))
|
|
150
155
|
elif runner == "node-script":
|
|
151
156
|
commands.extend({"cwd": folder or ".", "command": f"node {f}"} for f in sorted(local))
|
|
152
157
|
continue
|
|
@@ -260,6 +265,7 @@ _TEST_CASES = {
|
|
|
260
265
|
".py": re.compile(r"^\s*(async\s+)?def\s+test\w*\s*\(|^\s*class\s+Test\w*|unittest\.TestCase", re.M),
|
|
261
266
|
".java": re.compile(r"@(Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate|RunWith|Suite)\b|"
|
|
262
267
|
r"\bstatic\s+\w*Test\s+suite\s*\(|\bextends\s+TestCase\b|\bvoid\s+test\w*\s*\("),
|
|
268
|
+
".go": re.compile(r"^func\s+(Test|Benchmark|Fuzz|Example)\w*\s*\(", re.M),
|
|
263
269
|
"script": re.compile(r"\b(it|test|describe|suite)(\.\w+)?\s*\(|\bDeno\.test\b|\bnode:test\b|\bassert\b"),
|
|
264
270
|
}
|
|
265
271
|
|
package/code_graph.py
CHANGED
|
@@ -20,10 +20,11 @@ import index_scope
|
|
|
20
20
|
import project_identity
|
|
21
21
|
import web_search_adapters
|
|
22
22
|
import web_document_graph
|
|
23
|
+
import code_graph_go
|
|
23
24
|
import document_text
|
|
24
25
|
|
|
25
|
-
VERSION =
|
|
26
|
-
SOURCE_EXTENSIONS = ('.py','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')
|
|
26
|
+
VERSION = 3
|
|
27
|
+
SOURCE_EXTENSIONS = ('.py','.go','.mod','.java','.js','.jsx','.ts','.tsx','.mjs','.cjs','.mts','.cts','.json','.html','.htm','.css','.scss','.sass','.less','.md','.markdown')
|
|
27
28
|
MAX_OVERLAY_BYTES = 5 * 1024 * 1024
|
|
28
29
|
MAX_FILES = 3000
|
|
29
30
|
MAX_TEXT = 24 * 1024 * 1024
|
|
@@ -284,7 +285,7 @@ def _analyze(path):
|
|
|
284
285
|
def _analyze_sources(files,sources,diagnostics,truncated):
|
|
285
286
|
merged={'symbols':[],'dependencies':[],'calls':[],'unresolved':[],'diagnostics':diagnostics,'parsed':[]}
|
|
286
287
|
retryable=False
|
|
287
|
-
for output in (_python_graph(sources),_javascript_graph(sources,[f['path'] for f in files]),_java_graph(sources),web_document_graph.analyze(sources,[f['path'] for f in files])):
|
|
288
|
+
for output in (_python_graph(sources),_javascript_graph(sources,[f['path'] for f in files]),_java_graph(sources),code_graph_go.analyze(sources),web_document_graph.analyze(sources,[f['path'] for f in files])):
|
|
288
289
|
for key in merged:merged[key].extend(output.get(key,[]))
|
|
289
290
|
truncated |= output.get('truncated',False)
|
|
290
291
|
retryable |= output.get('retryable',False)
|
package/code_graph_go.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Imports between Go files, read from the package clause and the import declarations of the indexed snapshot.
|
|
2
|
+
|
|
3
|
+
A Go package is a directory, so a file that imports a package depends on every non-test file of that directory, and an
|
|
4
|
+
internal test file (`package x` in `x_test.go`) depends on every non-test file of its own directory. Import paths are
|
|
5
|
+
resolved against the nearest `go.mod` module path; with no `go.mod` in the snapshot an import resolves to the longest
|
|
6
|
+
directory whose path ends the import path. No symbols or calls are produced: the doc and duplicate hooks stay silent
|
|
7
|
+
for Go until their thresholds are measured on it, and `affected_tests` selects by package import."""
|
|
8
|
+
import posixpath
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
_COMMENT = re.compile(r"//[^\n]*|/\*.*?\*/", re.S)
|
|
12
|
+
_PACKAGE = re.compile(r"^package\s+(\w+)", re.M)
|
|
13
|
+
_IMPORT_BLOCK = re.compile(r"^import\s*\((.*?)^\)", re.M | re.S)
|
|
14
|
+
_IMPORT_ONE = re.compile(r'^import\s+(?:[\w.]+\s+)?"([^"]+)"', re.M)
|
|
15
|
+
_IMPORT_SPEC = re.compile(r'^\s*(?:[\w.]+\s+)?"([^"]+)"', re.M)
|
|
16
|
+
_MODULE = re.compile(r"^module\s+(\S+)", re.M)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _module_roots(sources):
|
|
20
|
+
"""Folder of each go.mod in the snapshot -> its module path."""
|
|
21
|
+
roots = {}
|
|
22
|
+
for path, text in sources.items():
|
|
23
|
+
if posixpath.basename(path) == "go.mod":
|
|
24
|
+
match = _MODULE.search(_COMMENT.sub("", text))
|
|
25
|
+
if match:
|
|
26
|
+
roots[posixpath.dirname(path)] = match.group(1).strip('"')
|
|
27
|
+
return roots
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _enclosing_module(folder, roots):
|
|
31
|
+
"""(module folder, module path) of the nearest go.mod above `folder`, or (None, None)."""
|
|
32
|
+
while True:
|
|
33
|
+
if folder in roots:
|
|
34
|
+
return folder, roots[folder]
|
|
35
|
+
if not folder:
|
|
36
|
+
return None, None
|
|
37
|
+
folder = posixpath.dirname(folder)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _imports(text):
|
|
41
|
+
"""Import paths of one Go file (comments already blanked)."""
|
|
42
|
+
found = [match.group(1) for match in _IMPORT_ONE.finditer(text)]
|
|
43
|
+
for block in _IMPORT_BLOCK.finditer(text):
|
|
44
|
+
found.extend(spec.group(1) for spec in _IMPORT_SPEC.finditer(block.group(1)))
|
|
45
|
+
return found
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def analyze(sources):
|
|
49
|
+
go_files = {path: text for path, text in sources.items() if path.endswith(".go")}
|
|
50
|
+
parsed = [path for path in sources if path.endswith(".go") or posixpath.basename(path) == "go.mod"]
|
|
51
|
+
if not go_files:
|
|
52
|
+
return {"parsed": parsed}
|
|
53
|
+
roots = _module_roots(sources)
|
|
54
|
+
packages = {}
|
|
55
|
+
for path in go_files:
|
|
56
|
+
if not path.endswith("_test.go"):
|
|
57
|
+
packages.setdefault(posixpath.dirname(path), []).append(path)
|
|
58
|
+
|
|
59
|
+
def package_dir(path, spec):
|
|
60
|
+
folder, module = _enclosing_module(posixpath.dirname(path), roots)
|
|
61
|
+
if module is not None:
|
|
62
|
+
if spec != module and not spec.startswith(module + "/"):
|
|
63
|
+
return None
|
|
64
|
+
return folder if spec == module else posixpath.normpath(posixpath.join(folder, spec[len(module) + 1:]))
|
|
65
|
+
candidates = [d for d in packages if d and (spec == d or spec.endswith("/" + d))]
|
|
66
|
+
return max(candidates, key=len, default=None)
|
|
67
|
+
|
|
68
|
+
dependencies = []
|
|
69
|
+
for path, text in sorted(go_files.items()):
|
|
70
|
+
bare = _COMMENT.sub(lambda m: re.sub(r"[^\n]", " ", m.group(0)), text)
|
|
71
|
+
for spec in _imports(bare):
|
|
72
|
+
folder = package_dir(path, spec)
|
|
73
|
+
line = bare[:bare.find(f'"{spec}"')].count("\n") + 1
|
|
74
|
+
targets = [t for t in packages.get(folder, ()) if t != path] if folder is not None else []
|
|
75
|
+
if not targets:
|
|
76
|
+
dependencies.append({"source": path, "target": None, "specifier": spec, "line": line, "kind": "import",
|
|
77
|
+
"resolution": "external"})
|
|
78
|
+
dependencies.extend({"source": path, "target": target, "specifier": spec, "line": line, "kind": "import",
|
|
79
|
+
"resolution": "resolved"} for target in targets)
|
|
80
|
+
clause = _PACKAGE.search(bare)
|
|
81
|
+
if path.endswith("_test.go") and clause and not clause.group(1).endswith("_test"):
|
|
82
|
+
dependencies.extend({"source": path, "target": target, "specifier": posixpath.dirname(path) or ".",
|
|
83
|
+
"line": 1, "kind": "package", "resolution": "resolved"}
|
|
84
|
+
for target in packages.get(posixpath.dirname(path), ()))
|
|
85
|
+
return {"dependencies": dependencies, "parsed": parsed}
|
package/config.py
CHANGED
|
@@ -20,6 +20,9 @@ CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
|
|
|
20
20
|
# RLock: update_config() chama load/save sob o mesmo lock; Lock travaria a própria thread.
|
|
21
21
|
_LOCK = threading.RLock()
|
|
22
22
|
|
|
23
|
+
# The config fields that select a gateway model; every per-role table (recommended models, failure log) keys on these.
|
|
24
|
+
MODEL_ROLES = ("router_model", "scope_model", "scout_model", "research_model", "embedding_model", "rerank_model")
|
|
25
|
+
|
|
23
26
|
DEFAULT_CONFIG = {
|
|
24
27
|
"router_model": "",
|
|
25
28
|
"scope_model": "",
|
package/index_profile.py
CHANGED
|
@@ -12,10 +12,12 @@ KINDS = ("code", "test", "doc")
|
|
|
12
12
|
RETRY_AFTER_S = 3600
|
|
13
13
|
MAX_DIRS = 90
|
|
14
14
|
PROSE = {"md", "txt", "docx", "rst", "adoc"}
|
|
15
|
-
# Data and markup inside a documentation folder are reports, not code (GitHub Linguist
|
|
15
|
+
# Data and markup inside a documentation, design or data-dump folder are reports and mockups, not code (GitHub Linguist
|
|
16
|
+
# treats docs/ as documentation; a design/ HTML or a probe's JSON result pushed hook_decision.py out of the code block).
|
|
16
17
|
DOC_DATA = {"html", "htm", "json", "csv", "xml", "yaml", "yml", "svg"}
|
|
17
18
|
_NAME_DOC = re.compile(r"(^|/)(readme|changelog|license|licence|contributing|install|history|authors)(\.[^/]*)?$", re.I)
|
|
18
19
|
_DIR_DOC = re.compile(r"(^|/)(docs?|adrs?|documentation)/", re.I)
|
|
20
|
+
_DIR_DOC_DATA = re.compile(r"(^|/)(docs?|adrs?|documentation|designs?|references?|mockups?|prototypes?|data|datasets?|reports?|results?)/", re.I)
|
|
19
21
|
_TEST = re.compile(r"(^|/)(tests?|__tests__|spec|specs|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)test_[^/]+\.py$|"
|
|
20
22
|
r"(^|/)conftest\.py$|_test\.(py|go)$|\.(test|spec)(-d)?\.[cm]?[jt]sx?$|(^|/)[^/]*[a-z0-9](?-i:Tests?)\.java$|"
|
|
21
23
|
r"(^|/)src/test/|(^|/)test[-_][^/]*\.[cm]?[jt]sx?$|[-_]test\.[cm]?[jt]sx?$", re.I)
|
|
@@ -151,7 +153,7 @@ def kind(path, profile):
|
|
|
151
153
|
return _model_kind(path, groups or [{"path": ".", "kind": "doc"}])
|
|
152
154
|
if _TEST.search(path):
|
|
153
155
|
return "test"
|
|
154
|
-
if ext in DOC_DATA and
|
|
156
|
+
if ext in DOC_DATA and _DIR_DOC_DATA.search(path) and _explicit_kind(path, groups) != "code":
|
|
155
157
|
return "doc"
|
|
156
158
|
if not ext and _NAME_DOC.search(path):
|
|
157
159
|
return "doc"
|
package/model_defaults.py
CHANGED
|
@@ -11,7 +11,7 @@ import model_catalog
|
|
|
11
11
|
# first and price second (docs/research/2026-10-24-modelos-do-scout-e-dos-outros-papeis.md): the router answers pages
|
|
12
12
|
# and the scout reads code excerpts, where Claude Haiku 5.5 answered every request and led the no-index path; scope and
|
|
13
13
|
# research pick the GPT-5.4 nano, as accurate there at about half the price and without the long reasoning of the mini.
|
|
14
|
-
|
|
14
|
+
_CHOICES = {
|
|
15
15
|
"router_model": ("chat", "claude-haiku-5-5"),
|
|
16
16
|
"scope_model": ("chat", "gpt-5.4-nano"),
|
|
17
17
|
"scout_model": ("chat", "claude-haiku-5-5"),
|
|
@@ -19,10 +19,18 @@ RECOMMENDED_MODELS = {
|
|
|
19
19
|
"embedding_model": ("embedding", "text-embedding-3-small"),
|
|
20
20
|
"rerank_model": ("rerank", "rerank-3-5"),
|
|
21
21
|
}
|
|
22
|
+
RECOMMENDED_MODELS = {role: _CHOICES[role] for role in config.MODEL_ROLES}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _route_rank(model_id):
|
|
26
|
+
"""Vertex first (the owner's gateway is stable there), Bedrock only when nothing else serves the model."""
|
|
27
|
+
route = re.split(r"[/.:]", model_id)[0].lower()
|
|
28
|
+
return 0 if route.startswith("vertex") else 2 if route.startswith("bedrock") else 1
|
|
22
29
|
|
|
23
30
|
|
|
24
31
|
def _matching(catalog, name):
|
|
25
|
-
return next((model_id for model_id in sorted(catalog
|
|
32
|
+
return next((model_id for model_id in sorted(catalog, key=lambda found: (_route_rank(found), found))
|
|
33
|
+
if re.split(r"[/.:]", model_id)[-1] == name
|
|
26
34
|
or model_id.endswith(("/" + name, "." + name)) or model_id == name), None)
|
|
27
35
|
|
|
28
36
|
|
package/model_health.py
CHANGED
|
@@ -23,7 +23,6 @@ MUTE_S = 7 * 24 * 3600
|
|
|
23
23
|
KEEP_S = 7 * 24 * 3600
|
|
24
24
|
KEEP_FAILURES = 500
|
|
25
25
|
ERROR_CHARS = 300
|
|
26
|
-
_ROLES = ("router_model", "scope_model", "scout_model", "research_model", "embedding_model", "rerank_model")
|
|
27
26
|
_PATHS = ("/v1/chat/completions", "/v1/embeddings", "/v1/rerank")
|
|
28
27
|
_TOOL = contextvars.ContextVar("model_health_tool", default=None)
|
|
29
28
|
_COLLECTED = contextvars.ContextVar("model_health_collected", default=None)
|
|
@@ -60,7 +59,7 @@ def _tables(db):
|
|
|
60
59
|
def role_of(model, cfg=None):
|
|
61
60
|
"""Name of the config field that selects `model` ('other' when none does)."""
|
|
62
61
|
cfg = cfg if cfg is not None else config.load_config()
|
|
63
|
-
return next((field for field in
|
|
62
|
+
return next((field for field in config.MODEL_ROLES if cfg.get(field) == model), "other")
|
|
64
63
|
|
|
65
64
|
|
|
66
65
|
def error_text(exc):
|
package/model_upgrade.py
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@allansantos-dev/smart-tool",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.34",
|
|
4
4
|
"description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Allan Santos",
|
package/setup_audit.py
CHANGED
|
@@ -11,6 +11,7 @@ Usage: python setup_audit.py [--project DIR] [--no-probe] [--json]
|
|
|
11
11
|
"""
|
|
12
12
|
import argparse
|
|
13
13
|
import concurrent.futures
|
|
14
|
+
import glob
|
|
14
15
|
import hashlib
|
|
15
16
|
import json
|
|
16
17
|
import os
|
|
@@ -41,18 +42,22 @@ PROBE_MAX_AGE_S = 6 * 3600
|
|
|
41
42
|
SKILL_DESCRIPTION_MAX = 1536
|
|
42
43
|
SKILL_BUDGET_CHARS = 8000
|
|
43
44
|
CODEX_PROJECT_DOC_MAX = 32 * 1024
|
|
45
|
+
SMART_TOOL_SERVER = "smart-tool"
|
|
44
46
|
CLAUDE_TOOLS = ("Bash", "Read", "Grep", "Glob", "LS", "Edit", "MultiEdit", "Write", "NotebookEdit", "WebFetch",
|
|
45
47
|
"WebSearch", "Task")
|
|
46
48
|
CODEX_TOOLS = ("Bash", "apply_patch")
|
|
47
49
|
DECIDING_EVENTS = ("PreToolUse",)
|
|
48
50
|
DOCS = {"claude_hooks": "https://code.claude.com/docs/en/hooks.md", "codex_hooks": "https://learn.chatgpt.com/docs/hooks.md",
|
|
49
51
|
"claude_skills": "https://code.claude.com/docs/en/skills.md",
|
|
52
|
+
"claude_subagents": "https://code.claude.com/docs/en/sub-agents",
|
|
50
53
|
"codex_skills": "https://learn.chatgpt.com/docs/build-skills.md",
|
|
51
54
|
"claude_memory": "https://code.claude.com/docs/en/memory.md",
|
|
52
55
|
"codex_agents_md": "https://developers.openai.com/codex/guides/agents-md",
|
|
53
56
|
"claude_mcp": "https://code.claude.com/docs/en/mcp.md", "mcp_lifecycle":
|
|
54
57
|
"https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle"}
|
|
55
58
|
_SCRIPT_RE = re.compile(r"""["']?([^\s"'|&;<>]+\.(?:js|cjs|mjs|py|ps1|sh|bat|cmd|exe))["']?""", re.IGNORECASE)
|
|
59
|
+
# The quoted program text after -e / -c / -p / -Command / --eval: code, not a file the hook runs.
|
|
60
|
+
_INLINE_CODE_RE = re.compile(r"""\s(?:-e|-c|-p|-Command|--eval)\s+(?:"(?:[^"\\]|\\.)*"|'[^']*')""", re.IGNORECASE)
|
|
56
61
|
_SILENCED_RE = re.compile(r"\|\|\s*(?:true|exit\s+0)\b|2>\s*(?:/dev/null|nul)\b|;\s*exit\s+0\s*$", re.IGNORECASE)
|
|
57
62
|
_IMPORT_RE = re.compile(r"(?<![\w`])@((?:~|\.{1,2})?/?[\w.\-/\\]+\.\w+)")
|
|
58
63
|
|
|
@@ -204,6 +209,20 @@ def _approvals(data, approval):
|
|
|
204
209
|
approval["off"].update(data.get("disabledMcpjsonServers") or [])
|
|
205
210
|
|
|
206
211
|
|
|
212
|
+
def _agents(snapshot, folder, scope):
|
|
213
|
+
"""Subagent definitions (*.md with frontmatter) in folder: name, scope, path and `tools` (None when the field is
|
|
214
|
+
absent, which makes the agent inherit every tool, MCP included)."""
|
|
215
|
+
found = []
|
|
216
|
+
for path in sorted(glob.glob(os.path.join(folder, "*.md"))):
|
|
217
|
+
meta = _frontmatter(snapshot.read(path))
|
|
218
|
+
tools = meta.get("tools")
|
|
219
|
+
if isinstance(tools, str):
|
|
220
|
+
tools = [item.strip() for item in tools.split(",") if item.strip()]
|
|
221
|
+
found.append({"name": str(meta.get("name") or os.path.basename(path)[:-3]), "scope": scope, "path": path,
|
|
222
|
+
"tools": [str(item) for item in tools] if isinstance(tools, list) else None})
|
|
223
|
+
return found
|
|
224
|
+
|
|
225
|
+
|
|
207
226
|
def collect_claude(project, snapshot):
|
|
208
227
|
"""Claude Code's effective set for project: settings layers, enabled plugins, hooks, MCP, skills, CLAUDE.md."""
|
|
209
228
|
layers = [(path, "managed") for path in _managed_settings()] + [
|
|
@@ -234,6 +253,8 @@ def collect_claude(project, snapshot):
|
|
|
234
253
|
mcp += [e for e in shared if e["name"] not in unapproved]
|
|
235
254
|
skills = _skills(snapshot, os.path.join(endpoint_sync.claude_dir(), "skills"), "user", disabled=disabled_skills)
|
|
236
255
|
skills += _skills(snapshot, os.path.join(project, ".claude", "skills"), "project", disabled=disabled_skills)
|
|
256
|
+
agents = _agents(snapshot, os.path.join(endpoint_sync.claude_dir(), "agents"), "user")
|
|
257
|
+
agents += _agents(snapshot, os.path.join(project, ".claude", "agents"), "project")
|
|
237
258
|
plugins = []
|
|
238
259
|
installed = snapshot.json(os.path.join(endpoint_sync.claude_dir(), "plugins", "installed_plugins.json")) or {}
|
|
239
260
|
for plugin_id, installs in (installed.get("plugins") or {}).items():
|
|
@@ -261,10 +282,64 @@ def collect_claude(project, snapshot):
|
|
|
261
282
|
text = snapshot.read(path)
|
|
262
283
|
if text is not None:
|
|
263
284
|
instructions.append({"path": path, "kind": kind, "bytes": len(text.encode("utf-8")), "_text": text})
|
|
264
|
-
return {"settings": settings, "plugins": plugins, "hooks": hooks, "mcp": mcp, "skills": skills,
|
|
285
|
+
return {"settings": settings, "plugins": plugins, "hooks": hooks, "mcp": mcp, "skills": skills, "agents": agents,
|
|
265
286
|
"instructions": instructions, "unapproved_mcp": unapproved}
|
|
266
287
|
|
|
267
288
|
|
|
289
|
+
def _codex_plugin_root(market, name):
|
|
290
|
+
"""Newest installed version folder of a Codex plugin (`plugins/cache/<marketplace>/<name>/<version>/`), skipping
|
|
291
|
+
the `latest` alias unless it is all there is; None when the plugin is not in the cache."""
|
|
292
|
+
base = os.path.join(codex_dir(), "plugins", "cache", market, name)
|
|
293
|
+
if not os.path.isdir(base):
|
|
294
|
+
return None
|
|
295
|
+
versions = [os.path.join(base, entry) for entry in os.listdir(base) if os.path.isdir(os.path.join(base, entry))]
|
|
296
|
+
real = [path for path in versions if os.path.basename(path) != "latest"]
|
|
297
|
+
return max(real or versions, key=os.path.getmtime) if versions else None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _codex_plugin_path(manifest, key, root, defaults):
|
|
301
|
+
"""Path a Codex plugin manifest names for `key` (relative to the plugin root), else the first default that exists."""
|
|
302
|
+
named = manifest.get(key)
|
|
303
|
+
if isinstance(named, str):
|
|
304
|
+
return os.path.join(root, named)
|
|
305
|
+
return next((os.path.join(root, d) for d in defaults if os.path.exists(os.path.join(root, d))), None)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _collect_codex_plugins(config, snapshot):
|
|
309
|
+
"""Skills, hooks and MCP servers of the Codex plugins enabled in config.toml (`[plugins."<name>@<marketplace>"]`
|
|
310
|
+
unless `enabled = false`), read from the plugin cache: manifest `.codex-plugin/plugin.json`, `skills/`,
|
|
311
|
+
`hooks/hooks.json`, `.mcp.json`. A plugin whose folder or manifest is missing is reported as not verified."""
|
|
312
|
+
plugins, hooks, mcp, skills = [], [], [], []
|
|
313
|
+
for plugin_id, options in (config.get("plugins") or {}).items():
|
|
314
|
+
if isinstance(options, dict) and options.get("enabled") is False:
|
|
315
|
+
continue
|
|
316
|
+
name, _, market = plugin_id.rpartition("@")
|
|
317
|
+
root = _codex_plugin_root(market, name) if name else None
|
|
318
|
+
if not root:
|
|
319
|
+
snapshot.unverified.append(f"Codex plugin {plugin_id}: not found under {os.path.join(codex_dir(), 'plugins', 'cache')}")
|
|
320
|
+
continue
|
|
321
|
+
manifest_path = next((p for p in (os.path.join(root, ".codex-plugin", "plugin.json"),
|
|
322
|
+
os.path.join(root, "plugin.json")) if os.path.exists(p)), None)
|
|
323
|
+
manifest = snapshot.json(manifest_path) if manifest_path else None
|
|
324
|
+
if manifest is None:
|
|
325
|
+
snapshot.unverified.append(f"Codex plugin {plugin_id}: no readable manifest (.codex-plugin/plugin.json) in {root}")
|
|
326
|
+
continue
|
|
327
|
+
plugins.append({"id": plugin_id, "path": root})
|
|
328
|
+
hook_file = _codex_plugin_path(manifest, "hooks", root, (os.path.join("hooks", "hooks.json"),))
|
|
329
|
+
hooks += _hooks_from(snapshot.json(hook_file) if hook_file else None, f"plugin:{name}", root)
|
|
330
|
+
mcp_file = _codex_plugin_path(manifest, "mcpServers", root, (".mcp.json", "mcp.json"))
|
|
331
|
+
plugin_mcp = snapshot.json(mcp_file) if mcp_file else None
|
|
332
|
+
if plugin_mcp is None and isinstance(manifest.get("mcpServers"), dict):
|
|
333
|
+
plugin_mcp = manifest["mcpServers"]
|
|
334
|
+
plugin_mcp = plugin_mcp or {}
|
|
335
|
+
mcp += _mcp_entries(plugin_mcp.get("mcpServers", plugin_mcp), f"plugin:{name}", mcp_file or manifest_path,
|
|
336
|
+
f"plugin:{name}:", {"PLUGIN_ROOT": root})
|
|
337
|
+
skill_dir = _codex_plugin_path(manifest, "skills", root, ("skills",))
|
|
338
|
+
skills += [{**s, "name": f"{name}:{s['name']}"} for s in
|
|
339
|
+
_skills(snapshot, skill_dir, f"plugin:{name}", recursive=True)] if skill_dir else []
|
|
340
|
+
return plugins, hooks, mcp, skills
|
|
341
|
+
|
|
342
|
+
|
|
268
343
|
def collect_codex(project, snapshot):
|
|
269
344
|
"""Codex's effective set for project: config.toml (MCP, hooks), hooks.json, skills, AGENTS.md chain."""
|
|
270
345
|
config = snapshot.toml(os.path.join(codex_dir(), "config.toml")) or {}
|
|
@@ -274,6 +349,10 @@ def collect_codex(project, snapshot):
|
|
|
274
349
|
skills = _skills(snapshot, os.path.join(codex_dir(), "skills"), "user", recursive=True)
|
|
275
350
|
skills += _skills(snapshot, _home(".agents", "skills"), "user-agents")
|
|
276
351
|
skills += _skills(snapshot, os.path.join(project, ".agents", "skills"), "project")
|
|
352
|
+
plugins, plugin_hooks, plugin_mcp, plugin_skills = _collect_codex_plugins(config, snapshot)
|
|
353
|
+
hooks += plugin_hooks
|
|
354
|
+
mcp += plugin_mcp
|
|
355
|
+
skills += plugin_skills
|
|
277
356
|
instructions = []
|
|
278
357
|
for name in ("AGENTS.override.md", "AGENTS.md"):
|
|
279
358
|
text = snapshot.read(os.path.join(codex_dir(), name))
|
|
@@ -292,8 +371,7 @@ def collect_codex(project, snapshot):
|
|
|
292
371
|
instructions.append({"path": os.path.join(folder, name), "kind": "project",
|
|
293
372
|
"bytes": len(text.encode("utf-8")), "_text": text})
|
|
294
373
|
break
|
|
295
|
-
return {"hooks": hooks, "mcp": mcp, "skills": skills, "instructions": instructions
|
|
296
|
-
"plugins_unverified": bool(config.get("plugins"))}
|
|
374
|
+
return {"plugins": plugins, "hooks": hooks, "mcp": mcp, "skills": skills, "instructions": instructions}
|
|
297
375
|
|
|
298
376
|
|
|
299
377
|
def _finding(check, severity, harness, subject, detail, source, evidence=()):
|
|
@@ -336,8 +414,9 @@ def check_hook_overlap(harness, hooks, tools):
|
|
|
336
414
|
|
|
337
415
|
|
|
338
416
|
def _expand(token, root):
|
|
339
|
-
"""A hook script path with ${CLAUDE_PLUGIN_ROOT}, ~ and environment variables expanded."""
|
|
340
|
-
|
|
417
|
+
"""A hook script path with ${CLAUDE_PLUGIN_ROOT} / ${PLUGIN_ROOT}, ~ and environment variables expanded."""
|
|
418
|
+
for variable in ("CLAUDE_PLUGIN_ROOT", "PLUGIN_ROOT"):
|
|
419
|
+
token = token.replace("${%s}" % variable, root or "${%s}" % variable)
|
|
341
420
|
return os.path.expandvars(os.path.expanduser(token))
|
|
342
421
|
|
|
343
422
|
|
|
@@ -348,7 +427,9 @@ def check_hook_targets(harness, hooks, mcp_names, probe):
|
|
|
348
427
|
for hook in hooks:
|
|
349
428
|
where = f"{hook['source']} {hook['event']} {hook['matcher'] or '*'}"
|
|
350
429
|
if hook["type"] == "command" and hook["command"]:
|
|
351
|
-
for token in _SCRIPT_RE.findall(hook["command"]):
|
|
430
|
+
for token in _SCRIPT_RE.findall(_INLINE_CODE_RE.sub(" ", hook["command"])):
|
|
431
|
+
if not re.search(r"[\\/]", token) and shutil.which(token):
|
|
432
|
+
continue
|
|
352
433
|
path = _expand(token, hook.get("root"))
|
|
353
434
|
if "$" in path or "%" in path:
|
|
354
435
|
continue
|
|
@@ -384,6 +465,24 @@ def _reachable(url):
|
|
|
384
465
|
return str(exc)
|
|
385
466
|
|
|
386
467
|
|
|
468
|
+
def check_agents(claude):
|
|
469
|
+
"""Subagents whose `tools` list names no Smart Tool tool while the smart-tool server is registered: the redirect
|
|
470
|
+
leaves them alone (nothing to redirect to), so they search folders with Grep and Read at full cost. A subagent
|
|
471
|
+
with no `tools` field inherits every tool, MCP included; with the field, only what it lists."""
|
|
472
|
+
if not any(entry["name"] == SMART_TOOL_SERVER for entry in claude["mcp"]):
|
|
473
|
+
return []
|
|
474
|
+
blind = [a for a in claude.get("agents", []) if a["tools"] is not None
|
|
475
|
+
and not any(tool == "*" or tool.startswith(f"mcp__{SMART_TOOL_SERVER}") for tool in a["tools"])]
|
|
476
|
+
if not blind:
|
|
477
|
+
return []
|
|
478
|
+
names = ", ".join(sorted(a["name"] for a in blind)[:8]) + (" ..." if len(blind) > 8 else "")
|
|
479
|
+
return [_finding("agent_without_smart_tool", "warn", "claude", "subagents without Smart Tool",
|
|
480
|
+
f"{len(blind)} subagent(s) list tools but none of mcp__{SMART_TOOL_SERVER}__*: {names}. Add "
|
|
481
|
+
f"mcp__{SMART_TOOL_SERVER}__smart_search and mcp__{SMART_TOOL_SERVER}__smart_search_result to "
|
|
482
|
+
"their `tools`, or drop the `tools` line to inherit every tool.", DOCS["claude_subagents"],
|
|
483
|
+
[a["path"] for a in blind[:8]])]
|
|
484
|
+
|
|
485
|
+
|
|
387
486
|
def check_skills(harness, skills):
|
|
388
487
|
"""Skill name collisions, descriptions over Claude's per-skill cut, and the total over the list budget."""
|
|
389
488
|
findings, by_name = [], {}
|
|
@@ -635,6 +734,7 @@ def run(project, probe=True, save=True, probe_max_age=0, trigger="manual"):
|
|
|
635
734
|
results = (probe_mcp(data["mcp"], project, max_age=probe_max_age) if probe
|
|
636
735
|
else [None] * len(data["mcp"]))
|
|
637
736
|
findings += check_mcp(harness, data["mcp"], results)
|
|
737
|
+
findings += check_agents(claude)
|
|
638
738
|
findings += check_instructions(project, claude, codex)
|
|
639
739
|
unverified = list(snapshot.unverified)
|
|
640
740
|
if not probe:
|
|
@@ -642,8 +742,6 @@ def run(project, probe=True, save=True, probe_max_age=0, trigger="manual"):
|
|
|
642
742
|
for name in claude["unapproved_mcp"]:
|
|
643
743
|
unverified.append(f".mcp.json server '{name}' is not approved in Claude Code, so it is neither loaded nor "
|
|
644
744
|
"started here")
|
|
645
|
-
if codex.get("plugins_unverified"):
|
|
646
|
-
unverified.append("Codex plugins (their skills and hooks) are not collected yet")
|
|
647
745
|
unverified += ["skill trigger overlap and contradictions between instructions need a model (not run)",
|
|
648
746
|
"the order between two parallel rewrites is undocumented and cannot be checked"]
|
|
649
747
|
key = hashlib.sha256(json.dumps(sorted(snapshot.files.items())).encode()).hexdigest()[:16]
|
package/version.py
CHANGED