@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 +17 -0
- package/README.md +7 -4
- package/autostart.py +4 -0
- package/model_client.py +2 -0
- package/model_health.py +164 -0
- package/model_upgrade.py +140 -0
- package/notice_action.py +58 -0
- package/package.json +1 -1
- 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/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.
|
|
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`
|
|
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):
|
package/model_health.py
ADDED
|
@@ -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"
|
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 = 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")
|
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.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();
|
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)
|
package/toast_notice.py
ADDED
|
@@ -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