@allansantos-dev/smart-tool 0.9.31 → 0.9.32

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.32 - beta
4
+
5
+ - Users who updated are told about the new recommended models. `model_upgrade` compares the saved `router_model`,
6
+ `scope_model`, `research_model` and `scout_model` with the recommended ones against the gateway's catalog and the
7
+ Windows tray shows a toast with "Apply now" and "Don't show again". Applying keeps the previous models so they can be
8
+ restored from the setup page (card "New recommended models"); a role you changed after the offer is never overwritten;
9
+ closing the toast reminds again after 24 h; declining silences it for this version. Embedding and rerank models are
10
+ never offered (changing them invalidates every index). Buttons are protocol links run by `notice_action.py`
11
+ (`pythonw`, no window, HKCU only, no admin), each carrying a nonce of the offer.
12
+ - Model failures are logged and shown. `model_health` records every model call by hour, model and role (and, on
13
+ failure, tool, route and error text without the request content). A tool answer that hit a failing model now carries
14
+ a line with the model, the role, the error and the link to `/setup#models`; the Models page shows the last 24 h
15
+ (failures/calls) and the 20 latest failures; the tray raises one toast per model per day (5+ calls and 50%+ failures
16
+ in 24 h) with "Switch model" and "Mute 7 days". Endpoint: `GET /setup/models/health`.
17
+ - Tests: `tests/test_model_upgrade.py`, `tests/test_model_health.py`; research note
18
+ `docs/research/2026-10-24-aviso-de-modelos-novos-e-falhas-de-modelo.md`.
19
+
3
20
  ## 0.9.31 - beta
4
21
 
5
22
  - `scout_model` is filled by default. The setup screen (and the daemon, when it checks the models) now fills an empty
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.31 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
19
+ **Status:** 0.9.32 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
20
20
 
21
21
  ## How it works
22
22
 
@@ -92,7 +92,7 @@ updating, updated, update failed), and agent sessions get the same line on their
92
92
  start when the SessionStart report is on, shown to you too) with the link to the changes. The tray menu has "Update to
93
93
  X" (or "Check for updates") on demand and an "Update automatically" switch; `SMART_TOOL_AUTO_UPDATE=off` also turns
94
94
  automatic updates off, and the newer-version notice then carries the command to run. Running the command by hand also updates; `~/.smart-tool` is kept. It downloads [uv](https://docs.astral.sh/uv/) (checksum
95
- verified) and a private Python 3.12 into `~/.smart-tool/runtime`, with nothing added to `PATH` or the registry, then
95
+ verified) and a private Python 3.12 into `~/.smart-tool/runtime`, with nothing added to `PATH` and, in the registry, only two per-user (HKCU) keys that make the notification buttons work, then
96
96
  copies Smart Tool to `%LOCALAPPDATA%\Programs\SmartTool` with its own virtual environment,
97
97
  installs the web runtime, registers a per-user Scheduled Task that starts the tray at logon and starts the daemon on
98
98
  `http://127.0.0.1:8765` (another free port is used, and announced, if 8765 is taken).
@@ -251,13 +251,16 @@ fails to load is listed in the setup screen with its error and does not affect t
251
251
  everything local.
252
252
  - Web queries go to the search sources listed in `NOTICE`. Their free tiers are rate limited per IP address; respect
253
253
  each service's terms of use.
254
- - Logs and metrics are written to `~/.smart-tool/data` and never leave the machine.
254
+ - Logs and metrics are written to `~/.smart-tool/data` and never leave the machine. That includes the model failure log
255
+ (model, role, tool and the error text, up to 300 characters, which can carry the gateway's error body; never the request).
255
256
 
256
257
  ## Uninstall
257
258
 
258
259
  Turn off "Start automatically" in the setup screen, quit from the tray, remove the hook entries from
259
260
  `~/.claude/settings.json` / `~/.codex/hooks.json` (the setup screen shows them), run `claude mcp remove smart-tool -s
260
- user` / `codex mcp remove smart-tool`, then delete `%LOCALAPPDATA%\Programs\SmartTool` and `~/.smart-tool`.
261
+ user` / `codex mcp remove smart-tool`, then delete `%LOCALAPPDATA%\Programs\SmartTool` and `~/.smart-tool`. Turning off "Start automatically" also removes the
262
+ two per-user registry keys of the notification buttons (`HKCU\Software\Classes\smart-tool-action` and
263
+ `HKCU\Software\Classes\AppUserModelId\SmartTool.Notifications`); they come back the next time a notification is shown.
261
264
 
262
265
  ## License
263
266
 
package/autostart.py CHANGED
@@ -11,6 +11,8 @@ import subprocess
11
11
  import sys
12
12
  import tempfile
13
13
 
14
+ import toast_notice
15
+
14
16
  TASK_NAME = os.environ.get("SMART_TOOL_TASK_NAME", "SmartTool-Tray")
15
17
 
16
18
 
@@ -188,6 +190,8 @@ def remove():
188
190
  pass
189
191
  if _remove_startup_shortcut():
190
192
  removed.append("startup_folder")
193
+ if sys.platform == "win32" and toast_notice.unregister():
194
+ removed.append("toast_registry")
191
195
  return {"ok": True, "removed": removed}
192
196
 
193
197
 
package/model_client.py CHANGED
@@ -7,6 +7,7 @@ import urllib.error
7
7
  import usage_meter
8
8
 
9
9
  import gateway
10
+ import model_health
10
11
 
11
12
  OFFLINE = contextvars.ContextVar("model_offline", default=None)
12
13
  _NO_TEMPERATURE = set()
@@ -34,6 +35,7 @@ def fetch(path, token, method="GET", body=None, timeout=15):
34
35
  raise
35
36
  finally:
36
37
  usage_meter.record(path,body,result,headers,failure,time.monotonic()-started)
38
+ model_health.record(path,body,failure)
37
39
 
38
40
 
39
41
  def _fetch(path, _token, method="GET", body=None, timeout=15):
@@ -0,0 +1,164 @@
1
+ """Failure log and failure rate of the gateway models, per model and role (router, scope, scout, research, embedding,
2
+ rerank). Never stores request content: the model, the role, the tool that was running, the HTTP path, a short error
3
+ text and the time.
4
+
5
+ Three consumers read it: the tool answer (a warning line when a call of that very tool failed), the setup screen
6
+ (rate table and recent failures) and the tray (one toast per model per day when the rate passes the limit).
7
+ """
8
+ import contextlib
9
+ import contextvars
10
+ import io
11
+ import os
12
+ import sqlite3
13
+ import time
14
+
15
+ import config
16
+ import project_store
17
+
18
+ WINDOW_S = 24 * 3600
19
+ ALERT_MIN_CALLS = 5
20
+ ALERT_RATE = 0.5
21
+ ALERT_EVERY_S = 24 * 3600
22
+ MUTE_S = 7 * 24 * 3600
23
+ KEEP_S = 7 * 24 * 3600
24
+ KEEP_FAILURES = 500
25
+ ERROR_CHARS = 300
26
+ _ROLES = ("router_model", "scope_model", "scout_model", "research_model", "embedding_model", "rerank_model")
27
+ _PATHS = ("/v1/chat/completions", "/v1/embeddings", "/v1/rerank")
28
+ _TOOL = contextvars.ContextVar("model_health_tool", default=None)
29
+ _COLLECTED = contextvars.ContextVar("model_health_collected", default=None)
30
+ _SCHEMA = set()
31
+
32
+
33
+ @contextlib.contextmanager
34
+ def tool_call(name):
35
+ """Marks the model calls made inside the block as run by tool `name` and collects their failures; the block yields
36
+ the list, which `warning_text` turns into the line appended to the tool answer."""
37
+ failures = []
38
+ tool_token, list_token = _TOOL.set(name), _COLLECTED.set(failures)
39
+ try:
40
+ yield failures
41
+ finally:
42
+ _TOOL.reset(tool_token)
43
+ _COLLECTED.reset(list_token)
44
+
45
+
46
+ def _db():
47
+ return project_store._db()
48
+
49
+
50
+ def _tables(db):
51
+ if project_store.DB_PATH in _SCHEMA and os.path.isfile(project_store.DB_PATH):
52
+ return
53
+ db.execute("CREATE TABLE IF NOT EXISTS model_calls(hour INTEGER,model TEXT,role TEXT,calls INTEGER,errors INTEGER,"
54
+ "PRIMARY KEY(hour,model,role))")
55
+ db.execute("CREATE TABLE IF NOT EXISTS model_failures(at REAL,model TEXT,role TEXT,tool TEXT,path TEXT,error TEXT)")
56
+ db.execute("CREATE TABLE IF NOT EXISTS model_alerts(model TEXT PRIMARY KEY,last_notified REAL,muted_until REAL)")
57
+ _SCHEMA.add(project_store.DB_PATH)
58
+
59
+
60
+ def role_of(model, cfg=None):
61
+ """Name of the config field that selects `model` ('other' when none does)."""
62
+ cfg = cfg if cfg is not None else config.load_config()
63
+ return next((field for field in _ROLES if cfg.get(field) == model), "other")
64
+
65
+
66
+ def error_text(exc):
67
+ """Short text of a failed call. An HTTP error body is read once and put back so the caller can still read it."""
68
+ text = f"{type(exc).__name__}: {exc}"
69
+ fp = getattr(exc, "fp", None)
70
+ if fp is not None and hasattr(exc, "read"):
71
+ try:
72
+ detail = exc.read()
73
+ exc.read = io.BytesIO(detail).read
74
+ text += " | " + detail.decode("utf-8", "replace")
75
+ except Exception:
76
+ pass
77
+ return " ".join(text.split())[:ERROR_CHARS]
78
+
79
+
80
+ def record(path, body, failure=None):
81
+ """Counts one model call and, when it failed, logs it. Never raises: the log must not change the call's result."""
82
+ if path not in _PATHS:
83
+ return
84
+ try:
85
+ model = str((body or {}).get("model", "")) if isinstance(body, dict) else ""
86
+ role = role_of(model)
87
+ now = time.time()
88
+ text = error_text(failure) if failure is not None else None
89
+ with project_store._LOCK, _db() as db:
90
+ _tables(db)
91
+ db.execute("INSERT INTO model_calls VALUES(?,?,?,1,?) ON CONFLICT(hour,model,role) DO UPDATE SET "
92
+ "calls=calls+1,errors=errors+excluded.errors", (int(now // 3600), model, role, int(text is not None)))
93
+ if text is not None:
94
+ db.execute("INSERT INTO model_failures VALUES(?,?,?,?,?,?)", (now, model, role, _TOOL.get() or "", path, text))
95
+ db.execute("DELETE FROM model_failures WHERE at<? OR rowid NOT IN "
96
+ "(SELECT rowid FROM model_failures ORDER BY at DESC LIMIT ?)", (now - KEEP_S, KEEP_FAILURES))
97
+ db.execute("DELETE FROM model_calls WHERE hour<?", (int((now - KEEP_S) // 3600),))
98
+ collected = _COLLECTED.get()
99
+ if text is not None and collected is not None:
100
+ collected.append({"model": model, "role": role, "error": text})
101
+ except Exception:
102
+ pass
103
+
104
+
105
+ def warning_text(failures, setup_url):
106
+ """The line a tool answer carries when a model call inside it failed ('' when none did)."""
107
+ if not failures:
108
+ return ""
109
+ first = failures[0]
110
+ more = f" (+{len(failures) - 1} more)" if len(failures) > 1 else ""
111
+ return (f"\n\nSmart Tool model warning: {first['role']} `{first['model']}` failed in this call{more}: "
112
+ f"{first['error']}. Pick another model: {setup_url}/setup#models")
113
+
114
+
115
+ def summary(now=None):
116
+ """Per model and role over the last 24 h, plus the 20 latest failures."""
117
+ now = now or time.time()
118
+ with project_store._LOCK, _db() as db:
119
+ _tables(db)
120
+ db.row_factory = sqlite3.Row
121
+ models = [dict(row) for row in db.execute(
122
+ "SELECT model,role,SUM(calls) AS calls,SUM(errors) AS errors FROM model_calls WHERE hour>=? "
123
+ "GROUP BY model,role ORDER BY SUM(errors) DESC,SUM(calls) DESC", (int((now - WINDOW_S) // 3600),))]
124
+ failures = [dict(row) for row in db.execute(
125
+ "SELECT at,model,role,tool,path,error FROM model_failures WHERE at>=? ORDER BY at DESC LIMIT 20", (now - WINDOW_S,))]
126
+ for row in models:
127
+ last = db.execute("SELECT error FROM model_failures WHERE model=? AND role=? ORDER BY at DESC LIMIT 1",
128
+ (row["model"], row["role"])).fetchone()
129
+ row["last_error"] = last[0] if last else ""
130
+ for row in models:
131
+ row["rate"] = round(row["errors"] / row["calls"], 3) if row["calls"] else 0.0
132
+ row["failing"] = row["calls"] >= ALERT_MIN_CALLS and row["rate"] >= ALERT_RATE
133
+ return {"window_hours": WINDOW_S // 3600, "models": models, "failures": failures}
134
+
135
+
136
+ def alerts_due(now=None):
137
+ """Models over the failure limit that are neither muted nor already announced in the last 24 h; marks them
138
+ announced, so a second call in the same day returns nothing."""
139
+ now = now or time.time()
140
+ due = []
141
+ for row in summary(now)["models"]:
142
+ if not row["failing"]:
143
+ continue
144
+ with project_store._LOCK, _db() as db:
145
+ _tables(db)
146
+ seen = db.execute("SELECT last_notified,muted_until FROM model_alerts WHERE model=?", (row["model"],)).fetchone()
147
+ if seen and (seen[1] > now or now - seen[0] < ALERT_EVERY_S):
148
+ continue
149
+ db.execute("INSERT INTO model_alerts VALUES(?,?,0) ON CONFLICT(model) DO UPDATE SET last_notified=excluded.last_notified",
150
+ (row["model"], now))
151
+ due.append(row)
152
+ return due
153
+
154
+
155
+ def mute(model, now=None):
156
+ """Silences the failure toast for `model` for 7 days (the failures stay in the log)."""
157
+ if not model:
158
+ raise ValueError("No model to mute.")
159
+ now = now or time.time()
160
+ with project_store._LOCK, _db() as db:
161
+ _tables(db)
162
+ db.execute("INSERT INTO model_alerts VALUES(?,?,?) ON CONFLICT(model) DO UPDATE SET muted_until=excluded.muted_until",
163
+ (model, now, now + MUTE_S))
164
+ return f"failure alerts for {model} muted for 7 days"
@@ -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 = 1
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")
@@ -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.31",
3
+ "version": "0.9.32",
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_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();
@@ -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
- return _tool_result(handler(arguments))
3988
- except Exception as exc:
3989
- # Boundary central pra qualquer handler atual/futuro: uma exceção pode
3990
- # carregar texto de uma fonte não totalmente confiável (ex.: corpo/reason
3991
- # de erro de um proxy externo) até este ponto sem ter passado por
3992
- # sanitização própria do handler.
3993
- return _tool_result(_sanitize_text(str(exc)), is_error=True)
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)
@@ -0,0 +1,99 @@
1
+ """Windows toast with buttons, no dependency: PowerShell 5.1 (always present) shows it through the WinRT toast API and
2
+ each button is a protocol link that Windows hands to `notice_action.py`.
3
+
4
+ An unpackaged app has no COM activator, so a button cannot call back into a running process. The protocol route needs
5
+ only HKCU keys (no admin): one AppUserModelId key (name and icon of the toast) and one URL protocol whose command runs
6
+ `pythonw notice_action.py "<uri>"` without a window. Actions are therefore small independent commands that read and
7
+ write the config file, never state held by the tray.
8
+ """
9
+ import base64
10
+ import os
11
+ import subprocess
12
+ import sys
13
+ import urllib.parse
14
+ from xml.sax.saxutils import quoteattr, escape
15
+
16
+ APP_ID = "SmartTool.Notifications"
17
+ PROTOCOL = "smart-tool-action"
18
+ ACTION_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "notice_action.py")
19
+
20
+ _SHOW_SCRIPT = r"""
21
+ $ErrorActionPreference = 'Stop'
22
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
23
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
24
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
25
+ $xml.LoadXml($env:SMART_TOOL_TOAST_XML)
26
+ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
27
+ if ($env:SMART_TOOL_TOAST_TAG) { $toast.Tag = $env:SMART_TOOL_TOAST_TAG; $toast.Group = 'smart-tool' }
28
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($env:SMART_TOOL_TOAST_APPID).Show($toast)
29
+ """
30
+
31
+
32
+ def _pythonw():
33
+ candidate = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
34
+ return candidate if os.path.isfile(candidate) else sys.executable
35
+
36
+
37
+ def action_command():
38
+ """The command the protocol runs; `%1` is the clicked link."""
39
+ return f'"{_pythonw()}" "{ACTION_SCRIPT}" "%1"'
40
+
41
+
42
+ def register():
43
+ """Writes the HKCU keys (idempotent). Raises on any failure: a toast whose buttons do nothing is worse than none."""
44
+ import winreg
45
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Classes\AppUserModelId\{APP_ID}") as key:
46
+ winreg.SetValueEx(key, "DisplayName", 0, winreg.REG_SZ, "Smart Tool")
47
+ root = rf"Software\Classes\{PROTOCOL}"
48
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, root) as key:
49
+ winreg.SetValueEx(key, None, 0, winreg.REG_SZ, "URL:Smart Tool action")
50
+ winreg.SetValueEx(key, "URL Protocol", 0, winreg.REG_SZ, "")
51
+ with winreg.CreateKey(winreg.HKEY_CURRENT_USER, root + r"\shell\open\command") as key:
52
+ winreg.SetValueEx(key, None, 0, winreg.REG_SZ, action_command())
53
+
54
+
55
+ def unregister():
56
+ """Deletes the HKCU keys `register` wrote, deepest first; returns how many existed (missing keys are not an error)."""
57
+ import winreg
58
+ count = 0
59
+ for path in (rf"Software\Classes\{PROTOCOL}\shell\open\command", rf"Software\Classes\{PROTOCOL}\shell\open",
60
+ rf"Software\Classes\{PROTOCOL}\shell", rf"Software\Classes\{PROTOCOL}",
61
+ rf"Software\Classes\AppUserModelId\{APP_ID}"):
62
+ try:
63
+ winreg.DeleteKey(winreg.HKEY_CURRENT_USER, path)
64
+ count += 1
65
+ except FileNotFoundError:
66
+ pass
67
+ return count
68
+
69
+
70
+ def action_uri(action, **params):
71
+ """The protocol link a button carries: `smart-tool-action:<action>?<params>`; returns the link as text."""
72
+ query = urllib.parse.urlencode(params)
73
+ return f"{PROTOCOL}:{action}" + (f"?{query}" if query else "")
74
+
75
+
76
+ def toast_xml(title, lines, buttons):
77
+ """`buttons` is a list of (label, uri); each one launches its protocol link and closes the toast."""
78
+ text = "".join(f"<text>{escape(line)}</text>" for line in [title, *lines])
79
+ actions = "".join(
80
+ f'<action content={quoteattr(label)} arguments={quoteattr(uri)} activationType="protocol"/>'
81
+ for label, uri in buttons)
82
+ return (f'<toast><visual><binding template="ToastGeneric">{text}</binding></visual>'
83
+ f'<actions>{actions}</actions></toast>')
84
+
85
+
86
+ def show(title, lines, buttons, tag=None):
87
+ """Shows the toast; raises RuntimeError with PowerShell's message when Windows refuses it."""
88
+ if sys.platform != "win32":
89
+ raise RuntimeError("Toast notifications are only available on Windows.")
90
+ register()
91
+ script = base64.b64encode(_SHOW_SCRIPT.encode("utf-16-le")).decode("ascii")
92
+ env = {**os.environ, "SMART_TOOL_TOAST_XML": toast_xml(title, lines, buttons),
93
+ "SMART_TOOL_TOAST_APPID": APP_ID, "SMART_TOOL_TOAST_TAG": tag or ""}
94
+ done = subprocess.run(
95
+ ["powershell.exe", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-EncodedCommand", script],
96
+ env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30,
97
+ creationflags=subprocess.CREATE_NO_WINDOW)
98
+ if done.returncode:
99
+ raise RuntimeError(f"Windows refused the notification: {(done.stderr or done.stdout).strip()[:500]}")
package/tray.py CHANGED
@@ -22,11 +22,13 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
22
  import autostart
23
23
  import config
24
24
  import daemon_launcher
25
+ import toast_notice
25
26
  import update_check
26
27
  import version
27
28
 
28
29
  POLL_INTERVAL_S = 45
29
30
  UPDATE_CHECK_INTERVAL_S = 24 * 3600
31
+ NOTICE_INTERVAL_S = 600
30
32
  SETUP_URL = "http://127.0.0.1:8765/setup"
31
33
 
32
34
  _STATE_LOCK = threading.Lock()
@@ -111,6 +113,42 @@ def _poll_loop(icon):
111
113
  time.sleep(POLL_INTERVAL_S)
112
114
 
113
115
 
116
+ def _show_notices(icon, notices):
117
+ """Shows a toast for each pending notice: the new-default-models offer and each model over the failure limit."""
118
+ offer = notices.get("upgrade")
119
+ if offer:
120
+ lines = [f"{role}: {change['current']} -> {change['new']}" for role, change in offer["changes"].items()]
121
+ nonce = offer["nonce"]
122
+ toast_notice.show("Smart Tool has new recommended models", lines, [
123
+ ("Apply now", toast_notice.action_uri("models-apply", n=nonce)),
124
+ ("Don't show again", toast_notice.action_uri("models-decline", n=nonce)),
125
+ ], tag="models-upgrade")
126
+ for row in notices.get("failing") or []:
127
+ last = row.get("last_error") or "see the failure log in the settings"
128
+ toast_notice.show(
129
+ f"{row['model']} is failing", [f"{row['errors']} of {row['calls']} calls failed in 24 h as {row['role']}.", last[:160]],
130
+ [("Switch model", toast_notice.action_uri("open-setup", section="models")),
131
+ ("Mute 7 days", toast_notice.action_uri("mute-model", model=row["model"]))], tag="model-failing:" + row["model"])
132
+
133
+
134
+ def _notice_loop(icon):
135
+ """Asks the daemon every 10 minutes whether a toast is due. The daemon decides (once a day per notice) and marks it
136
+ announced, so a restart of the tray never repeats one."""
137
+ while True:
138
+ try:
139
+ daemon_url = (daemon_launcher.read_registry() or {}).get("url")
140
+ if daemon_url:
141
+ request = urllib.request.Request(daemon_url.rstrip("/") + "/setup/notices", data=b"{}", method="POST",
142
+ headers={"Content-Type": "application/json"})
143
+ with urllib.request.urlopen(request, timeout=60) as response:
144
+ _show_notices(icon, json.load(response))
145
+ except Exception as exc:
146
+ if str(exc) != _state.get("notice_error"):
147
+ _state["notice_error"] = str(exc)
148
+ icon.notify(f"Could not show a Smart Tool notice: {exc}", "Smart Tool")
149
+ time.sleep(NOTICE_INTERVAL_S)
150
+
151
+
114
152
  def _announce_start(icon):
115
153
  """Tells the user, once per start, that Smart Tool was just updated or that the last automatic update failed."""
116
154
  updated = update_check.note_running_version()
@@ -304,6 +342,7 @@ def main():
304
342
  )
305
343
  threading.Thread(target=_poll_loop, args=(icon,), daemon=True).start()
306
344
  threading.Thread(target=_update_loop, args=(icon,), daemon=True).start()
345
+ threading.Thread(target=_notice_loop, args=(icon,), daemon=True).start()
307
346
  try:
308
347
  icon.run()
309
348
  finally:
package/version.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Single source of the Smart Tool version and of the User-Agent sent to public APIs."""
2
2
  import re
3
3
 
4
- VERSION = "0.9.31"
4
+ VERSION = "0.9.32"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7