@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.
@@ -52,8 +52,10 @@ import index_inventory
52
52
  import usage_meter
53
53
  import document_text
54
54
  import dossier
55
+ import scout
55
56
  import embedding_cache
56
57
  import model_defaults
58
+ import model_health
57
59
  import project_identity
58
60
  import project_store
59
61
  import project_scheduler
@@ -334,7 +336,8 @@ def _smart_search_job_payload(job_id):
334
336
  return payload
335
337
 
336
338
  # Claude Code defers MCP tools behind tool search; the tools the hook redirects to (and their job polling) load
337
- # upfront, so an agent denied by the hook already has the replacement in its tool list.
339
+ # upfront, so an agent denied by the hook already has the replacement in its tool list. smart_scout loads upfront too:
340
+ # the server instructions point to it in place of reading several files.
338
341
  ALWAYS_LOAD = {"anthropic/alwaysLoad": True}
339
342
  SERVER_INSTRUCTIONS = (
340
343
  "Smart Tool serves an indexed semantic search over the user's projects and a cheaper web reader. Use smart_search "
@@ -345,9 +348,9 @@ SERVER_INSTRUCTIONS = (
345
348
  "node, PowerShell, curl or wget. "
346
349
  "Before changing a function, project_manage action=graph with symbol lists its callers and tests; after editing, "
347
350
  "project_manage action=affected_tests lists the tests to run. When smart_search returns nothing, retry smart_search with "
348
- "other terms; do not switch to Grep, Read of folders or shell search. A helper agent (smart-scout, a cheap model) is "
349
- "optional: use it only for a task that chains several Smart Tool calls or while the index is still loading; a plain "
350
- "search is faster called directly. A helper that returns empty means retry with the tool or report not found."
351
+ "other terms; do not switch to Grep, Read of folders or shell search. To answer a question that would mean reading "
352
+ "several files, call smart_scout: it reads them inside Smart Tool and returns only the lines that answer, as "
353
+ "path:lines with the literal text."
351
354
  )
352
355
 
353
356
  TOOLS = [
@@ -433,6 +436,30 @@ TOOLS = [
433
436
  "required": ["project_root"],
434
437
  },
435
438
  },
439
+ {
440
+ "name": "smart_scout",
441
+ "_meta": ALWAYS_LOAD,
442
+ "description": (
443
+ "Answers a question about a project's code with the lines that answer it, read inside Smart Tool so the "
444
+ "files never enter your context: path:lines plus the literal text of each excerpt, read from disk now. "
445
+ "Use it instead of reading several files one by one (how a flow works, where something is decided). "
446
+ "Candidates come from the index when it is ready and from a keyword scan of the disk when it is not. "
447
+ "When a scout_model is configured, a light model also writes a short answer and keeps only the lines that "
448
+ "support it; its text never replaces the literal lines. For a plain lookup of a name, smart_search is enough."
449
+ ),
450
+ "inputSchema": {
451
+ "type": "object",
452
+ "properties": {
453
+ "project_root": {"type": "string", "description": "Absolute path to the project root"},
454
+ "question": {"type": "string", "description": "The question in natural language, as you would ask a colleague"},
455
+ "terms": {"type": "string", "description": "Optional identifier words you already know (function, class, config names)"},
456
+ "max_files": {"type": "integer", "minimum": 1, "maximum": scout.MAX_FILES, "default": scout.DEFAULT_MAX_FILES,
457
+ "description": "Files read and shown at most"},
458
+ },
459
+ "required": ["project_root", "question"],
460
+ "additionalProperties": False,
461
+ },
462
+ },
436
463
  {
437
464
  "name": "smart_search_result",
438
465
  "_meta": ALWAYS_LOAD,
@@ -1102,6 +1129,7 @@ def _perform_smart_search_isolated(arguments, job_id):
1102
1129
  vectors = {}
1103
1130
  overlay = None
1104
1131
  search_model = model
1132
+ source = "index"
1105
1133
  if problem:
1106
1134
  model_client.OFFLINE.set(problem)
1107
1135
  token = "" if problem else model_client.get_token()
@@ -1120,6 +1148,7 @@ def _perform_smart_search_isolated(arguments, job_id):
1120
1148
  elif _cold_index(root, cfg, problem):
1121
1149
  overlay, notice = _cold_overlay(root, texts)
1122
1150
  search_model = None
1151
+ source = "cold"
1123
1152
  warnings += [text for text in (notice, _refresh_in_background(root, {"refresh": True})) if text]
1124
1153
  elif problem and not local_embedder.is_local(model):
1125
1154
  if not indexer.info(root).get("chunks"):
@@ -1144,6 +1173,7 @@ def _perform_smart_search_isolated(arguments, job_id):
1144
1173
  lag = _index_lag(root, model, cfg, problem, job_id)
1145
1174
  if lag is not None:
1146
1175
  overlay, notice = _lag_overlay(root, lag)
1176
+ source = "index+disk" if overlay else source
1147
1177
  warnings += [text for text in (notice, _refresh_in_background(root, lag)) if text]
1148
1178
  else:
1149
1179
  try:
@@ -1231,6 +1261,9 @@ def _perform_smart_search_isolated(arguments, job_id):
1231
1261
  warning = " ".join(warnings)
1232
1262
  if warning:
1233
1263
  _update_job(job_id, warning=warning)
1264
+ if arguments.get("_structured"):
1265
+ return {"source": source if source == "cold" or vectors else "lexical index", "blocks": blocks,
1266
+ "warnings": warnings}
1234
1267
  return _grouped_yaml(index_profile.public(profile), blocks, " ".join(warnings + notes))
1235
1268
 
1236
1269
 
@@ -1614,6 +1647,152 @@ def _agent_search_answer(payload):
1614
1647
  return _to_json_sanitized(slim)
1615
1648
 
1616
1649
 
1650
+ SCOUT_SEARCH_WAIT_S = 20
1651
+ SCOUT_MODEL_TIMEOUT_S = 30
1652
+ SCOUT_SCAN_MAX_FILES = 4000
1653
+ SCOUT_MAX_POINTERS = 8
1654
+ _SCOUT_SOURCES = {
1655
+ "index": "index (semantic search and rerank)",
1656
+ "index+disk": "index, with the files changed after it searched by keyword on disk",
1657
+ "lexical index": "index by keyword only (no embedding)",
1658
+ "cold": "keyword scan of the disk (no index yet; it is being built in the background)",
1659
+ "pending": "keyword scan of the disk (the index is being updated in the background)",
1660
+ "failed": "keyword scan of the disk (the search failed)",
1661
+ }
1662
+
1663
+
1664
+ def _handle_smart_scout(arguments):
1665
+ """smart_scout: the literal lines that answer a question about a project, read from disk, with a short answer by
1666
+ the configured `scout_model` when there is one. Candidates come from the smart_search pipeline (index when ready,
1667
+ keyword scan of the disk when not); without an index the model first plans terms and folders to scan (two calls in
1668
+ all, one with an index). A model failure is reported in a warning and the literal lines are returned without a
1669
+ summary; model calls are recorded in the usage meter under the purpose `scout`."""
1670
+ started = time.monotonic()
1671
+ args = scout.arguments(arguments)
1672
+ root = project_identity.display_root(args["project_root"])
1673
+ question, max_files = args["question"], args["max_files"]
1674
+ query = " ".join(part for part in (args["terms"], question) if part)
1675
+ model = config.load_config().get("scout_model") or ""
1676
+ problem = _gateway_problem() if model else None
1677
+ warnings, calls, failure = [], 0, None
1678
+ if problem:
1679
+ warnings.append(f"scout_model {model} unavailable ({problem}): literal lines only, no answer.")
1680
+ failure, model = "gateway", ""
1681
+ with usage_meter.job(root, index_views.describe(root), "scout"):
1682
+ found = _scout_search(root, query, question, max_files)
1683
+ usage_meter.phase("scout")
1684
+ warnings += found["warnings"]
1685
+ rows = scout.order_candidates(found["blocks"], max_files)
1686
+ plan_terms = []
1687
+ if model and found["source"] in ("cold", "pending", "failed"):
1688
+ calls += 1
1689
+ try:
1690
+ plan_terms, planned = _scout_plan_scan(root, question, model)
1691
+ rows = planned + rows
1692
+ except Exception as exc:
1693
+ warnings.append(f"scout_model {model} failed planning the scan ({_project_error(exc)}): literal lines "
1694
+ "only, no answer.")
1695
+ failure, model = "plan", ""
1696
+ if (not model and found["source"] in ("cold", "pending", "failed")
1697
+ and not index_scope.valid_scope(index_scope.load_scope(root))):
1698
+ wide, scanned, total = _scout_scan(root, [""], " ".join(scout.focus_terms(args["terms"], question)))
1699
+ rows = wide + rows
1700
+ warnings.append(f"No index and no saved scope yet: keyword scan of the whole project in {scanned} of {total} "
1701
+ "files (dependency, hidden and generated folders excluded).")
1702
+ terms = scout.focus_terms(args["terms"], question, " ".join(plan_terms))
1703
+ items = scout.evidence(root, rows, terms, max_files,
1704
+ scout.MODEL_WINDOW_LINES if model else scout.LEXICAL_WINDOW_LINES)
1705
+ shown = {item["path"] for item in items}
1706
+ others = list(dict.fromkeys(f"{row['path']}:{row['lines']}" for row in rows
1707
+ if row["path"] not in shown))[:SCOUT_MAX_POINTERS]
1708
+ source = _SCOUT_SOURCES[found["source"]]
1709
+ text = None
1710
+ if model and items:
1711
+ calls += 1
1712
+ try:
1713
+ raw = _research_llm_call(model, model_client.get_token(), scout.ANSWER_SYSTEM,
1714
+ scout.answer_prompt(question, items), scout.ANSWER_TOOL,
1715
+ timeout=SCOUT_MODEL_TIMEOUT_S)
1716
+ found_answer, answer, citations = scout.valid_answer(raw, items)
1717
+ note = f"{model} ({calls} call{'s' if calls > 1 else ''})"
1718
+ if citations:
1719
+ text = scout.render(question, source, items, citations, answer, found_answer, note, warnings, others)
1720
+ else:
1721
+ shown_items = scout.evidence(root, rows, terms, max_files,
1722
+ scout.LEXICAL_WINDOW_LINES)[:scout.UNCITED_WINDOWS]
1723
+ rest = [f"{item['path']}:{item['start']}-{item['end']}" for item in items
1724
+ if item["path"] not in {kept["path"] for kept in shown_items}]
1725
+ text = scout.render(question, source, shown_items, None, answer, found_answer, note,
1726
+ warnings + ["the scout_model cited no lines; the best keyword windows are shown "
1727
+ "instead."], rest + others)
1728
+ except Exception as exc:
1729
+ warnings.append(f"scout_model {model} failed writing the answer ({_project_error(exc)}): literal lines "
1730
+ "only, no answer.")
1731
+ failure = "answer"
1732
+ items = scout.evidence(root, rows, terms, max_files, scout.LEXICAL_WINDOW_LINES)
1733
+ if text is None:
1734
+ text = scout.render(question, source, items, warnings=warnings, others=others)
1735
+ _metric("scout", source=found["source"], model=bool(model) and failure is None, model_calls=calls, failure=failure,
1736
+ files=len({item["path"] for item in items}), chars=len(text), elapsed_s=round(time.monotonic() - started, 2))
1737
+ return _sanitize_text(text)
1738
+
1739
+
1740
+ def _scout_search(root, query, question, max_files):
1741
+ """`{"source", "blocks", "warnings"}` for smart_scout: the smart_search pipeline (structured result) when it answers
1742
+ within SCOUT_SEARCH_WAIT_S; otherwise, or when it fails, a keyword scan of the disk in the searched scope while the
1743
+ search job keeps updating the index in the background. While the first index of the folder is being built the cold
1744
+ path runs at once, outside the per-folder queue, as for a redirected search."""
1745
+ limits = {"code": min(SEARCH_LIMIT_TOTAL - 6, max_files * 2), "test": 3, "doc": 3}
1746
+ search = {"project_root": root, "query": query, "doc_query": question, "group": "all", "top_k": limits,
1747
+ "_structured": True}
1748
+ if _cold_index_building(root):
1749
+ return _perform_smart_search(search, "")
1750
+ job_id, future = _submit_project_job("smart_search", search)
1751
+ deadline = time.monotonic() + SCOUT_SEARCH_WAIT_S
1752
+ if future is not None:
1753
+ with contextlib.suppress(Exception):
1754
+ future.result(timeout=SCOUT_SEARCH_WAIT_S)
1755
+ while True:
1756
+ with _JOBS_LOCK:
1757
+ job = dict(_jobs.get(job_id) or {})
1758
+ if job.get("status") != "pending" or time.monotonic() >= deadline:
1759
+ break
1760
+ time.sleep(0.2)
1761
+ if job.get("status") == "done" and isinstance(job.get("result"), dict):
1762
+ return job["result"]
1763
+ state, warnings = ("pending", []) if job.get("status") == "pending" else (
1764
+ "failed", [f"smart_search failed ({job.get('error') or 'no result'}); keyword scan of the disk instead."])
1765
+ overlay, _notice = _cold_overlay(root, [query])
1766
+ found = indexer.search(root, None, top_k=max_files * 3, query=query, overlay=overlay)
1767
+ return {"source": state, "warnings": warnings,
1768
+ "blocks": {"code": [{"path": path, "lines": f"{start}-{end}", "snippet": text[:500]}
1769
+ for _score, path, start, end, text in found]}}
1770
+
1771
+
1772
+ def _scout_plan_scan(root, question, model):
1773
+ """First model call of smart_scout without an index: the model names identifier terms and folders from the
1774
+ project outline, and the daemon scans those folders (plus the root files) by keyword within COLD_SCAN_BUDGET_S.
1775
+ Returns `(terms, rows)` with the rows ranked like the cold search."""
1776
+ folders, root_files = scout.outline(root)
1777
+ raw = _research_llm_call(model, model_client.get_token(), scout.PLAN_SYSTEM,
1778
+ scout.plan_prompt(question, folders, root_files), scout.PLAN_TOOL,
1779
+ timeout=SCOUT_MODEL_TIMEOUT_S)
1780
+ terms, picked = scout.valid_plan(raw, folders)
1781
+ rows, _scanned, _total = _scout_scan(root, picked, " ".join(terms))
1782
+ return terms, rows
1783
+
1784
+
1785
+ def _scout_scan(root, include, query):
1786
+ """`(rows, scanned, total)`: the files under the `include` folders (index exclusions applied, root files always
1787
+ in, '' = the whole project) holding a term of `query`, read from disk within COLD_SCAN_BUDGET_S and ranked by
1788
+ keyword like the cold search."""
1789
+ files = index_scope.resolve_included_files(root, {"include": include})[:SCOUT_SCAN_MAX_FILES]
1790
+ chunks, scanned = indexer.keyword_chunks(root, files, [query], COLD_SCAN_BUDGET_S)
1791
+ found = indexer.search(root, None, top_k=scout.MAX_FILES * 2, query=query, overlay={"drop": [], "chunks": chunks})
1792
+ return ([{"path": path, "lines": f"{start}-{end}", "snippet": text[:500]} for _score, path, start, end, text in found],
1793
+ scanned, len(files))
1794
+
1795
+
1617
1796
  def _project_payload(project):
1618
1797
  data = dict(project)
1619
1798
  data['dismissed_duplicates'] = len(data.get('dismissed_duplicates') or {})
@@ -3773,6 +3952,7 @@ _TOOL_HANDLERS = {
3773
3952
  "project_manage": _handle_project_manage,
3774
3953
  "smart_search": _handle_smart_search,
3775
3954
  "smart_search_result": _handle_smart_search_result,
3955
+ "smart_scout": _handle_smart_scout,
3776
3956
  "web_search": _handle_web_search,
3777
3957
  "web_search_result": _handle_web_search_result,
3778
3958
  }
@@ -3805,13 +3985,19 @@ def _dispatch_tool_call(params, session_id=None):
3805
3985
  return _tool_result(_sanitize_text(f"Unknown tool: {name}"), is_error=True)
3806
3986
  token = _MCP_SESSION.set(session_id)
3807
3987
  try:
3808
- return _tool_result(handler(arguments))
3809
- except Exception as exc:
3810
- # Boundary central pra qualquer handler atual/futuro: uma exceção pode
3811
- # carregar texto de uma fonte não totalmente confiável (ex.: corpo/reason
3812
- # de erro de um proxy externo) até este ponto sem ter passado por
3813
- # sanitização própria do handler.
3814
- 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
3815
4001
  finally:
3816
4002
  _MCP_SESSION.reset(token)
3817
4003
 
@@ -3880,6 +4066,7 @@ def _loopback_host(header_value, require_port=False):
3880
4066
 
3881
4067
  _SETUP_SAVES = {
3882
4068
  "/setup/models": setup_ui.handle_models_save,
4069
+ "/setup/models/upgrade": setup_ui.handle_upgrade_action,
3883
4070
  "/setup/providers": setup_ui.handle_providers_save,
3884
4071
  "/setup/local-models": setup_ui.handle_local_models_save,
3885
4072
  "/setup/integration": setup_ui.handle_integration_save,
@@ -4001,6 +4188,13 @@ class MCPHandler(BaseHTTPRequestHandler):
4001
4188
  except Exception as exc:
4002
4189
  self._write_json(500, {"error": _sanitize_text(str(exc))})
4003
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
4004
4198
  if self.path == "/projects":
4005
4199
  import projects_ui
4006
4200
  self._write_html(200, projects_ui.render_projects_page())
@@ -4084,6 +4278,12 @@ class MCPHandler(BaseHTTPRequestHandler):
4084
4278
  if urllib.parse.urlsplit(self.path).path == "/hooks/session-audit":
4085
4279
  self._write_json(200, _session_audit_hook(self))
4086
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
4087
4287
  save = _SETUP_SAVES.get(self.path)
4088
4288
  if save:
4089
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/usage_meter.py CHANGED
@@ -11,7 +11,7 @@ import project_store
11
11
 
12
12
  _CONTEXT=contextvars.ContextVar('smart_usage',default=None)
13
13
  _PHASES={'scope':'scope','embedding_query':'query_embedding','model_probe':'embedding_probe',
14
- 'indexing':'index_embedding','reranking':'rerank'}
14
+ 'indexing':'index_embedding','reranking':'rerank','scout':'scout'}
15
15
 
16
16
 
17
17
  @contextlib.contextmanager
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.29"
4
+ VERSION = "0.9.32"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7