@allansantos-dev/smart-tool 0.9.5 → 0.9.7
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 +67 -0
- package/README.md +10 -3
- package/block_patterns.py +266 -0
- package/code_impact.py +28 -1
- package/config.py +22 -0
- package/duplicates.py +23 -7
- package/hook_decision.py +115 -8
- package/index_scope.py +26 -0
- package/indexer.py +17 -1
- package/package.json +1 -1
- package/redirect_rule.py +232 -0
- package/router.py +48 -349
- package/setup_ui.py +84 -3
- package/smart_tool_daemon.py +36 -4
- package/tray.py +51 -8
- package/update_check.py +36 -2
- package/version.py +1 -1
package/smart_tool_daemon.py
CHANGED
|
@@ -169,6 +169,10 @@ _jobs = {}
|
|
|
169
169
|
_JOBS_LOCK = threading.Lock()
|
|
170
170
|
_MAX_JOBS_RETAINED = 200
|
|
171
171
|
SMART_SEARCH_SYNC_WAIT_S = 3
|
|
172
|
+
# smart_search_result waits for the job this long before answering pending: answering at once had an agent poll the
|
|
173
|
+
# same job 7 times in 16 s and then give up on Smart Tool (claude -p session, 2026-10-09; job durations median 6 s,
|
|
174
|
+
# p75 10.7 s).
|
|
175
|
+
SMART_SEARCH_RESULT_WAIT_S = 15
|
|
172
176
|
SMART_SEARCH_MAX_PENDING = 8
|
|
173
177
|
SMART_SEARCH_EXECUTOR = project_scheduler.ProjectScheduler(workers=3)
|
|
174
178
|
_PROJECT_LOCKS = {}
|
|
@@ -300,6 +304,20 @@ def _smart_search_job_payload(job_id):
|
|
|
300
304
|
payload['view'] = job['view']
|
|
301
305
|
return payload
|
|
302
306
|
|
|
307
|
+
# Claude Code defers MCP tools behind tool search; the tools the hook redirects to (and their job polling) load
|
|
308
|
+
# upfront, so a blocked agent already has the replacement in its tool list.
|
|
309
|
+
ALWAYS_LOAD = {"anthropic/alwaysLoad": True}
|
|
310
|
+
SERVER_INSTRUCTIONS = (
|
|
311
|
+
"Smart Tool serves an indexed semantic search over the user's projects and a cheaper web reader. The user installed "
|
|
312
|
+
"a PreToolUse hook that blocks content searches over a project folder (grep -r, rg, git grep, Grep on a folder) "
|
|
313
|
+
"and native WebFetch/WebSearch. A call blocked by Smart Tool is a routing rule, not a failure: make the call the block "
|
|
314
|
+
"message names (smart_search with project_root and query_identifiers, web_fetch with url and prompt, web_search "
|
|
315
|
+
"with query). Never retry the same search or download through Bash, python, node, PowerShell, curl or wget: that "
|
|
316
|
+
"bypasses the rule the user set. Reading one known file, or searching inside one known file, is never blocked. "
|
|
317
|
+
"Before changing a function, project_manage action=graph with symbol lists its callers and tests; after editing, "
|
|
318
|
+
"project_manage action=affected_tests lists the tests to run."
|
|
319
|
+
)
|
|
320
|
+
|
|
303
321
|
TOOLS = [
|
|
304
322
|
{
|
|
305
323
|
"name": "project_manage",
|
|
@@ -339,6 +357,7 @@ TOOLS = [
|
|
|
339
357
|
},
|
|
340
358
|
{
|
|
341
359
|
"name": "smart_search",
|
|
360
|
+
"_meta": ALWAYS_LOAD,
|
|
342
361
|
"description": (
|
|
343
362
|
"Semantic search (embed+rerank) over the project's index. Use instead of "
|
|
344
363
|
"raw grep/read when the search has no clear scope (e.g. a folder with node_modules). "
|
|
@@ -372,6 +391,7 @@ TOOLS = [
|
|
|
372
391
|
},
|
|
373
392
|
{
|
|
374
393
|
"name": "smart_search_result",
|
|
394
|
+
"_meta": ALWAYS_LOAD,
|
|
375
395
|
"description": (
|
|
376
396
|
"Checks the progress and result of a background smart_search. "
|
|
377
397
|
"Pass the job_id returned by smart_search; while pending, wait retry_after_s and check again."
|
|
@@ -386,6 +406,7 @@ TOOLS = [
|
|
|
386
406
|
},
|
|
387
407
|
{
|
|
388
408
|
"name": "web_search",
|
|
409
|
+
"_meta": ALWAYS_LOAD,
|
|
389
410
|
"description": (
|
|
390
411
|
"Web search with curated results (title/url/snippet) - use instead of "
|
|
391
412
|
"the native WebSearch: several sources in parallel, the query in two languages and a cache shared across sessions. Starts with free HTTP "
|
|
@@ -433,6 +454,7 @@ TOOLS = [
|
|
|
433
454
|
},
|
|
434
455
|
{
|
|
435
456
|
"name": "web_fetch",
|
|
457
|
+
"_meta": ALWAYS_LOAD,
|
|
436
458
|
"description": (
|
|
437
459
|
"Reads a known URL and returns only the answer to the prompt, written by the gateway's mini model - use instead of "
|
|
438
460
|
"the native WebFetch. The page is cached for 24 h: another prompt on the same URL does not download it again. "
|
|
@@ -449,6 +471,7 @@ TOOLS = [
|
|
|
449
471
|
},
|
|
450
472
|
{
|
|
451
473
|
"name": "web_search_result",
|
|
474
|
+
"_meta": ALWAYS_LOAD,
|
|
452
475
|
"description": (
|
|
453
476
|
"Checks the status/result of a deep research job "
|
|
454
477
|
"(web_search with depth='deep' or depth='research')."
|
|
@@ -788,6 +811,11 @@ def _prepare_project_index(arguments, job_id, cfg, token, query_vector=None, pre
|
|
|
788
811
|
checkpoint()
|
|
789
812
|
_update_job(job_id, phase="scope")
|
|
790
813
|
scope = index_scope.load_scope(root)
|
|
814
|
+
if scope is None and not arguments.get("force_scope"):
|
|
815
|
+
inherited = index_scope.sibling_scope(root)
|
|
816
|
+
if inherited is not None and not index_scope.needs_rescan(root, inherited):
|
|
817
|
+
index_scope.save_scope(root, inherited)
|
|
818
|
+
scope = inherited
|
|
791
819
|
scope_model = cfg.get("scope_model") or cfg.get("router_model")
|
|
792
820
|
if arguments.get("force_scope") or (project.get('scope_dirty') and not (scope or {}).get('manual')) or scope is None or index_scope.needs_rescan(root, scope):
|
|
793
821
|
problem = _gateway_problem() if scope_model else None
|
|
@@ -1323,6 +1351,10 @@ def _handle_smart_search_result(arguments):
|
|
|
1323
1351
|
if not job_id:
|
|
1324
1352
|
raise ValueError("smart_search_result requires 'job_id'")
|
|
1325
1353
|
payload = _smart_search_job_payload(job_id)
|
|
1354
|
+
deadline = time.monotonic() + SMART_SEARCH_RESULT_WAIT_S
|
|
1355
|
+
while payload["status"] == "pending" and time.monotonic() < deadline:
|
|
1356
|
+
time.sleep(0.25)
|
|
1357
|
+
payload = _smart_search_job_payload(job_id)
|
|
1326
1358
|
_mark_notice_delivered(job_id)
|
|
1327
1359
|
return _to_json_sanitized(payload)
|
|
1328
1360
|
|
|
@@ -3243,10 +3275,9 @@ def _web_route(tool, url):
|
|
|
3243
3275
|
if problem:
|
|
3244
3276
|
return {"redirect": False, "reason": f"gateway unavailable: {problem}"}
|
|
3245
3277
|
if tool == "WebFetch":
|
|
3246
|
-
return {"redirect": True, "reason": "
|
|
3247
|
-
"
|
|
3248
|
-
return {"redirect": True, "reason": "
|
|
3249
|
-
"two languages and a cache shared across sessions."}
|
|
3278
|
+
return {"redirect": True, "reason": "the mini model reads the page, the answer comes back short and the page is "
|
|
3279
|
+
"cached for 24 h."}
|
|
3280
|
+
return {"redirect": True, "reason": "several sources, two languages and a cache shared across sessions."}
|
|
3250
3281
|
|
|
3251
3282
|
|
|
3252
3283
|
HOOK_MAX_BODY_BYTES = 4 * 1024 * 1024
|
|
@@ -3330,6 +3361,7 @@ def _dispatch(method, params, session_id):
|
|
|
3330
3361
|
"protocolVersion": PROTOCOL_VERSION,
|
|
3331
3362
|
"capabilities": {"tools": {}},
|
|
3332
3363
|
"serverInfo": {"name": "smart-tool", "version": version.VERSION},
|
|
3364
|
+
"instructions": SERVER_INSTRUCTIONS,
|
|
3333
3365
|
}
|
|
3334
3366
|
if method == "tools/list":
|
|
3335
3367
|
return {"tools": TOOLS}
|
package/tray.py
CHANGED
|
@@ -20,8 +20,10 @@ import pystray
|
|
|
20
20
|
|
|
21
21
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
22
22
|
import autostart
|
|
23
|
+
import config
|
|
23
24
|
import daemon_launcher
|
|
24
25
|
import update_check
|
|
26
|
+
import version
|
|
25
27
|
|
|
26
28
|
POLL_INTERVAL_S = 45
|
|
27
29
|
UPDATE_CHECK_INTERVAL_S = 24 * 3600
|
|
@@ -110,6 +112,9 @@ def _poll_loop(icon):
|
|
|
110
112
|
|
|
111
113
|
|
|
112
114
|
def _update_loop(icon):
|
|
115
|
+
"""Checks npm at start and every 24 h. At start (logon, before agent sessions use the daemon) a newer version is
|
|
116
|
+
installed right away when auto_update is on; later checks only notify and offer it in the menu."""
|
|
117
|
+
first = True
|
|
113
118
|
while True:
|
|
114
119
|
try:
|
|
115
120
|
latest = update_check.newer_version()
|
|
@@ -118,19 +123,56 @@ def _update_loop(icon):
|
|
|
118
123
|
if latest and latest != _state["update"]:
|
|
119
124
|
_state["update"] = latest
|
|
120
125
|
icon.update_menu()
|
|
121
|
-
|
|
126
|
+
if first and config.auto_update() and update_check.should_auto_update(latest):
|
|
127
|
+
icon.notify(f"Updating Smart Tool to {latest}...", "Smart Tool")
|
|
128
|
+
update_check.run_update(latest, automatic=True)
|
|
129
|
+
else:
|
|
130
|
+
icon.notify(f"Smart Tool {latest} is available: use \"Update to {latest}\" in this menu.", "Smart Tool")
|
|
131
|
+
first = False
|
|
122
132
|
time.sleep(UPDATE_CHECK_INTERVAL_S)
|
|
123
133
|
|
|
124
134
|
|
|
125
135
|
def _update_label(item):
|
|
126
|
-
return f"Update to {_state['update']}
|
|
136
|
+
return f"Update to {_state['update']}" if _state["update"] else "Check for updates"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
_update_busy = threading.Lock()
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _update_now(icon, item):
|
|
143
|
+
"""Menu action: installs the known newer version, or checks npm first and says when there is none."""
|
|
144
|
+
if not _update_busy.acquire(blocking=False):
|
|
145
|
+
return
|
|
146
|
+
|
|
147
|
+
def run():
|
|
148
|
+
try:
|
|
149
|
+
latest = _state["update"] or update_check.newer_version()
|
|
150
|
+
if not latest:
|
|
151
|
+
icon.notify(f"Smart Tool {version.VERSION} is the latest version.", "Smart Tool")
|
|
152
|
+
return
|
|
153
|
+
_state["update"] = latest
|
|
154
|
+
icon.update_menu()
|
|
155
|
+
update_check.run_update(latest, automatic=False)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
icon.notify(f"Could not update: {exc}", "Smart Tool")
|
|
158
|
+
finally:
|
|
159
|
+
_update_busy.release()
|
|
160
|
+
threading.Thread(target=run, daemon=True).start()
|
|
161
|
+
|
|
127
162
|
|
|
163
|
+
def _auto_update_checked(item):
|
|
164
|
+
try:
|
|
165
|
+
return config.auto_update()
|
|
166
|
+
except Exception:
|
|
167
|
+
return False
|
|
128
168
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
169
|
+
|
|
170
|
+
def _toggle_auto_update(icon, item):
|
|
171
|
+
try:
|
|
172
|
+
config.update_config(lambda cfg: cfg.__setitem__("auto_update", not config.auto_update(cfg)))
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
icon.notify(f"Could not change automatic updates: {exc}", "Smart Tool")
|
|
175
|
+
icon.update_menu()
|
|
134
176
|
|
|
135
177
|
|
|
136
178
|
def _open_setup(icon, item):
|
|
@@ -227,7 +269,8 @@ def main():
|
|
|
227
269
|
pystray.MenuItem("Index a folder…", _add_project),
|
|
228
270
|
pystray.MenuItem("Open settings", _open_setup),
|
|
229
271
|
pystray.MenuItem("Start with Windows", _toggle_autostart, checked=_autostart_checked),
|
|
230
|
-
pystray.MenuItem(_update_label,
|
|
272
|
+
pystray.MenuItem(_update_label, _update_now),
|
|
273
|
+
pystray.MenuItem("Update automatically", _toggle_auto_update, checked=_auto_update_checked),
|
|
231
274
|
pystray.MenuItem("Quit", _quit),
|
|
232
275
|
),
|
|
233
276
|
)
|
package/update_check.py
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
"""Newer Smart Tool release on npm
|
|
1
|
+
"""Newer Smart Tool release on npm and the update itself: the tray runs the same install command a user would, in a
|
|
2
|
+
visible console, at startup (once per version, when auto_update is on) or on demand from its menu."""
|
|
2
3
|
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import time
|
|
3
7
|
import urllib.request
|
|
4
8
|
|
|
9
|
+
import atomic_io
|
|
10
|
+
import paths
|
|
5
11
|
import version
|
|
6
12
|
|
|
7
13
|
PACKAGE = "@allansantos-dev/smart-tool"
|
|
8
14
|
REGISTRY_URL = "https://registry.npmjs.org/@allansantos-dev%2Fsmart-tool/latest"
|
|
9
|
-
UPDATE_COMMAND = f"npx {PACKAGE}@latest install"
|
|
15
|
+
UPDATE_COMMAND = f"npx --yes {PACKAGE}@latest install"
|
|
16
|
+
STATE_PATH = os.path.join(paths.DATA_DIR, "update-state.json")
|
|
10
17
|
|
|
11
18
|
|
|
12
19
|
def _parts(value):
|
|
@@ -20,3 +27,30 @@ def newer_version(timeout=10):
|
|
|
20
27
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
21
28
|
latest = json.load(response)["version"]
|
|
22
29
|
return latest if _parts(latest) > _parts(version.VERSION) else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def attempted():
|
|
33
|
+
"""Version the last automatic update went for, or None."""
|
|
34
|
+
try:
|
|
35
|
+
with open(STATE_PATH, encoding="utf-8") as stream:
|
|
36
|
+
return json.load(stream).get("attempted")
|
|
37
|
+
except FileNotFoundError:
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def should_auto_update(latest):
|
|
42
|
+
"""An automatic update runs once per version: when it fails, the installer restores the previous version and
|
|
43
|
+
restarts the tray, which would otherwise try the same version again on every start."""
|
|
44
|
+
return bool(latest) and attempted() != latest
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_update(latest, automatic):
|
|
48
|
+
"""Starts the install command in its own visible console, detached from the tray (the installer stops and restarts
|
|
49
|
+
the tray). The console closes on success and stays open with the error on failure."""
|
|
50
|
+
if automatic:
|
|
51
|
+
os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
|
|
52
|
+
atomic_io.write_secret_text(STATE_PATH, json.dumps({"attempted": latest, "at": time.time()}))
|
|
53
|
+
flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
54
|
+
command = f'title Smart Tool update to {latest} && {UPDATE_COMMAND} || pause'
|
|
55
|
+
subprocess.Popen(["cmd.exe", "/c", command], creationflags=flags, close_fds=True,
|
|
56
|
+
cwd=os.path.expanduser("~"))
|
package/version.py
CHANGED