@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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -2
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +86 -0
- package/browser_service_client.py +232 -0
- package/capabilities.py +63 -0
- package/client_hooks.py +192 -0
- package/code_graph.py +403 -0
- package/code_graph_java.mjs +49 -0
- package/code_graph_js.cjs +135 -0
- package/config.py +241 -0
- package/daemon_launcher.py +165 -0
- package/directory_watch.py +131 -0
- package/document_text.py +69 -0
- package/duplicates.py +313 -0
- package/embedding_cache.py +99 -0
- package/endpoint_sync.py +179 -0
- package/folder_picker.py +73 -0
- package/freshness.py +278 -0
- package/gateway.py +220 -0
- package/hook_decision.py +96 -0
- package/hooks/pretool_router.py +45 -0
- package/index_inventory.py +75 -0
- package/index_profile.py +167 -0
- package/index_scope.py +458 -0
- package/index_views.py +121 -0
- package/indexer.py +878 -0
- package/install.py +353 -0
- package/install_runtime.py +283 -0
- package/integration_metrics.py +82 -0
- package/local_embedder.py +172 -0
- package/model_adapters/__init__.py +0 -0
- package/model_adapters/openai_compatible.py +57 -0
- package/model_catalog.py +74 -0
- package/model_client.py +38 -0
- package/model_defaults.py +70 -0
- package/package.json +33 -4
- package/paths.py +6 -0
- package/project_identity.py +42 -0
- package/project_insights_ui.py +38 -0
- package/project_maps_ui.py +96 -0
- package/project_monitor.py +204 -0
- package/project_scheduler.py +61 -0
- package/project_storage.py +165 -0
- package/project_store.py +220 -0
- package/projects_ui.py +146 -0
- package/requirements.txt +8 -0
- package/research_cache.py +171 -0
- package/research_metrics.py +110 -0
- package/router.py +582 -0
- package/secret_store.py +121 -0
- package/setup_ui.py +986 -0
- package/site_memory.py +53 -0
- package/smart_tool_daemon.py +3604 -0
- package/tray.py +245 -0
- package/update_check.py +22 -0
- package/usage_meter.py +124 -0
- package/version.py +18 -0
- package/view_compare.py +68 -0
- package/web_adapters/browser/browser_service.py +302 -0
- package/web_adapters/browser/crawl4ai_search.py +236 -0
- package/web_adapters/browser/fetch_camoufox.py +28 -0
- package/web_adapters/browser/requirements.txt +2 -0
- package/web_adapters/node/adapter-common.mjs +40 -0
- package/web_adapters/node/ddg-search.mjs +89 -0
- package/web_adapters/node/open-websearch-adapter.mjs +77 -0
- package/web_adapters/node/package-lock.json +2720 -0
- package/web_adapters/node/package.json +12 -0
- package/web_adapters/node/wikipedia-search.mjs +44 -0
- package/web_document_graph.py +71 -0
- package/web_fetch.py +242 -0
- package/web_search_adapters.py +193 -0
- package/web_search_ddgs.py +42 -0
- package/web_search_external.py +288 -0
- 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\": [...]}}).")
|
package/model_catalog.py
ADDED
|
@@ -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)
|
package/model_client.py
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|