@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.
@@ -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": "Use the web_fetch tool from the smart-tool MCP (url + prompt): the mini model reads "
3247
- "the page, the answer comes back short and the page is cached for 24 h."}
3248
- return {"redirect": True, "reason": "Use the web_search tool from the smart-tool MCP (query + query_en): several sources, "
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
- icon.notify(f"Smart Tool {latest} is available. Update with: {update_check.UPDATE_COMMAND}", "Smart Tool")
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']} (copy command)"
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
- def _copy_update_command(icon, item):
130
- import subprocess
131
- subprocess.run(["clip.exe"], input=update_check.UPDATE_COMMAND.encode("ascii"), check=True,
132
- creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
133
- icon.notify(f"Copied: {update_check.UPDATE_COMMAND}. Paste it in a terminal.", "Smart Tool")
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, _copy_update_command, visible=lambda item: bool(_state["update"])),
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, for the tray notice. Updating is running the install command again."""
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
@@ -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.5"
4
+ VERSION = "0.9.7"
5
5
  _CONTACT_RE = re.compile(r"^(?:[^\s@()<>;]+@[^\s@()<>;]+\.[^\s@()<>;]+|https?://[^\s()<>;]+)$")
6
6
 
7
7