@allansantos-dev/smart-tool 0.9.31 → 0.9.33
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 +26 -0
- package/README.md +24 -9
- package/affected_tests.py +8 -2
- package/autostart.py +4 -0
- 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_client.py +2 -0
- package/model_defaults.py +10 -2
- package/model_health.py +163 -0
- package/model_upgrade.py +140 -0
- package/notice_action.py +58 -0
- package/package.json +1 -1
- package/setup_audit.py +106 -8
- package/setup_ui.py +119 -1
- package/smart_tool_daemon.py +28 -7
- package/toast_notice.py +99 -0
- package/tray.py +39 -0
- package/version.py +1 -1
package/model_upgrade.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Tells who updated that this version recommends other models than the ones the config holds, and applies them only on
|
|
2
|
+
consent. `model_defaults.ensure_recommended_models` fills empty fields only, so a user who updated keeps the previous
|
|
3
|
+
version's defaults forever; this module is the visible, reversible way out.
|
|
4
|
+
|
|
5
|
+
State lives in `config.json` under `defaults_notice`, tied to DEFAULTS_VERSION: pending (announced once a day until the
|
|
6
|
+
user answers), declined (silent for this version), applied (the replaced models are kept for `revert`). Raising
|
|
7
|
+
DEFAULTS_VERSION when RECOMMENDED_MODELS changes starts a new notice for everyone. Embedding and rerank are never
|
|
8
|
+
offered: changing them invalidates every index.
|
|
9
|
+
"""
|
|
10
|
+
import secrets
|
|
11
|
+
import time
|
|
12
|
+
|
|
13
|
+
import config
|
|
14
|
+
import model_catalog
|
|
15
|
+
import model_defaults
|
|
16
|
+
|
|
17
|
+
DEFAULTS_VERSION = 2
|
|
18
|
+
ROLES = ("router_model", "scope_model", "scout_model", "research_model")
|
|
19
|
+
REMIND_EVERY_S = 24 * 3600
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _current(cfg):
|
|
23
|
+
"""The notice of this defaults version, or a blank one."""
|
|
24
|
+
notice = cfg.get("defaults_notice") or {}
|
|
25
|
+
return notice if notice.get("version") == DEFAULTS_VERSION else {"version": DEFAULTS_VERSION, "state": "none"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _differences(cfg, chat):
|
|
29
|
+
"""{role: {"current", "new"}} for each role the user has set to something other than the recommended model that
|
|
30
|
+
this gateway's catalog has."""
|
|
31
|
+
found = {}
|
|
32
|
+
for role in ROLES:
|
|
33
|
+
wanted = model_defaults._matching(chat, model_defaults.RECOMMENDED_MODELS[role][1])
|
|
34
|
+
if wanted and cfg.get(role) and cfg[role] != wanted:
|
|
35
|
+
found[role] = {"current": cfg[role], "new": wanted}
|
|
36
|
+
return found
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def check():
|
|
40
|
+
"""Recomputes what is on offer against the gateway catalog and stores it. Raises when the catalog is unreachable
|
|
41
|
+
(the caller shows that; no answer is invented). Returns `status()`."""
|
|
42
|
+
cfg = config.load_config()
|
|
43
|
+
notice = _current(cfg)
|
|
44
|
+
if notice["state"] in ("declined", "applied"):
|
|
45
|
+
return status()
|
|
46
|
+
found = _differences(cfg, set(model_catalog.list_models("chat")))
|
|
47
|
+
|
|
48
|
+
def store(current):
|
|
49
|
+
notice = dict(_current(current))
|
|
50
|
+
if not found:
|
|
51
|
+
notice.update(state="none", changes={}, nonce="")
|
|
52
|
+
else:
|
|
53
|
+
notice.update(state="pending", changes=found, nonce=notice.get("nonce") or secrets.token_hex(8))
|
|
54
|
+
current["defaults_notice"] = notice
|
|
55
|
+
|
|
56
|
+
config.update_config(store)
|
|
57
|
+
return status()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def status(cfg=None):
|
|
61
|
+
"""What the setup screen and the tray read: state, the model changes on offer, and what `revert` would restore."""
|
|
62
|
+
notice = _current(cfg if cfg is not None else config.load_config())
|
|
63
|
+
return {"version": DEFAULTS_VERSION, "state": notice["state"], "changes": notice.get("changes") or {},
|
|
64
|
+
"previous": notice.get("previous") or {}, "nonce": notice.get("nonce", "")}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _change(mutate_notice, nonce):
|
|
68
|
+
"""Runs `mutate_notice(cfg, notice)` under the config lock on a pending notice; `nonce` None (the setup screen,
|
|
69
|
+
which has its own token) skips the link check, a toast button must carry the nonce it was shown with."""
|
|
70
|
+
result = {}
|
|
71
|
+
|
|
72
|
+
def apply(cfg):
|
|
73
|
+
notice = dict(_current(cfg))
|
|
74
|
+
if notice["state"] != "pending":
|
|
75
|
+
raise ValueError(f"No new default models are waiting (state: {notice['state']}).")
|
|
76
|
+
if nonce is not None and not secrets.compare_digest(str(nonce), str(notice.get("nonce", ""))):
|
|
77
|
+
raise ValueError("This notification is out of date; open the settings to see the current offer.")
|
|
78
|
+
result["text"] = mutate_notice(cfg, notice)
|
|
79
|
+
cfg["defaults_notice"] = notice
|
|
80
|
+
|
|
81
|
+
config.update_config(apply)
|
|
82
|
+
return result["text"]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def apply(nonce=None):
|
|
86
|
+
"""Switches the offered roles to the recommended models and keeps the previous ones for `revert`."""
|
|
87
|
+
def mutate(cfg, notice):
|
|
88
|
+
# A role the user changed since the offer was made is theirs: it is skipped, never overwritten.
|
|
89
|
+
todo = {role: change for role, change in notice["changes"].items() if cfg.get(role) == change["current"]}
|
|
90
|
+
if not todo:
|
|
91
|
+
raise ValueError("The offered roles were changed since the notice; open the settings to see the current state.")
|
|
92
|
+
notice["previous"] = {role: change["current"] for role, change in todo.items()}
|
|
93
|
+
for role, change in todo.items():
|
|
94
|
+
cfg[role] = change["new"]
|
|
95
|
+
notice.update(state="applied", applied_at=time.time())
|
|
96
|
+
return "applied: " + ", ".join(f"{role} {change['current']} -> {change['new']}" for role, change in todo.items())
|
|
97
|
+
return _change(mutate, nonce)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def decline(nonce=None):
|
|
101
|
+
"""Stops announcing this defaults version; the user's models stay as they are."""
|
|
102
|
+
def mutate(cfg, notice):
|
|
103
|
+
notice["state"] = "declined"
|
|
104
|
+
return "declined: no more notices for this version of the defaults"
|
|
105
|
+
return _change(mutate, nonce)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def revert():
|
|
109
|
+
"""Puts back the models `apply` replaced. Raises when nothing was applied."""
|
|
110
|
+
done = {}
|
|
111
|
+
|
|
112
|
+
def put_back(cfg):
|
|
113
|
+
notice = dict(_current(cfg))
|
|
114
|
+
if notice["state"] != "applied" or not notice.get("previous"):
|
|
115
|
+
raise ValueError("Nothing to revert: the recommended models were not applied.")
|
|
116
|
+
for role, model in notice["previous"].items():
|
|
117
|
+
cfg[role] = model
|
|
118
|
+
done["text"] = "reverted: " + ", ".join(f"{role} -> {model}" for role, model in notice["previous"].items())
|
|
119
|
+
notice.update(state="declined", previous={})
|
|
120
|
+
cfg["defaults_notice"] = notice
|
|
121
|
+
|
|
122
|
+
config.update_config(put_back)
|
|
123
|
+
return done["text"]
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def due(now=None):
|
|
127
|
+
"""The offer the tray should announce now (pending and not announced in the last 24 h), or None. Marks it
|
|
128
|
+
announced, so a second call in the same day returns None; closing the toast without choosing brings it back."""
|
|
129
|
+
now = now or time.time()
|
|
130
|
+
taken = {}
|
|
131
|
+
|
|
132
|
+
def mark(cfg):
|
|
133
|
+
notice = dict(_current(cfg))
|
|
134
|
+
if notice["state"] == "pending" and notice.get("changes") and now - notice.get("last_notified", 0) >= REMIND_EVERY_S:
|
|
135
|
+
notice["last_notified"] = now
|
|
136
|
+
cfg["defaults_notice"] = notice
|
|
137
|
+
taken["offer"] = {"changes": notice["changes"], "nonce": notice["nonce"]}
|
|
138
|
+
|
|
139
|
+
config.update_config(mark)
|
|
140
|
+
return taken.get("offer")
|
package/notice_action.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Runs a toast button: `pythonw notice_action.py "smart-tool-action:<action>?<query>"`.
|
|
3
|
+
|
|
4
|
+
Started by Windows through the protocol registered in `toast_notice.register`, with no window. Every outcome (done or
|
|
5
|
+
refused) is appended to `notice-actions.log` in the data folder, because nothing else can show an error here.
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import webbrowser
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
14
|
+
import model_upgrade
|
|
15
|
+
import paths
|
|
16
|
+
import toast_notice
|
|
17
|
+
|
|
18
|
+
LOG_PATH = os.path.join(paths.DATA_DIR, "notice-actions.log")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def run(uri):
|
|
22
|
+
"""Returns a short result line. Raises ValueError for a link that is not ours or an action that does not exist."""
|
|
23
|
+
prefix = toast_notice.PROTOCOL + ":"
|
|
24
|
+
if not uri.startswith(prefix):
|
|
25
|
+
raise ValueError(f"Not a Smart Tool link: {uri[:80]!r}")
|
|
26
|
+
action, _, query = uri[len(prefix):].partition("?")
|
|
27
|
+
params = dict(urllib.parse.parse_qsl(query))
|
|
28
|
+
if action == "models-apply":
|
|
29
|
+
return model_upgrade.apply(params.get("n", ""))
|
|
30
|
+
if action == "models-decline":
|
|
31
|
+
return model_upgrade.decline(params.get("n", ""))
|
|
32
|
+
if action == "open-setup":
|
|
33
|
+
import daemon_launcher
|
|
34
|
+
webbrowser.open(daemon_launcher.ensure_daemon_running().rstrip("/") + "/setup#" + params.get("section", "models"))
|
|
35
|
+
return "setup opened"
|
|
36
|
+
if action == "mute-model":
|
|
37
|
+
import model_health
|
|
38
|
+
return model_health.mute(params.get("model", ""))
|
|
39
|
+
raise ValueError(f"Unknown action {action!r}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main(argv):
|
|
43
|
+
"""Runs the link in `argv[1]` and logs the outcome; returns the process exit code (0 done, 1 refused)."""
|
|
44
|
+
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
|
|
45
|
+
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
46
|
+
try:
|
|
47
|
+
line = f"{stamp} ok {argv[1] if len(argv) > 1 else ''} -> {run(argv[1])}"
|
|
48
|
+
code = 0
|
|
49
|
+
except Exception as exc:
|
|
50
|
+
line = f"{stamp} FAIL {argv[1] if len(argv) > 1 else ''} -> {type(exc).__name__}: {exc}"
|
|
51
|
+
code = 1
|
|
52
|
+
with open(LOG_PATH, "a", encoding="utf-8") as log:
|
|
53
|
+
log.write(line + "\n")
|
|
54
|
+
return code
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
sys.exit(main(sys.argv))
|
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.33",
|
|
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/setup_ui.py
CHANGED
|
@@ -18,6 +18,7 @@ import json
|
|
|
18
18
|
import os
|
|
19
19
|
import re
|
|
20
20
|
import secrets
|
|
21
|
+
import time
|
|
21
22
|
|
|
22
23
|
import autostart
|
|
23
24
|
import block_patterns
|
|
@@ -29,6 +30,8 @@ import endpoint_sync
|
|
|
29
30
|
import gateway
|
|
30
31
|
import model_catalog
|
|
31
32
|
import model_defaults
|
|
33
|
+
import model_health
|
|
34
|
+
import model_upgrade
|
|
32
35
|
import local_embedder
|
|
33
36
|
import project_store
|
|
34
37
|
import secret_store
|
|
@@ -155,6 +158,56 @@ def handle_models_get():
|
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
|
|
161
|
+
_UPGRADE_CHECK_EVERY_S = 3600
|
|
162
|
+
_upgrade_checked = {"at": 0.0, "error": ""}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _checked_upgrade(force=False):
|
|
166
|
+
"""The model offer, rechecked against the catalog at most once an hour (or when forced). A catalog that cannot be
|
|
167
|
+
read is reported in `check_error`, with the last stored offer, never as 'nothing to offer'."""
|
|
168
|
+
now = time.time()
|
|
169
|
+
if force or now - _upgrade_checked["at"] >= _UPGRADE_CHECK_EVERY_S:
|
|
170
|
+
try:
|
|
171
|
+
model_upgrade.check()
|
|
172
|
+
_upgrade_checked["error"] = ""
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
_upgrade_checked["error"] = str(exc)[:300]
|
|
175
|
+
_upgrade_checked["at"] = now
|
|
176
|
+
return {**model_upgrade.status(), "check_error": _upgrade_checked["error"]}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def handle_upgrade_get():
|
|
180
|
+
return _checked_upgrade(force=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def handle_upgrade_action(body):
|
|
184
|
+
action = body.get("action") if isinstance(body, dict) else None
|
|
185
|
+
try:
|
|
186
|
+
if action == "apply":
|
|
187
|
+
text = model_upgrade.apply()
|
|
188
|
+
elif action == "decline":
|
|
189
|
+
text = model_upgrade.decline()
|
|
190
|
+
elif action == "revert":
|
|
191
|
+
text = model_upgrade.revert()
|
|
192
|
+
else:
|
|
193
|
+
raise SetupError("Invalid action: use apply, decline or revert.")
|
|
194
|
+
except (ValueError, config.ConfigCorruptedError, OSError) as exc:
|
|
195
|
+
raise SetupError(str(exc))
|
|
196
|
+
return {"ok": True, "message": text, "upgrade": model_upgrade.status()}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def handle_health_get():
|
|
200
|
+
return model_health.summary()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def handle_notices():
|
|
204
|
+
"""What the tray should show now: the model offer (once a day until answered) and models over the failure limit
|
|
205
|
+
(once a day each). Fetching marks them announced, so each toast is shown once per reminder period."""
|
|
206
|
+
upgrade = _checked_upgrade()
|
|
207
|
+
offer = model_upgrade.due() if upgrade["state"] == "pending" else None
|
|
208
|
+
return {"upgrade": offer, "failing": model_health.alerts_due(), "check_error": upgrade["check_error"]}
|
|
209
|
+
|
|
210
|
+
|
|
158
211
|
def handle_local_models_get():
|
|
159
212
|
available = local_embedder.info() if local_embedder.configured() else None
|
|
160
213
|
return {"mode": config.local_models_mode(), "modes": list(config.LOCAL_MODEL_MODES),
|
|
@@ -671,6 +724,24 @@ _PAGE_TEMPLATE = """<!doctype html>
|
|
|
671
724
|
<div class="msg" id="gateway-msg" role="status"></div>
|
|
672
725
|
</div>
|
|
673
726
|
|
|
727
|
+
<div class="card" id="upgrade-card" hidden>
|
|
728
|
+
<div class="card-head"><h3>New recommended models</h3><span class="pill" id="upgrade-pill" data-tone="warn">Available</span></div>
|
|
729
|
+
<p class="lede">This version recommends other models than the ones you use. Nothing changes until you apply; the models you have now are kept so you can switch back.</p>
|
|
730
|
+
<ul id="upgrade-list"></ul>
|
|
731
|
+
<div class="actions">
|
|
732
|
+
<button type="button" class="primary" id="btn-upgrade-apply">Apply recommended models</button>
|
|
733
|
+
<button type="button" id="btn-upgrade-decline">Keep mine, don't ask again</button>
|
|
734
|
+
<button type="button" id="btn-upgrade-revert" hidden>Switch back to my previous models</button>
|
|
735
|
+
</div>
|
|
736
|
+
<div class="msg" id="upgrade-msg" role="status"></div>
|
|
737
|
+
</div>
|
|
738
|
+
|
|
739
|
+
<div class="card" id="health-card">
|
|
740
|
+
<div class="card-head"><h3>Model failures (last 24 h)</h3><span class="pill" id="health-pill" data-tone="busy">Checking</span></div>
|
|
741
|
+
<p class="lede">Every failed call to a gateway model, by role and tool. A model that fails half of its calls (5 or more) also raises a Windows notification.</p>
|
|
742
|
+
<div id="health-table"></div>
|
|
743
|
+
</div>
|
|
744
|
+
|
|
674
745
|
<div class="card" id="models-card">
|
|
675
746
|
<div class="card-head"><h3>2. Models</h3><span class="pill" id="models-pill" data-tone="busy">Checking</span></div>
|
|
676
747
|
<p class="lede">Any model id your gateway accepts. The gateway's catalog shows up as suggestions while you type.</p>
|
|
@@ -1048,6 +1119,52 @@ async function loadModels() {
|
|
|
1048
1119
|
setMsg("models-msg", "error", "Could not read the gateway catalog: " + error.message);
|
|
1049
1120
|
}
|
|
1050
1121
|
}
|
|
1122
|
+
|
|
1123
|
+
function esc(text) { const el = document.createElement("span"); el.textContent = text; return el.innerHTML; }
|
|
1124
|
+
async function loadUpgrade() {
|
|
1125
|
+
try {
|
|
1126
|
+
const data = await getJSON("/setup/models/upgrade");
|
|
1127
|
+
const rows = Object.entries(data.changes || {}).map(([role, change]) => `<li><b>${esc(role)}</b>: ${esc(change.current)} → ${esc(change.new)}</li>`);
|
|
1128
|
+
const offer = data.state === "pending" && rows.length;
|
|
1129
|
+
const applied = data.state === "applied" && Object.keys(data.previous || {}).length;
|
|
1130
|
+
$("upgrade-card").hidden = !(offer || applied);
|
|
1131
|
+
$("upgrade-list").innerHTML = offer ? rows.join("") : Object.entries(data.previous).map(([role, model]) => `<li><b>${esc(role)}</b> was ${esc(model)}</li>`).join("");
|
|
1132
|
+
setPill("upgrade-pill", offer ? "warn" : "ok", offer ? "Available" : "Applied");
|
|
1133
|
+
$("btn-upgrade-apply").hidden = $("btn-upgrade-decline").hidden = !offer;
|
|
1134
|
+
$("btn-upgrade-revert").hidden = !applied;
|
|
1135
|
+
if (data.check_error) setMsg("upgrade-msg", "error", "Could not check the catalog: " + data.check_error);
|
|
1136
|
+
} catch (error) {
|
|
1137
|
+
$("upgrade-card").hidden = false;
|
|
1138
|
+
setMsg("upgrade-msg", "error", "Could not read the model offer: " + error.message);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
for (const [id, action] of [["btn-upgrade-apply", "apply"], ["btn-upgrade-decline", "decline"], ["btn-upgrade-revert", "revert"]]) {
|
|
1142
|
+
$(id).addEventListener("click", async () => {
|
|
1143
|
+
setMsg("upgrade-msg", "", "Working…");
|
|
1144
|
+
try {
|
|
1145
|
+
const result = await post("/setup/models/upgrade", { action });
|
|
1146
|
+
await loadUpgrade();
|
|
1147
|
+
await loadModels();
|
|
1148
|
+
setMsg("upgrade-msg", "ok", result.message);
|
|
1149
|
+
} catch (error) {
|
|
1150
|
+
setMsg("upgrade-msg", "error", error.message);
|
|
1151
|
+
}
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1154
|
+
async function loadHealth() {
|
|
1155
|
+
try {
|
|
1156
|
+
const data = await getJSON("/setup/models/health");
|
|
1157
|
+
const failing = data.models.filter((row) => row.failing).length;
|
|
1158
|
+
setPill("health-pill", failing ? "bad" : "ok", failing ? plural(failing, "model") + " failing" : "No problem");
|
|
1159
|
+
const models = data.models.map((row) => `<tr><td>${esc(row.role)}</td><td>${esc(row.model)}</td><td>${row.errors} / ${row.calls}</td><td>${Math.round(row.rate * 100)}%</td></tr>`).join("");
|
|
1160
|
+
const failures = data.failures.map((row) => `<tr><td>${esc(new Date(row.at * 1000).toLocaleString())}</td><td>${esc(row.role)}</td><td>${esc(row.model)}</td><td>${esc(row.tool || "-")}</td><td>${esc(row.error)}</td></tr>`).join("");
|
|
1161
|
+
$("health-table").innerHTML = (models ? `<table><tr><th>Role</th><th>Model</th><th>Failed / calls</th><th>Rate</th></tr>${models}</table>` : "<p>No model call in the last 24 hours.</p>")
|
|
1162
|
+
+ (failures ? `<table><tr><th>When</th><th>Role</th><th>Model</th><th>Tool</th><th>Error</th></tr>${failures}</table>` : "");
|
|
1163
|
+
} catch (error) {
|
|
1164
|
+
setPill("health-pill", "bad", "Unavailable");
|
|
1165
|
+
$("health-table").textContent = "Could not read the failure log: " + error.message;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1051
1168
|
$("btn-save-models").addEventListener("click", async () => {
|
|
1052
1169
|
if (!modelsLoaded) { setMsg("models-msg", "error", "The catalog has not loaded yet; nothing was saved."); return; }
|
|
1053
1170
|
setMsg("models-msg", "", "Saving…");
|
|
@@ -1809,7 +1926,8 @@ $("btn-audit-run").addEventListener("click", async () => {
|
|
|
1809
1926
|
|
|
1810
1927
|
renderGateway();
|
|
1811
1928
|
renderOverview();
|
|
1812
|
-
if (status.gateway.status === "ready") loadModels();
|
|
1929
|
+
if (status.gateway.status === "ready") { loadModels(); loadUpgrade(); }
|
|
1930
|
+
loadHealth();
|
|
1813
1931
|
loadLocalModels();
|
|
1814
1932
|
loadIntegration();
|
|
1815
1933
|
loadAutostart();
|
package/smart_tool_daemon.py
CHANGED
|
@@ -55,6 +55,7 @@ import dossier
|
|
|
55
55
|
import scout
|
|
56
56
|
import embedding_cache
|
|
57
57
|
import model_defaults
|
|
58
|
+
import model_health
|
|
58
59
|
import project_identity
|
|
59
60
|
import project_store
|
|
60
61
|
import project_scheduler
|
|
@@ -3984,13 +3985,19 @@ def _dispatch_tool_call(params, session_id=None):
|
|
|
3984
3985
|
return _tool_result(_sanitize_text(f"Unknown tool: {name}"), is_error=True)
|
|
3985
3986
|
token = _MCP_SESSION.set(session_id)
|
|
3986
3987
|
try:
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3988
|
+
with model_health.tool_call(name) as failures:
|
|
3989
|
+
try:
|
|
3990
|
+
result = _tool_result(handler(arguments))
|
|
3991
|
+
except Exception as exc:
|
|
3992
|
+
# Boundary central pra qualquer handler atual/futuro: uma exceção pode
|
|
3993
|
+
# carregar texto de uma fonte não totalmente confiável (ex.: corpo/reason
|
|
3994
|
+
# de erro de um proxy externo) até este ponto sem ter passado por
|
|
3995
|
+
# sanitização própria do handler.
|
|
3996
|
+
result = _tool_result(_sanitize_text(str(exc)), is_error=True)
|
|
3997
|
+
warning = model_health.warning_text(failures, setup_ui.setup_origin())
|
|
3998
|
+
if warning:
|
|
3999
|
+
result["content"][0]["text"] += _sanitize_text(warning)
|
|
4000
|
+
return result
|
|
3994
4001
|
finally:
|
|
3995
4002
|
_MCP_SESSION.reset(token)
|
|
3996
4003
|
|
|
@@ -4059,6 +4066,7 @@ def _loopback_host(header_value, require_port=False):
|
|
|
4059
4066
|
|
|
4060
4067
|
_SETUP_SAVES = {
|
|
4061
4068
|
"/setup/models": setup_ui.handle_models_save,
|
|
4069
|
+
"/setup/models/upgrade": setup_ui.handle_upgrade_action,
|
|
4062
4070
|
"/setup/providers": setup_ui.handle_providers_save,
|
|
4063
4071
|
"/setup/local-models": setup_ui.handle_local_models_save,
|
|
4064
4072
|
"/setup/integration": setup_ui.handle_integration_save,
|
|
@@ -4180,6 +4188,13 @@ class MCPHandler(BaseHTTPRequestHandler):
|
|
|
4180
4188
|
except Exception as exc:
|
|
4181
4189
|
self._write_json(500, {"error": _sanitize_text(str(exc))})
|
|
4182
4190
|
return
|
|
4191
|
+
if self.path in ("/setup/models/upgrade", "/setup/models/health"):
|
|
4192
|
+
try:
|
|
4193
|
+
self._write_json(200, setup_ui.handle_upgrade_get() if self.path.endswith("upgrade")
|
|
4194
|
+
else setup_ui.handle_health_get())
|
|
4195
|
+
except Exception as exc:
|
|
4196
|
+
self._write_json(500, {"error": _sanitize_text(str(exc))})
|
|
4197
|
+
return
|
|
4183
4198
|
if self.path == "/projects":
|
|
4184
4199
|
import projects_ui
|
|
4185
4200
|
self._write_html(200, projects_ui.render_projects_page())
|
|
@@ -4263,6 +4278,12 @@ class MCPHandler(BaseHTTPRequestHandler):
|
|
|
4263
4278
|
if urllib.parse.urlsplit(self.path).path == "/hooks/session-audit":
|
|
4264
4279
|
self._write_json(200, _session_audit_hook(self))
|
|
4265
4280
|
return
|
|
4281
|
+
if self.path == "/setup/notices":
|
|
4282
|
+
try:
|
|
4283
|
+
self._write_json(200, setup_ui.handle_notices())
|
|
4284
|
+
except Exception as exc:
|
|
4285
|
+
self._write_json(500, {"error": _sanitize_text(str(exc))})
|
|
4286
|
+
return
|
|
4266
4287
|
save = _SETUP_SAVES.get(self.path)
|
|
4267
4288
|
if save:
|
|
4268
4289
|
self._post_setup(save)
|