@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 +62 -0
- package/README.md +16 -4
- package/autostart.py +4 -0
- package/config.py +1 -0
- package/model_client.py +27 -2
- package/model_defaults.py +9 -5
- package/model_health.py +164 -0
- package/model_upgrade.py +140 -0
- package/notice_action.py +58 -0
- package/package.json +1 -1
- package/scout.py +307 -0
- package/setup_ui.py +125 -2
- package/smart_tool_daemon.py +211 -11
- package/toast_notice.py +99 -0
- package/tray.py +39 -0
- package/usage_meter.py +1 -1
- package/version.py +1 -1
package/smart_tool_daemon.py
CHANGED
|
@@ -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.
|
|
349
|
-
"
|
|
350
|
-
"
|
|
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
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
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)
|
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/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