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

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 (77) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +32 -0
  4. package/README.md +116 -3
  5. package/atomic_io.py +94 -0
  6. package/autostart.py +199 -0
  7. package/bin/smart-tool.mjs +82 -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 +251 -0
  32. package/install_runtime.py +249 -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 +35 -6
  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/requirements.txt +2 -0
  66. package/web_adapters/node/adapter-common.mjs +40 -0
  67. package/web_adapters/node/ddg-search.mjs +89 -0
  68. package/web_adapters/node/open-websearch-adapter.mjs +77 -0
  69. package/web_adapters/node/package-lock.json +2720 -0
  70. package/web_adapters/node/package.json +12 -0
  71. package/web_adapters/node/wikipedia-search.mjs +44 -0
  72. package/web_document_graph.py +71 -0
  73. package/web_fetch.py +242 -0
  74. package/web_search_adapters.py +193 -0
  75. package/web_search_ddgs.py +42 -0
  76. package/web_search_external.py +288 -0
  77. package/web_search_health.py +88 -0
@@ -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
- {
2
- "name": "@allansantos-dev/smart-tool",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "@allansantos-dev/smart-tool",
3
+ "version": "0.9.0",
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
@@ -0,0 +1,38 @@
1
+ """Painéis por projeto: consumo, armazenamento seletivo e comparação de visões."""
2
+ STYLE=r'''
3
+ .insights-controls{display:flex;gap:10px;align-items:end;flex-wrap:wrap;margin:16px 0}.insights-controls label{font-size:12px;margin:0 0 4px}.insights-controls select{max-width:100%;min-height:40px}.insights-table-wrap{max-height:340px;overflow:auto;margin:16px 0}.insights-table{width:100%;border-collapse:collapse;font-size:13px}.insights-table th,.insights-table td{text-align:left;vertical-align:top;padding:10px 9px;border-bottom:1px solid var(--border);overflow-wrap:anywhere}.insights-table th{color:var(--muted);font-weight:600}.insights-table td:first-child,.insights-table th:first-child{padding-left:0}.insights-table td button{font-size:12px;min-height:30px;padding:4px 8px}.insights-note{color:var(--muted);font-size:13px;overflow-wrap:anywhere}.insights-summary{display:grid;grid-template-columns:1fr 1fr;gap:0 20px;margin:14px 0}.insights-summary div{padding:10px 0;border-top:1px solid var(--border)}.insights-summary small{display:block}.insights-summary strong{font-size:15px}.cleanup-preview{border:1px solid var(--border);border-radius:8px;padding:16px;margin:16px 0}.comparison-diff{white-space:pre-wrap;overflow-wrap:anywhere;font:12px/1.65 Consolas,monospace;max-height:430px;overflow:auto}.comparison-diff .added{color:var(--good)}.comparison-diff .removed{color:var(--bad)}.comparison-item{width:100%;text-align:left;margin:4px 0}.insights-note:empty{display:none}.insights-panel select{min-width:0}.insights-views{display:grid;grid-template-columns:1fr 1fr;gap:16px}.insights-views select{width:100%}
4
+ @media(max-width:700px){.insights-summary,.insights-views{grid-template-columns:1fr}.insights-table{min-width:490px}.insights-controls>div{max-width:100%}}
5
+ '''
6
+ HTML=r'''
7
+ <details class="insights-panel" id="usage-panel"><summary>Usage and reuse by project and view</summary>
8
+ <div class="insights-controls"><div><label for="usage-days">Period</label><select id="usage-days"><option value="7">7 days</option><option value="30" selected>30 days</option><option value="90">90 days</option><option value="366">1 year</option></select></div><div><label for="usage-view">View</label><select id="usage-view"><option value="">All</option></select></div><button id="usage-load">Refresh usage</button></div><p id="usage-message" class="insights-note">Local counts and usage reported by the gateway. No query text is saved in this telemetry.</p><div id="usage-summary" class="insights-summary"></div><div id="usage-table" class="insights-table-wrap"></div><div id="usage-indexing" class="insights-table-wrap"></div></details>
9
+ <details class="insights-panel" id="storage-panel"><summary>Disk space and view cleanup</summary><div class="insights-controls"><button id="storage-load">Refresh inventory</button></div><p class="insights-note">The active view and pinned views are protected. Cleanup is manual and removes only Smart Tool indexes and cache.</p><div id="storage-summary" class="insights-summary"></div><div id="storage-table" class="insights-table-wrap"></div><label class="check-label"><input id="cleanup-cache" type="checkbox" checked>Also clear vectors no longer used by the stored generations</label><div class="actions"><button id="cleanup-preview" disabled>Preview selected cleanup</button></div><div id="cleanup-plan" class="cleanup-preview" hidden></div><button id="cleanup-apply" class="danger" hidden>Confirm removal of this preview</button><p id="storage-message" class="insights-note"></p></details>
10
+ <details class="insights-panel" id="compare-panel"><summary>Compare two stored views</summary><p class="insights-note">The comparison uses the index content, with no checkout and no new embeddings. Scope differences can also show up as added or removed files.</p><button id="compare-load-views">Load views</button><div class="insights-views"><div><label for="compare-left">View A · base</label><select id="compare-left"></select></div><div><label for="compare-right">View B · compare with A</label><select id="compare-right"></select></div></div><label class="check-label"><input id="compare-relations" type="checkbox" checked>Also compare resolved references and static calls</label><div class="actions"><button id="compare-run" disabled>Compare A → B</button></div><p id="compare-message" class="insights-note"></p><div id="compare-summary" class="insights-summary"></div><div id="compare-files" class="insights-table-wrap"></div><div id="compare-graph"></div><h3 id="compare-file-title"></h3><pre id="compare-diff" class="comparison-diff"></pre></details>
11
+ '''
12
+ SCRIPT=r'''
13
+ window.SmartInsights=(()=>{
14
+ const $i=id=>document.getElementById(id);let sequence=0,storage=null,plan=null,comparison=null,compareRequest=0,diffRequest=0;
15
+ const count=n=>Number(n||0).toLocaleString('en-US'),size=n=>n>=1048576?(n/1048576).toLocaleString('en-US',{maximumFractionDigits:2})+' MiB':(n/1024).toLocaleString('en-US',{maximumFractionDigits:1})+' KiB';
16
+ const phaseNames={index_embedding:'Indexing',query_embedding:'Query',embedding_probe:'Model check',scope:'Scope',rerank:'Rerank',other:'Other step'};
17
+ function summary(host,rows){host.innerHTML=rows.map(([title,value,note])=>`<div><small>${esc(title)}</small><strong>${esc(value)}</strong>${note?'<small>'+esc(note)+'</small>':''}</div>`).join('')}
18
+ function table(headers,rows){return '<table class="insights-table"><thead><tr>'+headers.map(x=>'<th>'+esc(x)+'</th>').join('')+'</tr></thead><tbody>'+rows.map(row=>'<tr>'+row.map(value=>'<td>'+value+'</td>').join('')+'</tr>').join('')+'</tbody></table>'}
19
+ async function run(button,task){const seq=sequence,key=selected;button.disabled=true;try{await task(key,()=>seq===sequence&&key===selected)}catch(e){if(seq===sequence)message(e.message,true)}finally{if(seq===sequence)button.disabled=false}}
20
+ function clearPlan(){plan=null;$i('cleanup-plan').hidden=true;$i('cleanup-apply').hidden=true;$i('cleanup-preview').disabled=!document.querySelector('#storage-table input[data-storage]:checked')}
21
+ function reset(){sequence++;storage=null;comparison=null;plan=null;for(const id of ['usage-summary','usage-table','usage-indexing','storage-summary','storage-table','storage-message','compare-summary','compare-files','compare-graph','compare-file-title','compare-diff','compare-message'])$i(id).replaceChildren();$i('usage-view').replaceChildren(new Option('All',''));$i('compare-left').replaceChildren();$i('compare-right').replaceChildren();$i('compare-run').disabled=true;clearPlan();for(const id of ['usage-load','storage-load','compare-load-views'])$i(id).disabled=false;$i('usage-message').textContent='Refresh the usage of this project. The telemetry stores only counts and metadata.'}
22
+ async function loadUsage(key,valid){const requestedDays=$i('usage-days').value,requestedView=$i('usage-view').value;const data=await api({action:'usage',project_id:key,days:Number($i('usage-days').value),...($i('usage-view').value?{view_id:$i('usage-view').value}:{})});if(!valid()||requestedDays!==$i('usage-days').value||requestedView!==$i('usage-view').value)return;const t=data.totals,old=$i('usage-view').value,views=new Map();for(const row of [...data.daily,...data.indexing])views.set(row.view_id,row.view_label);if(!old)$i('usage-view').replaceChildren(new Option('All',''),...Array.from(views,([id,label])=>new Option(label||id,id)));summary($i('usage-summary'),[['Call attempts',count(t.attempts),count(t.errors)+' with errors'],['Reported tokens',t.token_reports?count((t.prompt_tokens||0)+(t.output_tokens||0)):'Not reported',count(t.token_reports)+' of '+count(t.attempts)+' calls with data'],['Cost reported by the gateway',t.reported_usd==null?'Not reported':new Intl.NumberFormat('en-US',{style:'currency',currency:'USD',maximumFractionDigits:6}).format(t.reported_usd),count(t.price_reports)+' calls with a cost; not the full bill'],['Chunks sent / cache',count(data.indexing.reduce((s,r)=>s+r.embedded_chunks,0))+' / '+count(data.indexing.reduce((s,r)=>s+r.reused_chunks,0)),'Unchanged files are counted separately']]);$i('usage-message').textContent='Loaded period: '+data.days+' days (UTC). '+data.notes.join(' ');$i('usage-table').innerHTML=table(['Date UTC','View / step','Model','Attempts','Reported tokens','Measured characters'],data.daily.slice(0,200).map(r=>[esc(r.day),esc(r.view_label)+'<br>'+esc(phaseNames[r.purpose]||r.purpose),esc(r.model),count(r.attempts),r.token_reports?count(r.prompt_tokens+r.output_tokens):'Not reported',count(r.input_characters)]));$i('usage-indexing').innerHTML=table(['View','Indexing jobs','Chunks sent','Cache reuse','Unchanged files'],data.indexing.map(r=>[esc(r.view_label),count(r.jobs),count(r.embedded_chunks),count(r.reused_chunks),count(r.unchanged_files)]))}
23
+ function renderStorage(data){storage=data;clearPlan();summary($i('storage-summary'),[['SQLite indexes',size(data.total_bytes)],['Vector cache',size(data.cache_bytes)],['Auxiliary generations',size(data.auxiliary_bytes)],['Stored views',count(data.views.length),'Active and pinned views are protected']]);$i('storage-table').innerHTML=table(['Clear','View','Space','Protection'],data.views.map(v=>[`<input type="checkbox" data-storage="${esc(v.storage_id)}" aria-label="Select ${esc(v.label)} for cleanup" ${v.protected?'disabled':''}>`,esc(v.label)+'<br><small>'+esc(v.commit?.slice(0,10)||'Commit not recorded')+'</small>',size(v.bytes),esc(v.protected_reason||'Can be selected')+`<br><button data-pin="${esc(v.storage_id)}" data-pinned="${v.pinned?'1':'0'}">${v.pinned?'Unpin':'Pin'}</button>`]));$i('storage-message').textContent=data.views.length?'Choose the old views and check the preview before removing.':'No stored views in this project.';clearPlan()}
24
+ async function loadStorage(key,valid){const data=await api({action:'storage',project_id:key});if(valid())renderStorage(data)}
25
+ $i('usage-load').onclick=()=>run($i('usage-load'),loadUsage);$i('usage-days').onchange=()=>run($i('usage-load'),loadUsage);$i('usage-view').onchange=()=>run($i('usage-load'),loadUsage);$i('storage-load').onclick=()=>run($i('storage-load'),loadStorage);
26
+ $i('storage-table').onchange=clearPlan;$i('cleanup-cache').onchange=clearPlan;
27
+ $i('storage-table').onclick=e=>{const button=e.target.closest('[data-pin]');if(button)run(button,async(key,valid)=>{const data=await api({action:'pin_view',project_id:key,storage_id:button.dataset.pin,pinned:button.dataset.pinned!=='1'});if(valid())renderStorage(data)})};
28
+ $i('cleanup-preview').onclick=()=>run($i('cleanup-preview'),async(key,valid)=>{const ids=[...document.querySelectorAll('#storage-table input[data-storage]:checked')].map(n=>n.dataset.storage);const data=await api({action:'cleanup_preview',project_id:key,storage_ids:ids,clear_cache:$i('cleanup-cache').checked});if(!valid())return;plan=data;$i('cleanup-plan').hidden=false;$i('cleanup-plan').innerHTML='<h3>Removal preview</h3><ul>'+data.views.map(v=>'<li>'+esc(v.label)+'</li>').join('')+'</ul><p>'+size(data.index_bytes)+' in indexes; '+count(data.unused_vectors)+' unreferenced vectors ('+size(data.vector_bytes)+' of vector content).</p><p class="insights-note">'+esc(data.note||'The final cache size depends on SQLite compaction.')+'</p><p>The project files will be kept.</p>';$i('cleanup-apply').hidden=false});
29
+ $i('cleanup-apply').onclick=()=>{if(!plan)return;if(!confirm('Remove the views in this preview: '+plan.views.map(v=>v.label).join(', ')+'? The project files will be kept.'))return;run($i('cleanup-apply'),async(key,valid)=>{const data=await api({action:'cleanup_commit',project_id:key,plan_id:plan.plan_id,confirm:true});if(!valid())return;renderStorage(data.storage);$i('storage-message').textContent=data.removed_views+' view(s) removed; '+data.removed_vectors+' vectors removed. '+data.warnings.join(' ');window.SmartMaps?.reset();await refresh()})};
30
+ async function loadViews(key,valid){compareRequest++;diffRequest++;comparison=null;for(const id of ['compare-summary','compare-files','compare-graph','compare-file-title','compare-diff'])$i(id).replaceChildren();const data=await api({action:'storage',project_id:key});if(!valid())return;const options=data.views.map(v=>[v.storage_id,v.label+(v.current?' (active)':'')]);$i('compare-left').replaceChildren(...options.map(([id,label])=>new Option(label,id)));$i('compare-right').replaceChildren(...options.map(([id,label])=>new Option(label,id)));if(options.length>1)$i('compare-right').selectedIndex=1;$i('compare-run').disabled=options.length<2;$i('compare-message').textContent=options.length<2?'Two indexed views are needed. Run searches on the branches you want to store them.':'Differences will be shown from A to B.'}
31
+ $i('compare-load-views').onclick=()=>run($i('compare-load-views'),loadViews);
32
+ for(const id of ['compare-left','compare-right','compare-relations'])$i(id).onchange=()=>{compareRequest++;diffRequest++;comparison=null;for(const host of ['compare-summary','compare-files','compare-graph','compare-file-title','compare-diff'])$i(host).replaceChildren();$i('compare-run').disabled=!$i('compare-left').value||!$i('compare-right').value||$i('compare-left').value===$i('compare-right').value;$i('compare-message').textContent='Selection changed. Run the comparison from A to B.'};
33
+ function fileButtons(data){const rows=[];for(const kind of ['added','removed','changed'])for(const path of data.files[kind])rows.push([kind==='added'?'Added in B':kind==='removed'?'Missing in B':'Changed',path]);for(const item of data.files.renamed)rows.push(['Rename detected',item.from+' → '+item.to]);return table(['Change','File'],rows.slice(0,1000).map(([kind,path])=>[esc(kind),kind==='Rename detected'?esc(path):`<button data-compare-file="${esc(path)}">${esc(path)}</button>`]))}
34
+ $i('compare-run').onclick=()=>run($i('compare-run'),async(key,valid)=>{const request=++compareRequest;diffRequest++;const left=$i('compare-left').value,right=$i('compare-right').value;if(!left||!right||left===right)throw Error('Choose two different views.');$i('compare-message').textContent='Comparing the stored snapshots…';const data=await api({action:'compare',project_id:key,left_view:left,right_view:right,relations:$i('compare-relations').checked});if(!valid()||request!==compareRequest)return;comparison={left,right};summary($i('compare-summary'),[['Added in B',count(data.files.added.length)],['Missing in B',count(data.files.removed.length)],['Changed',count(data.files.changed.length)],['Renamed / identical',count(data.files.renamed.length)+' / '+count(data.unchanged)]]);$i('compare-files').innerHTML=fileButtons(data);$i('compare-message').textContent=data.notes.join(' ')+(data.analysis_partial?' Some files/relations could not be analyzed.':'');$i('compare-file-title').textContent='';$i('compare-diff').textContent='';$i('compare-graph').innerHTML=data.relations?Object.entries(data.relations).map(([kind,changes])=>'<details><summary>'+esc(kind==='calls'?'Calls':'References between files')+' · +'+changes.added_count+' / −'+changes.removed_count+'</summary><div class="insights-table-wrap">'+table(['Change','Source → target'],[...changes.added.map(x=>['Added',esc(x[0])+' → '+esc(x[1])]),...changes.removed.map(x=>['Removed',esc(x[0])+' → '+esc(x[1])])])+'</div></details>').join(''):''});
35
+ $i('compare-files').onclick=e=>{const button=e.target.closest('[data-compare-file]');if(!button||!comparison)return;const chosen={...comparison},request=++diffRequest;run(button,async(key,valid)=>{const data=await api({action:'compare',project_id:key,left_view:chosen.left,right_view:chosen.right,file_path:button.dataset.compareFile,relations:false});if(!valid()||request!==diffRequest||comparison?.left!==chosen.left||comparison?.right!==chosen.right)return;$i('compare-file-title').textContent=button.dataset.compareFile+(button.dataset.compareFile.toLowerCase().endsWith('.docx')?' · extracted text':'');$i('compare-diff').innerHTML=data.diff_error?esc(data.diff_error):(data.diff||'The indexed text is identical; metadata/hashes may have changed.').split('\n').map(line=>'<span class="'+(line.startsWith('+')?'added':line.startsWith('-')?'removed':'')+'">'+esc(line)+'</span>').join('\n');if(data.diff_truncated)$i('compare-message').textContent+=' Diff limited to 1,000 lines / 128,000 characters.'})};
36
+ return {reset};
37
+ })();
38
+ '''