@allansantos-dev/smart-tool 0.9.29 → 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,67 @@
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
+
20
+ ## 0.9.31 - beta
21
+
22
+ - `scout_model` is filled by default. The setup screen (and the daemon, when it checks the models) now fills an empty
23
+ `scout_model` with Claude Haiku 5.5 when the configured gateway has it; a choice you made is never replaced, and a
24
+ gateway without that model leaves the field empty to choose (the scout then returns the literal lines, as before).
25
+ - The other recommended models were re-measured by the same criterion (correctness first, price second; 3 rounds per
26
+ model; docs/research/2026-10-24-modelos-do-scout-e-dos-outros-papeis.md) and changed for new fills: `router_model`
27
+ Claude Haiku 5.5 (was GPT-4o-mini: it invented an answer to every request whose answer was not on the page),
28
+ `scope_model` GPT-5.4 nano (same accuracy as GPT-5-mini at 29% of the price), `research_model` GPT-5.4 nano (was
29
+ GPT-OSS 20B, which failed tool calls). Models already set are kept.
30
+ - Scout with an index (16 questions x 3 rounds x 15 models): Haiku 5.5 21/36 files found (the index's ceiling, equal
31
+ to no model), 12/12 on the questions with no answer, 0 failures; without an index, 20/36 against 12/36 for the
32
+ keyword scan and at most 17/36 for the other models.
33
+ - 8-question sessions with Sonnet: US$ 0.121 per session with Haiku 5.5 against 0.177 without a model, 73/80 against
34
+ 75/80, and the agent never left the tool to read files itself (GPT-4o-mini: 10 times).
35
+ - The client repeats a chat call once without `temperature` when the gateway answers 400 about it (Claude on Bedrock,
36
+ the GPT-5 family) and remembers the model; a second refusal still fails.
37
+ - Tests: `tests/test_model_client.py`; probes `tests/scout_model_probe.py` and `tests/role_model_probe.py`;
38
+ `tests/scout_probe.py --scout-model`.
39
+
40
+ ## 0.9.30 - beta
41
+
42
+ - New MCP tool `smart_scout(project_root, question, terms?, max_files?)`: answers a question about a project's code with
43
+ the lines that answer it, read inside Smart Tool so the files never enter the agent's context.
44
+ - Candidates come from the `smart_search` pipeline: the index when it is ready, the index plus a keyword read of the
45
+ files changed after it, or the keyword scan of the disk while the first index is built (answered at once, outside
46
+ the per-folder queue). With no index, no saved scope and no model, the scan covers the whole project (dependency,
47
+ hidden and generated folders excluded) instead of the root files only.
48
+ - For each of up to `max_files` files (default 5, at most 10) it reads the current lines from disk and keeps the
49
+ window with most of the question's words; the agent gets `path:lines`, the functions in it and those lines verbatim.
50
+ - Optional `scout_model` (setup screen, "Code scout"; empty by default): a small model of the configured gateway
51
+ reads the windows, writes up to 5 lines of answer and picks the line ranges to show, which are sliced from what
52
+ was read on disk. Without an index it first chooses the terms and folders to scan (two calls; one with an index).
53
+ A range outside what was read is clipped or dropped; an answer that cites no lines comes with the best keyword
54
+ windows; a model failure returns the lines with the error in a warning. Model calls are metered under the
55
+ purpose `scout` (`project_manage action=usage`).
56
+ - The server instructions point to `smart_scout` for questions that would mean reading several files, and the tool
57
+ loads upfront in Claude Code.
58
+ - Measured in isolated sessions (clean HOME and config, no CLAUDE.md, hooks, skills or plugins; 8 questions per
59
+ session, Sonnet, two repositories; docs/research/2026-10-24-smart-scout-construido-e-medido.md): with an index,
60
+ `smart_scout` with a `scout_model` cost US$ 0.154 per session against US$ 0.250 for `smart_search` alone, 31/32
61
+ right against 29/32, 90 s against 70 s; without a model, US$ 0.203, 29/32, 63 s. On a fresh install (no index)
62
+ it answered in 43 s (no model) and 117 s (model) against 256 s.
63
+ - Tests: `tests/test_scout.py`; probe `tests/scout_probe.py`.
64
+
3
65
  ## 0.9.29 - beta
4
66
 
5
67
  - Registering the MCP server in Claude Code or Codex is idempotent. `claude mcp add` refuses a name that exists
package/README.md CHANGED
@@ -5,6 +5,7 @@ A local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharpe
5
5
  | Tool | What it does |
6
6
  |---|---|
7
7
  | `smart_search` / `smart_search_result` | Semantic + lexical search over an indexed project, results split into code, tests and docs, with the likeliest functions per chunk |
8
+ | `smart_scout` | Answers a question about the code with the lines that answer it: reads the candidate files inside Smart Tool and returns `path:lines` with the literal text, so the files never enter the agent's context; with a `scout_model`, a small model also writes a short answer and keeps only the lines that support it |
8
9
  | `web_search` / `web_search_result` | Web search across several sources in parallel, in the query language and in English, with a cache shared across sessions; `depth="research"` runs a multi-round research loop |
9
10
  | `web_fetch` | Reads a known URL and returns only the answer to your prompt, written by a small model; pages are cached for 24 h. Pages over 60,000 characters are not cut: the model reads the parts closest to the prompt (needs an `embedding_model`) |
10
11
  | `project_manage` | Registers and indexes projects, shows coverage, code maps, duplicated functions and index storage; `graph` with `symbol` tells what changing a function touches: callers, calls, the tests that reach it and the files that import it |
@@ -15,7 +16,7 @@ registered as the `playwright` server where this machine lets a browser be drive
15
16
  An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
16
17
  are cheaper, or just tells the agent what Smart Tool would do.
17
18
 
18
- **Status:** 0.9.29 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).
19
20
 
20
21
  ## How it works
21
22
 
@@ -27,6 +28,7 @@ program that runs on your machine and offers the agent better tools for the same
27
28
  ```
28
29
  Claude Code / Codex ──MCP──▶ Smart Tool daemon (127.0.0.1, one per machine)
29
30
  ├─ smart_search ──▶ local index of your project (SQLite)
31
+ ├─ smart_scout ───▶ the files behind a search, read on disk (small model optional)
30
32
  ├─ web_search ───▶ several search engines at once, shared cache
31
33
  ├─ web_fetch ────▶ the page, read by a small model
32
34
  └─ project_manage
@@ -41,6 +43,13 @@ rerank model, reorders the best candidates. The agent gets back a few chunks spl
41
43
  the functions most likely to matter, instead of every line that contains a word. The index follows the project:
42
44
  only files whose content changed are processed again, and each Git branch keeps its own view.
43
45
 
46
+ **Reading code to answer a question.** `smart_scout` takes a question in plain language. It finds the candidates the
47
+ same way as `smart_search` (or by keyword on disk while there is no index yet), reads the current lines of each one
48
+ and keeps, per file, the window with most of the question's words. The agent receives `path:lines` and those lines
49
+ verbatim. With a `scout_model` (the setup screen fills it with Claude Haiku 5.5 when the gateway has it), a small model reads those windows, writes a few lines of answer
50
+ and picks which lines to show; without an index it first picks the words and folders to scan (two calls; one with an
51
+ index). What the model writes never replaces the literal lines, and when it fails the lines come back with the error.
52
+
44
53
  **Searching the web.** `web_search` asks several free search sources in parallel (DuckDuckGo, Wikipedia, Yahoo,
45
54
  Exa, Google and others), in the language of the question and in English, and merges what comes back. Search APIs with a
46
55
  free quota (Tavily, Firecrawl) and a real browser are tried only when those results are not enough. Answers are cached and shared by every agent
@@ -83,7 +92,7 @@ updating, updated, update failed), and agent sessions get the same line on their
83
92
  start when the SessionStart report is on, shown to you too) with the link to the changes. The tray menu has "Update to
84
93
  X" (or "Check for updates") on demand and an "Update automatically" switch; `SMART_TOOL_AUTO_UPDATE=off` also turns
85
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
86
- 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
87
96
  copies Smart Tool to `%LOCALAPPDATA%\Programs\SmartTool` with its own virtual environment,
88
97
  installs the web runtime, registers a per-user Scheduled Task that starts the tray at logon and starts the daemon on
89
98
  `http://127.0.0.1:8765` (another free port is used, and announced, if 8765 is taken).
@@ -242,13 +251,16 @@ fails to load is listed in the setup screen with its error and does not affect t
242
251
  everything local.
243
252
  - Web queries go to the search sources listed in `NOTICE`. Their free tiers are rate limited per IP address; respect
244
253
  each service's terms of use.
245
- - 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).
246
256
 
247
257
  ## Uninstall
248
258
 
249
259
  Turn off "Start automatically" in the setup screen, quit from the tray, remove the hook entries from
250
260
  `~/.claude/settings.json` / `~/.codex/hooks.json` (the setup screen shows them), run `claude mcp remove smart-tool -s
251
- 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.
252
264
 
253
265
  ## License
254
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/config.py CHANGED
@@ -23,6 +23,7 @@ _LOCK = threading.RLock()
23
23
  DEFAULT_CONFIG = {
24
24
  "router_model": "",
25
25
  "scope_model": "",
26
+ "scout_model": "",
26
27
  "embedding_model": "",
27
28
  "rerank_model": "",
28
29
  "research_model": "",
package/model_client.py CHANGED
@@ -1,11 +1,17 @@
1
1
  """Smart Tool model client: talks to the configured model gateway through its adapter."""
2
2
  import contextvars
3
+ import io
4
+ import threading
3
5
  import time
6
+ import urllib.error
4
7
  import usage_meter
5
8
 
6
9
  import gateway
10
+ import model_health
7
11
 
8
12
  OFFLINE = contextvars.ContextVar("model_offline", default=None)
13
+ _NO_TEMPERATURE = set()
14
+ _no_temperature_lock = threading.Lock()
9
15
 
10
16
 
11
17
  def get_token():
@@ -29,10 +35,29 @@ def fetch(path, token, method="GET", body=None, timeout=15):
29
35
  raise
30
36
  finally:
31
37
  usage_meter.record(path,body,result,headers,failure,time.monotonic()-started)
38
+ model_health.record(path,body,failure)
32
39
 
33
40
 
34
41
  def _fetch(path, _token, method="GET", body=None, timeout=15):
35
- # Callers name routes as /v1/...; the adapter knows where its API starts.
42
+ """Routes are named /v1/...; the adapter knows where its API starts. Some models (a Claude on Bedrock, the GPT-5
43
+ family) reject `temperature` with HTTP 400; the request is repeated once without it and the model is remembered, so
44
+ any model of the gateway works with the same call. A second refusal propagates."""
36
45
  if not path.startswith("/v1/"):
37
46
  raise ValueError("Only /v1/ routes are accepted by this client.")
38
- return gateway.request(path[len("/v1/"):], body=body, timeout=timeout)
47
+ route = path[len("/v1/"):]
48
+ model = body.get("model") if isinstance(body, dict) else None
49
+ if isinstance(body, dict) and "temperature" in body and model in _NO_TEMPERATURE:
50
+ body = {key: value for key, value in body.items() if key != "temperature"}
51
+ try:
52
+ return gateway.request(route, body=body, timeout=timeout)
53
+ except urllib.error.HTTPError as exc:
54
+ if exc.code != 400 or not isinstance(body, dict) or "temperature" not in body:
55
+ raise
56
+ detail = exc.read()
57
+ if b"temperature" not in detail.lower():
58
+ exc.read = io.BytesIO(detail).read
59
+ raise
60
+ with _no_temperature_lock:
61
+ _NO_TEMPERATURE.add(model)
62
+ return gateway.request(route, body={key: value for key, value in body.items() if key != "temperature"},
63
+ timeout=timeout)
package/model_defaults.py CHANGED
@@ -6,12 +6,16 @@ import config
6
6
  import model_catalog
7
7
 
8
8
 
9
- # Matched as the last path/dot segment of the catalog id, so "gpt-4o-mini" also finds "openai/gpt-4o-mini" (OpenRouter)
10
- # and "openai.gpt-4o-mini" (LiteLLM aliases). Router: small, no reasoning (it runs before tool calls).
9
+ # Matched as the last path/dot segment of the catalog id, so "claude-haiku-5-5" also finds "vertex_ai.anthropic.claude-haiku-5-5"
10
+ # (LiteLLM aliases) and "anthropic/claude-haiku-5-5" (OpenRouter). Chosen by measurement, 3 rounds per model, correctness
11
+ # first and price second (docs/research/2026-10-24-modelos-do-scout-e-dos-outros-papeis.md): the router answers pages
12
+ # and the scout reads code excerpts, where Claude Haiku 5.5 answered every request and led the no-index path; scope and
13
+ # research pick the GPT-5.4 nano, as accurate there at about half the price and without the long reasoning of the mini.
11
14
  RECOMMENDED_MODELS = {
12
- "router_model": ("chat", "gpt-4o-mini"),
13
- "scope_model": ("chat", "gpt-5-mini"),
14
- "research_model": ("chat", "gpt-oss-20b"),
15
+ "router_model": ("chat", "claude-haiku-5-5"),
16
+ "scope_model": ("chat", "gpt-5.4-nano"),
17
+ "scout_model": ("chat", "claude-haiku-5-5"),
18
+ "research_model": ("chat", "gpt-5.4-nano"),
15
19
  "embedding_model": ("embedding", "text-embedding-3-small"),
16
20
  "rerank_model": ("rerank", "rerank-3-5"),
17
21
  }
@@ -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.29",
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",