@allansantos-dev/smart-tool 0.0.0-stage → 0.9.1

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -2
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +86 -0
  8. package/browser_service_client.py +232 -0
  9. package/capabilities.py +63 -0
  10. package/client_hooks.py +192 -0
  11. package/code_graph.py +403 -0
  12. package/code_graph_java.mjs +49 -0
  13. package/code_graph_js.cjs +135 -0
  14. package/config.py +241 -0
  15. package/daemon_launcher.py +165 -0
  16. package/directory_watch.py +131 -0
  17. package/document_text.py +69 -0
  18. package/duplicates.py +313 -0
  19. package/embedding_cache.py +99 -0
  20. package/endpoint_sync.py +179 -0
  21. package/folder_picker.py +73 -0
  22. package/freshness.py +278 -0
  23. package/gateway.py +220 -0
  24. package/hook_decision.py +96 -0
  25. package/hooks/pretool_router.py +45 -0
  26. package/index_inventory.py +75 -0
  27. package/index_profile.py +167 -0
  28. package/index_scope.py +458 -0
  29. package/index_views.py +121 -0
  30. package/indexer.py +878 -0
  31. package/install.py +353 -0
  32. package/install_runtime.py +283 -0
  33. package/integration_metrics.py +82 -0
  34. package/local_embedder.py +172 -0
  35. package/model_adapters/__init__.py +0 -0
  36. package/model_adapters/openai_compatible.py +57 -0
  37. package/model_catalog.py +74 -0
  38. package/model_client.py +38 -0
  39. package/model_defaults.py +70 -0
  40. package/package.json +33 -4
  41. package/paths.py +6 -0
  42. package/project_identity.py +42 -0
  43. package/project_insights_ui.py +38 -0
  44. package/project_maps_ui.py +96 -0
  45. package/project_monitor.py +204 -0
  46. package/project_scheduler.py +61 -0
  47. package/project_storage.py +165 -0
  48. package/project_store.py +220 -0
  49. package/projects_ui.py +146 -0
  50. package/requirements.txt +8 -0
  51. package/research_cache.py +171 -0
  52. package/research_metrics.py +110 -0
  53. package/router.py +582 -0
  54. package/secret_store.py +121 -0
  55. package/setup_ui.py +986 -0
  56. package/site_memory.py +53 -0
  57. package/smart_tool_daemon.py +3604 -0
  58. package/tray.py +245 -0
  59. package/update_check.py +22 -0
  60. package/usage_meter.py +124 -0
  61. package/version.py +18 -0
  62. package/view_compare.py +68 -0
  63. package/web_adapters/browser/browser_service.py +302 -0
  64. package/web_adapters/browser/crawl4ai_search.py +236 -0
  65. package/web_adapters/browser/fetch_camoufox.py +28 -0
  66. package/web_adapters/browser/requirements.txt +2 -0
  67. package/web_adapters/node/adapter-common.mjs +40 -0
  68. package/web_adapters/node/ddg-search.mjs +89 -0
  69. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  70. package/web_adapters/node/package-lock.json +2720 -0
  71. package/web_adapters/node/package.json +12 -0
  72. package/web_adapters/node/wikipedia-search.mjs +44 -0
  73. package/web_document_graph.py +71 -0
  74. package/web_fetch.py +242 -0
  75. package/web_search_adapters.py +193 -0
  76. package/web_search_ddgs.py +42 -0
  77. package/web_search_external.py +288 -0
  78. package/web_search_health.py +88 -0
@@ -0,0 +1,82 @@
1
+ import collections
2
+ import json
3
+ import os
4
+ import paths
5
+ import threading
6
+ import time
7
+
8
+ import atomic_io
9
+ import router
10
+
11
+ PATH = os.path.join(paths.DATA_DIR, "integration-metrics.jsonl")
12
+ MAX_BYTES = 20 * 1024 * 1024
13
+ _LOCK = threading.Lock()
14
+
15
+
16
+ def record(event, **fields):
17
+ line = json.dumps({"ts": time.time(), "event": event, **fields}, ensure_ascii=False)
18
+ with _LOCK:
19
+ os.makedirs(os.path.dirname(PATH), exist_ok=True)
20
+ atomic_io.rotate(PATH, MAX_BYTES)
21
+ with open(PATH, "a", encoding="utf-8") as stream:
22
+ stream.write(line + "\n")
23
+
24
+
25
+ def _read(path, since):
26
+ rows = []
27
+ for candidate in (path + ".1", path):
28
+ try:
29
+ with open(candidate, encoding="utf-8") as stream:
30
+ for line in stream:
31
+ try:
32
+ row = json.loads(line)
33
+ except ValueError:
34
+ continue
35
+ if isinstance(row, dict) and isinstance(row.get("ts"), (int, float)) and row["ts"] >= since:
36
+ rows.append(row)
37
+ except FileNotFoundError:
38
+ continue
39
+ return rows
40
+
41
+
42
+ def summary(days=7):
43
+ since = time.time() - days * 86400
44
+ events = _read(PATH, since)
45
+ by_client = collections.defaultdict(collections.Counter)
46
+ reasons, rejects = collections.Counter(), collections.Counter()
47
+ for row in events:
48
+ by_client[row.get("client") or "desconhecido"][row.get("event", "?")] += 1
49
+ if row.get("event") == "search" and row.get("single_language_reason"):
50
+ reasons[row["single_language_reason"][:120]] += 1
51
+ if row.get("event") == "reject":
52
+ rejects[row.get("kind", "outro")] += 1
53
+ web = [row for row in events if row.get("event") == "web_search"]
54
+ origin = lambda row: "error" if row.get("error") else row.get("cache", "?")
55
+ by_cache = collections.Counter(origin(row) for row in web)
56
+ elapsed = collections.defaultdict(list)
57
+ for row in web:
58
+ if isinstance(row.get("elapsed_s"), (int, float)):
59
+ elapsed[origin(row)].append(row["elapsed_s"])
60
+ reused = sum(count for status, count in by_cache.items() if status not in ("fresh", "error"))
61
+ web_cache = {
62
+ "calls": len(web), "reused": reused, "reuse_rate": round(reused / len(web), 3) if web else None,
63
+ "by_cache": dict(by_cache),
64
+ "probes": sum(1 for row in web if row.get("probe")),
65
+ "probe_verdicts": dict(collections.Counter(row["verdict"] for row in web if row.get("verdict"))),
66
+ "errors": dict(collections.Counter(row["error"] for row in web if row.get("error"))),
67
+ "reused_volatility": dict(collections.Counter(row.get("volatility") or "?" for row in web
68
+ if origin(row) not in ("fresh", "error"))),
69
+ "bilingual": sum(1 for row in web if row.get("bilingual")),
70
+ "degraded": sum(1 for row in web if row.get("degraded")),
71
+ "with_warnings": sum(1 for row in web if row.get("warnings")),
72
+ "avg_elapsed_s": {status: round(sum(values) / len(values), 2) for status, values in elapsed.items()},
73
+ }
74
+ routed = _read(router.METRICS_PATH, since)
75
+ decisions = collections.defaultdict(collections.Counter)
76
+ for row in routed:
77
+ decisions[row.get("client") or "no client"][row.get("decision", "?")] += 1
78
+ return {"days": days, "events_by_client": {k: dict(v) for k, v in by_client.items()},
79
+ "rejects_by_kind": dict(rejects), "single_language_reasons": dict(reasons.most_common(20)),
80
+ "hook_decisions_by_client": {k: dict(v) for k, v in decisions.items()},
81
+ "web_search_cache": web_cache,
82
+ "files": {"integration": PATH, "router": router.METRICS_PATH}}
@@ -0,0 +1,172 @@
1
+ """Embedder local do mcp-memory (sidecar granite em ~/.mcp-memory/embedding.properties) como reserva sem modelo no gateway.
2
+
3
+ Medido em 2026-10-05 no gabarito do rerank (42 alvos de código, sem rerank, docs/spikes/local_embedder_spike.py):
4
+ granite-embedding-107m-multilingual local 42/35/41 (no pool / 1º / top 5), MRR@5 0,892; gateway
5
+ text-embedding-3-small do gateway 42/35/40, 0,885; só lexical 38/29/36, 0,767.
6
+ """
7
+ import http.client
8
+ import json
9
+ import os
10
+ import threading
11
+ import time
12
+ import urllib.parse
13
+
14
+ import config
15
+ import indexer
16
+
17
+ PREFIX = "local:"
18
+ PROPERTIES = os.path.join(os.path.expanduser("~"), ".mcp-memory", "embedding.properties")
19
+ BATCH = 16
20
+ INFO_TTL_S = 60
21
+ MAX_ATTEMPTS = 3
22
+ REQUEST_TIMEOUT_S = 60
23
+ _lock = threading.Lock()
24
+ _info = {"at": 0.0, "value": None}
25
+
26
+
27
+ def url():
28
+ """Sidecar base URL; None when absent or `tei.url=off`. localhost becomes 127.0.0.1: on Windows ::1 is tried
29
+ first and a sidecar bound to IPv4 only costs ~2 s per connection (measured)."""
30
+ try:
31
+ with open(PROPERTIES, encoding="utf-8") as stream:
32
+ for line in stream:
33
+ key, _, value = line.strip().partition("=")
34
+ value = value.strip()
35
+ if key.strip() == "tei.url":
36
+ if not value or value.lower() == "off":
37
+ return None
38
+ parsed = urllib.parse.urlsplit(value.rstrip("/"))
39
+ if parsed.hostname == "localhost":
40
+ parsed = parsed._replace(netloc=parsed.netloc.replace("localhost", "127.0.0.1", 1))
41
+ return urllib.parse.urlunsplit(parsed)
42
+ except OSError:
43
+ return None
44
+ return None
45
+
46
+
47
+ def configured():
48
+ return url() is not None
49
+
50
+
51
+ def _connection(base, timeout):
52
+ parsed = urllib.parse.urlsplit(base)
53
+ cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
54
+ return cls(parsed.hostname, parsed.port, timeout=timeout)
55
+
56
+
57
+ def _request(conn, method, path, body=None):
58
+ headers = {"Content-Type": "application/json"} if body is not None else {}
59
+ conn.request(method, path, body=json.dumps(body).encode("utf-8") if body is not None else None, headers=headers)
60
+ response = conn.getresponse()
61
+ data = response.read()
62
+ if response.status >= 400:
63
+ raise ConnectionError(f"local embedder answered HTTP {response.status}")
64
+ return json.loads(data)
65
+
66
+
67
+ def _probe(base, timeout):
68
+ conn = _connection(base, timeout)
69
+ try:
70
+ data = _request(conn, "GET", "/info")
71
+ finally:
72
+ conn.close()
73
+ if not data.get("model_id") or int(data.get("dimension") or 0) <= 0:
74
+ raise ValueError("local embedder has no model_id/dimension in /info")
75
+ return {"model": PREFIX + data["model_id"], "dimensions": int(data["dimension"]), "url": base,
76
+ "sha256": data.get("model_sha256")}
77
+
78
+
79
+ def info(timeout=1.5):
80
+ """Sidecar /info cached for INFO_TTL_S; None when not configured or not answering. The probe runs under the lock
81
+ so a slow failing probe never overwrites a newer good answer."""
82
+ with _lock:
83
+ if time.monotonic() - _info["at"] < INFO_TTL_S:
84
+ return _info["value"]
85
+ base, value = url(), None
86
+ if base:
87
+ try:
88
+ value = _probe(base, timeout)
89
+ except (OSError, ValueError, http.client.HTTPException):
90
+ value = None
91
+ _info.update(at=time.monotonic(), value=value)
92
+ return value
93
+
94
+
95
+ def is_local(model):
96
+ return bool(model) and model.startswith(PREFIX)
97
+
98
+
99
+ def resolve(configured_model, current=None, mode=None):
100
+ """Embedding model for the index, following config `local_models` (fallback | off | prefer).
101
+
102
+ An index already built with the local model is kept while the sidecar is briefly down, so a restart of the
103
+ sidecar never triggers a rebuild to lexical and back."""
104
+ mode = mode or config.local_models_mode()
105
+ if mode == "off":
106
+ return configured_model or indexer.LEXICAL_MODEL
107
+ if configured_model and mode == "fallback":
108
+ return configured_model
109
+ available = info()
110
+ if available:
111
+ return available["model"]
112
+ if is_local(current):
113
+ return current
114
+ return configured_model or indexer.LEXICAL_MODEL
115
+
116
+
117
+ def _left(deadline):
118
+ if deadline is None:
119
+ return REQUEST_TIMEOUT_S
120
+ left = deadline - time.monotonic()
121
+ if left <= 0:
122
+ raise TimeoutError("Deadline exceeded before local embeddings finished.")
123
+ return min(REQUEST_TIMEOUT_S, left)
124
+
125
+
126
+ def embed(model, texts, deadline=None, cancel_check=None, on_retry=None):
127
+ base = url()
128
+ if not base:
129
+ raise RuntimeError(f"mcp-memory local embedder is not configured ({PROPERTIES}).")
130
+ # Fresh /info before and after: /embed does not echo the model, and a cached answer could let vectors of a
131
+ # different model reach the persistent vector cache under this model's contract.
132
+ try:
133
+ before = _probe(base, _left(deadline))
134
+ except (OSError, ValueError, http.client.HTTPException) as exc:
135
+ raise RuntimeError(f"mcp-memory local embedder is not responding ({base}): {exc}") from None
136
+ if before["model"] != model:
137
+ raise indexer.IndexCompatibilityError(
138
+ f"The local embedder is now {before['model']}; the index was built with {model} and must be rebuilt.")
139
+ vectors = []
140
+ conn = _connection(base, _left(deadline))
141
+ try:
142
+ for start in range(0, len(texts), BATCH):
143
+ part = texts[start:start + BATCH]
144
+ for attempt in range(1, MAX_ATTEMPTS + 1):
145
+ if cancel_check:
146
+ cancel_check()
147
+ conn.timeout = _left(deadline)
148
+ try:
149
+ got = _request(conn, "POST", "/embed", {"inputs": part})
150
+ break
151
+ except (OSError, http.client.HTTPException) as exc:
152
+ conn.close()
153
+ conn = _connection(base, _left(deadline))
154
+ if attempt == MAX_ATTEMPTS:
155
+ raise RuntimeError(f"Local embedder failed after {MAX_ATTEMPTS} attempts: {exc}") from None
156
+ if on_retry:
157
+ on_retry(attempt + 1, len(part))
158
+ delay = attempt
159
+ if deadline is not None and time.monotonic() + delay >= deadline:
160
+ raise TimeoutError("Not enough time left to retry the local embedder.") from None
161
+ time.sleep(delay)
162
+ if not isinstance(got, list) or len(got) != len(part):
163
+ raise indexer.IndexVectorError("Local embedder returned a vector count different from the input.")
164
+ for vector in got:
165
+ indexer.validate_vector(vector, before["dimensions"])
166
+ vectors += got
167
+ finally:
168
+ conn.close()
169
+ after = _probe(base, _left(deadline))
170
+ if (after["model"], after.get("sha256")) != (before["model"], before.get("sha256")):
171
+ raise indexer.IndexCompatibilityError("The local embedder changed models during the call; try again.")
172
+ return vectors
File without changes
@@ -0,0 +1,57 @@
1
+ """Built-in model adapter for any OpenAI-compatible API: OpenAI, OpenRouter, a LiteLLM proxy, Ollama, vLLM...
2
+
3
+ The base URL ends where the OpenAI SDK's `base_url` ends (usually `/v1`); the API key, when given, goes as a Bearer
4
+ token. A model adapter is a module with NAME, LABEL, FIELDS, request() and verify(); see gateway.py for the contract.
5
+ """
6
+ import json
7
+ import urllib.error
8
+ import urllib.parse
9
+ import urllib.request
10
+
11
+ NAME = "openai_compatible"
12
+ LABEL = "OpenAI-compatible"
13
+ FIELDS = (
14
+ {"name": "base_url", "label": "Base URL", "required": True, "placeholder": "https://api.openai.com/v1",
15
+ "hint": "Where the API starts, like the OpenAI SDK base_url: https://openrouter.ai/api/v1, "
16
+ "http://localhost:11434/v1."},
17
+ {"name": "api_key", "label": "API key", "secret": True,
18
+ "hint": "Optional (Ollama and local proxies do not need one)."},
19
+ )
20
+ _OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
21
+
22
+
23
+ def _base(options):
24
+ url = str(options.get("base_url") or "").strip().rstrip("/")
25
+ parsed = urllib.parse.urlparse(url)
26
+ if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.query or parsed.fragment:
27
+ raise ValueError("Enter the OpenAI-compatible base URL, for example https://api.openai.com/v1.")
28
+ return url
29
+
30
+
31
+ def request(options, route, body=None, timeout=15):
32
+ """POST when body is given, GET otherwise. Returns (json, response headers); HTTP errors propagate."""
33
+ data = json.dumps(body).encode("utf-8") if body is not None else None
34
+ headers = {"Content-Type": "application/json"} if data is not None else {}
35
+ if options.get("api_key"):
36
+ headers["Authorization"] = "Bearer " + options["api_key"]
37
+ req = urllib.request.Request(f"{_base(options)}/{route}", data=data, headers=headers,
38
+ method="POST" if data is not None else "GET")
39
+ with _OPENER.open(req, timeout=timeout) as response:
40
+ return json.load(response), response.headers
41
+
42
+
43
+ def verify(options):
44
+ url = _base(options)
45
+ try:
46
+ data, _headers = request(options, "models", timeout=10)
47
+ except urllib.error.HTTPError as exc:
48
+ if exc.code in (401, 403):
49
+ raise RuntimeError(f"{url} rejected the API key (HTTP {exc.code}).") from None
50
+ if exc.code == 404:
51
+ raise RuntimeError(f"{url}/models does not exist (HTTP 404): the base URL must end where the API "
52
+ "starts, usually at /v1.") from None
53
+ raise RuntimeError(f"{url} answered HTTP {exc.code} on /models.") from None
54
+ except (OSError, ValueError) as exc:
55
+ raise RuntimeError(f"{url} did not respond on /models: {getattr(exc, 'reason', exc)}") from None
56
+ if not isinstance(data, dict) or not isinstance(data.get("data"), list):
57
+ raise RuntimeError(f"{url}/models did not return the list in OpenAI format ({{\"data\": [...]}}).")
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env python3
2
+ """Catálogo de modelos do gateway para `configure.py` e a tela de setup do Smart Tool.
3
+
4
+ `/v1/model/info` (proxy LiteLLM) traz o `mode` de cada modelo; um gateway OpenAI padrão só tem `/v1/models`, e aí o
5
+ modo sai do nome (embed, rerank; o resto é chat). A lista é sugestão: a tela aceita qualquer id.
6
+ """
7
+ import re
8
+ import threading
9
+ import urllib.error
10
+
11
+ import gateway
12
+ import model_client
13
+
14
+ _LIGHTWEIGHT_KEYWORDS = ("mini", "nano", "flash", "lite", "haiku", "small")
15
+ _LIGHTWEIGHT_RE = re.compile(
16
+ r"\b(?:" + "|".join(_LIGHTWEIGHT_KEYWORDS) + r")\b"
17
+ )
18
+
19
+ _cache_lock = threading.Lock()
20
+ _cached_rows = None
21
+ _cached_selection_id = None
22
+
23
+
24
+ def is_lightweight(model_id):
25
+ """Heurística por palavra completa (o catálogo não tem campo de tier) — cobre os provedores sem hardcode.
26
+ \\b evita falso positivo tipo "gemini" casando a substring "mini"."""
27
+ model_id = (model_id or "").lower()
28
+ return bool(_LIGHTWEIGHT_RE.search(model_id))
29
+
30
+
31
+ def _fetch_rows():
32
+ """`/v1/model/info` devolve chat+embedding+rerank misturados no mesmo payload --
33
+ cacheado pra vida do processo. Lock só em volta do preenchimento: leitura depois de
34
+ populado é sempre segura, sem necessidade de guarda."""
35
+ global _cached_rows, _cached_selection_id
36
+ selected = gateway.fingerprint()
37
+ if _cached_rows is not None and selected == _cached_selection_id:
38
+ return _cached_rows
39
+ with _cache_lock:
40
+ if _cached_rows is None or selected != _cached_selection_id:
41
+ token = model_client.get_token()
42
+ try:
43
+ rows = model_client.fetch("/v1/model/info", token).get("data", [])
44
+ except urllib.error.HTTPError as exc:
45
+ exc.close()
46
+ if exc.code not in (400, 404, 405):
47
+ raise
48
+ rows = [{"model_name": row.get("id"), "model_info": {"mode": _mode_from_name(row.get("id") or "")}}
49
+ for row in model_client.fetch("/v1/models", token).get("data", [])]
50
+ _cached_rows = rows
51
+ _cached_selection_id = selected
52
+ return _cached_rows
53
+
54
+
55
+ def _mode_from_name(model_id):
56
+ name = model_id.lower()
57
+ return "rerank" if "rerank" in name else "embedding" if "embed" in name else "chat"
58
+
59
+
60
+ def list_models(mode="chat"):
61
+ """Ids de modelo disponíveis para a key atual num dado `mode`
62
+ (`chat`, `embedding` ou `rerank`), deduplicados e ordenados por nome."""
63
+ rows = _fetch_rows()
64
+
65
+ names = set()
66
+ for row in rows:
67
+ info = row.get("model_info") or {}
68
+ if info.get("mode") != mode:
69
+ continue
70
+ name = row.get("model_name")
71
+ if name:
72
+ names.add(name)
73
+
74
+ return sorted(names)
@@ -0,0 +1,38 @@
1
+ """Smart Tool model client: talks to the configured model gateway through its adapter."""
2
+ import contextvars
3
+ import time
4
+ import usage_meter
5
+
6
+ import gateway
7
+
8
+ OFFLINE = contextvars.ContextVar("model_offline", default=None)
9
+
10
+
11
+ def get_token():
12
+ """Kept for callers that pass a token along: credentials now live in the model adapter. Fails when no adapter."""
13
+ if OFFLINE.get():
14
+ raise RuntimeError(OFFLINE.get())
15
+ if gateway.selected()[0] is None:
16
+ raise RuntimeError("No model gateway configured: choose an adapter in the setup screen.")
17
+ return ""
18
+
19
+
20
+ def fetch(path, token, method="GET", body=None, timeout=15):
21
+ if OFFLINE.get():
22
+ raise RuntimeError(OFFLINE.get())
23
+ started=time.monotonic();result=None;headers=None;failure=None
24
+ try:
25
+ result,headers=_fetch(path,token,method,body,timeout)
26
+ return result
27
+ except Exception as exc:
28
+ failure=exc
29
+ raise
30
+ finally:
31
+ usage_meter.record(path,body,result,headers,failure,time.monotonic()-started)
32
+
33
+
34
+ def _fetch(path, _token, method="GET", body=None, timeout=15):
35
+ # Callers name routes as /v1/...; the adapter knows where its API starts.
36
+ if not path.startswith("/v1/"):
37
+ raise ValueError("Only /v1/ routes are accepted by this client.")
38
+ return gateway.request(path[len("/v1/"):], body=body, timeout=timeout)
@@ -0,0 +1,70 @@
1
+ """Preenche modelos recomendados somente após consultar o catálogo do gateway configurado."""
2
+
3
+ import re
4
+
5
+ import config
6
+ import model_catalog
7
+
8
+
9
+ # Matched as the last path/dot segment of the catalog id, so "gpt-4o-mini" also finds "openai/gpt-4o-mini" (OpenRouter)
10
+ # and "openai.gpt-4o-mini" (LiteLLM aliases). Router: small, no reasoning (it runs before tool calls).
11
+ RECOMMENDED_MODELS = {
12
+ "router_model": ("chat", "gpt-4o-mini"),
13
+ "scope_model": ("chat", "gpt-5-mini"),
14
+ "research_model": ("chat", "gpt-oss-20b"),
15
+ "embedding_model": ("embedding", "text-embedding-3-small"),
16
+ "rerank_model": ("rerank", "rerank-3-5"),
17
+ }
18
+
19
+
20
+ def _matching(catalog, name):
21
+ return next((model_id for model_id in sorted(catalog) if re.split(r"[/.:]", model_id)[-1] == name
22
+ or model_id.endswith(("/" + name, "." + name)) or model_id == name), None)
23
+
24
+
25
+ def ensure_recommended_models(key_ready):
26
+ """Preenche só campos vazios e só com IDs presentes no catálogo permitido.
27
+
28
+ Retorna os campos ainda vazios e uma explicação curta para a tela de setup/tray.
29
+ Um arquivo corrompido ou uma seleção manual nunca é substituído.
30
+ """
31
+ corrupted = config.corrupted_reason()
32
+ if corrupted:
33
+ return {"status": "config_corrupt", "missing": list(RECOMMENDED_MODELS),
34
+ "message": corrupted}
35
+
36
+ cfg = config.load_config()
37
+ missing = [field for field in RECOMMENDED_MODELS if not cfg.get(field)]
38
+ if not key_ready:
39
+ return {"status": "needs_key", "missing": missing,
40
+ "message": "Model gateway unavailable; check the base URL and the API key."}
41
+ if not missing:
42
+ return {"status": "ready", "missing": [], "message": "Models configured."}
43
+
44
+ try:
45
+ available = {mode: set(model_catalog.list_models(mode)) for mode in
46
+ {RECOMMENDED_MODELS[field][0] for field in missing}}
47
+ except Exception:
48
+ return {"status": "catalog_unavailable", "missing": missing,
49
+ "message": "Catalog unavailable. Check the base URL, the API key and the connection to the gateway."}
50
+
51
+ choices = {field: found for field in missing
52
+ for mode, name in [RECOMMENDED_MODELS[field]]
53
+ for found in [_matching(available[mode], name)] if found}
54
+ if choices:
55
+ def fill_empty(current):
56
+ for field, model_id in choices.items():
57
+ if not current.get(field):
58
+ current[field] = model_id
59
+
60
+ try:
61
+ cfg = config.update_config(fill_empty)
62
+ except (config.ConfigCorruptedError, OSError):
63
+ return {"status": "config_corrupt", "missing": missing,
64
+ "message": "Could not save the configuration. Open Smart Tool to check the file."}
65
+
66
+ missing = [field for field in RECOMMENDED_MODELS if not cfg.get(field)]
67
+ if missing:
68
+ return {"status": "needs_models", "missing": missing,
69
+ "message": "Some recommended models are not in this gateway's catalog. Choose them in the settings."}
70
+ return {"status": "ready", "missing": [], "message": "Recommended models configured."}
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.9.1",
4
+ "description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
5
+ "license": "Apache-2.0",
6
+ "author": "Allan Santos",
7
+ "homepage": "https://allansantos-dv.github.io/smart-tool/",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/AllanSantos-DV/smart-tool-dist.git"
11
+ },
12
+ "keywords": [
13
+ "mcp",
14
+ "claude-code",
15
+ "codex",
16
+ "semantic-search",
17
+ "web-search",
18
+ "coding-agent"
19
+ ],
20
+ "bin": {
21
+ "smart-tool": "bin/smart-tool.mjs"
22
+ },
23
+ "os": [
24
+ "win32"
25
+ ],
26
+ "cpu": [
27
+ "x64"
28
+ ],
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }
package/paths.py ADDED
@@ -0,0 +1,6 @@
1
+ """Where Smart Tool keeps its state: ~/.smart-tool (config.json and model adapters) and ~/.smart-tool/data (indexes,
2
+ daemon registry, caches, metrics). Pure stdlib so the installer can import it too."""
3
+ import os
4
+
5
+ HOME_DIR = os.path.join(os.path.expanduser("~"), ".smart-tool")
6
+ DATA_DIR = os.path.join(HOME_DIR, "data")
@@ -0,0 +1,42 @@
1
+ """Identidade única de raízes e contenção de caminhos, inclusive junctions Windows."""
2
+ import hashlib
3
+ import os
4
+
5
+
6
+ def display_root(root):
7
+ if not isinstance(root, (str, os.PathLike)) or not str(root).strip():
8
+ raise ValueError("Provide the project folder.")
9
+ return os.path.realpath(os.path.abspath(os.path.expanduser(os.fspath(root))))
10
+
11
+
12
+ def canonical_root(root):
13
+ return os.path.normcase(display_root(root))
14
+
15
+
16
+ def project_id(root):
17
+ return hashlib.sha256(canonical_root(root).encode("utf-8")).hexdigest()[:16]
18
+
19
+
20
+ def legacy_ids(root):
21
+ """Chaves antigas conhecidas; a migração copia os dados e preserva a origem."""
22
+ variants = [os.path.abspath(os.fspath(root)), display_root(root),
23
+ os.path.normcase(os.path.abspath(os.fspath(root)))]
24
+ return list(dict.fromkeys(hashlib.sha256(path.encode("utf-8")).hexdigest()[:16]
25
+ for path in variants))
26
+
27
+
28
+ def within_root(root, candidate):
29
+ try:
30
+ base = canonical_root(root)
31
+ resolved = canonical_root(candidate)
32
+ return os.path.commonpath([base, resolved]) == base
33
+ except (OSError, ValueError, TypeError):
34
+ return False
35
+
36
+
37
+ def state_path(path, state_dir):
38
+ """Valida o alvo antes de remover/substituir arquivos de estado."""
39
+ absolute = os.path.abspath(path)
40
+ if not within_root(state_dir, absolute) or canonical_root(absolute) == canonical_root(state_dir):
41
+ raise ValueError("Operation refused: path is outside the state directory.")
42
+ return absolute