@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
package/gateway.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Model gateway through adapters: how Smart Tool reaches embedding, chat and (optionally) rerank models.
|
|
2
|
+
|
|
3
|
+
Pattern copied from OpenCode providers (implementation + options, credentials kept apart; opencode.ai/docs/providers)
|
|
4
|
+
and LiteLLM custom handlers (a fixed set of methods registered by name; docs.litellm.ai custom_llm_server).
|
|
5
|
+
|
|
6
|
+
An adapter is a Python module with:
|
|
7
|
+
NAME unique id (str)
|
|
8
|
+
LABEL name shown in the setup screen
|
|
9
|
+
FIELDS sequence of {"name", "label", optional "secret", "required", "placeholder", "hint"}
|
|
10
|
+
request(options, route, body=None, timeout=15) -> (json, headers)
|
|
11
|
+
route is OpenAI-style: "models", "model/info", "embeddings", "chat/completions", "rerank"; body None = GET.
|
|
12
|
+
HTTP errors propagate as urllib.error.HTTPError.
|
|
13
|
+
verify(options) -> None, raising RuntimeError with a reason a person can act on.
|
|
14
|
+
|
|
15
|
+
Built-in adapters live in model_adapters/; any .py in ADAPTERS_DIR is loaded as a user adapter. Non-secret options
|
|
16
|
+
stay in config.json; secret fields go to the DPAPI key store.
|
|
17
|
+
"""
|
|
18
|
+
import importlib.util
|
|
19
|
+
import os
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
import config
|
|
24
|
+
import secret_store
|
|
25
|
+
from model_adapters import openai_compatible
|
|
26
|
+
|
|
27
|
+
ADAPTERS_DIR = os.path.join(config.CONFIG_DIR, "adapters")
|
|
28
|
+
BUILTIN = (openai_compatible,)
|
|
29
|
+
STATUS_TTL_S = 30
|
|
30
|
+
_lock = threading.Lock()
|
|
31
|
+
_loaded = {"key": None, "adapters": {}, "errors": []}
|
|
32
|
+
_status = {"at": 0.0, "key": None, "value": None}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check(module, origin):
|
|
36
|
+
for attribute in ("NAME", "LABEL", "FIELDS"):
|
|
37
|
+
if not getattr(module, attribute, None):
|
|
38
|
+
raise ValueError(f"{origin}: missing {attribute}")
|
|
39
|
+
for function in ("request", "verify"):
|
|
40
|
+
if not callable(getattr(module, function, None)):
|
|
41
|
+
raise ValueError(f"{origin}: missing function {function}()")
|
|
42
|
+
for field in module.FIELDS:
|
|
43
|
+
if not isinstance(field, dict) or not field.get("name") or not field.get("label"):
|
|
44
|
+
raise ValueError(f"{origin}: each FIELDS entry needs name and label")
|
|
45
|
+
return module
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def adapters():
|
|
49
|
+
"""({name: module}, [load errors]); user adapters are re-imported only when their files change."""
|
|
50
|
+
errors, stamped = [], []
|
|
51
|
+
try:
|
|
52
|
+
names = sorted(name for name in os.listdir(ADAPTERS_DIR) if name.endswith(".py"))
|
|
53
|
+
except FileNotFoundError:
|
|
54
|
+
names = []
|
|
55
|
+
except OSError as exc:
|
|
56
|
+
names, errors = [], [f"{ADAPTERS_DIR}: {type(exc).__name__}: {exc}"]
|
|
57
|
+
for name in names:
|
|
58
|
+
path = os.path.join(ADAPTERS_DIR, name)
|
|
59
|
+
try:
|
|
60
|
+
stamped.append((path, os.path.getmtime(path)))
|
|
61
|
+
except OSError:
|
|
62
|
+
continue
|
|
63
|
+
key = (tuple(stamped), tuple(errors))
|
|
64
|
+
with _lock:
|
|
65
|
+
if _loaded["key"] == key:
|
|
66
|
+
return dict(_loaded["adapters"]), list(_loaded["errors"])
|
|
67
|
+
# Imported outside the lock: an adapter that calls back into gateway at import time must not deadlock the daemon.
|
|
68
|
+
found = {module.NAME: module for module in BUILTIN}
|
|
69
|
+
for path, _mtime in stamped:
|
|
70
|
+
try:
|
|
71
|
+
spec = importlib.util.spec_from_file_location(
|
|
72
|
+
"smart_tool_adapter_" + os.path.splitext(os.path.basename(path))[0], path)
|
|
73
|
+
module = importlib.util.module_from_spec(spec)
|
|
74
|
+
spec.loader.exec_module(module)
|
|
75
|
+
_check(module, path)
|
|
76
|
+
if module.NAME in found:
|
|
77
|
+
raise ValueError(f"{path}: NAME {module.NAME!r} already exists")
|
|
78
|
+
found[module.NAME] = module
|
|
79
|
+
except Exception as exc:
|
|
80
|
+
errors.append(f"{os.path.basename(path)}: {type(exc).__name__}: {exc}")
|
|
81
|
+
with _lock:
|
|
82
|
+
_loaded.update(key=key, adapters=found, errors=errors)
|
|
83
|
+
return dict(found), list(errors)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _secret_name(adapter, field):
|
|
87
|
+
return f"adapter:{adapter}:{field}"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def selected():
|
|
91
|
+
"""(adapter module or None, options with secrets in clear). Unknown adapter in config fails loudly."""
|
|
92
|
+
cfg = config.load_config()
|
|
93
|
+
name = cfg.get("model_adapter") or ""
|
|
94
|
+
if not name:
|
|
95
|
+
return None, {}
|
|
96
|
+
found, errors = adapters()
|
|
97
|
+
if name not in found:
|
|
98
|
+
raise RuntimeError(f"Model adapter {name!r} not found" + (f" ({'; '.join(errors)})" if errors else ""))
|
|
99
|
+
module = found[name]
|
|
100
|
+
options = dict((cfg.get("model_adapter_options") or {}).get(name) or {})
|
|
101
|
+
for field in module.FIELDS:
|
|
102
|
+
if field.get("secret"):
|
|
103
|
+
options[field["name"]] = config.get_provider_api_key(_secret_name(name, field["name"]))
|
|
104
|
+
return module, options
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def describe():
|
|
108
|
+
"""Setup screen view: adapters with fields, stored non-secret values and secret state; never the secrets."""
|
|
109
|
+
cfg = config.load_config()
|
|
110
|
+
found, errors = adapters()
|
|
111
|
+
stored = cfg.get("model_adapter_options") or {}
|
|
112
|
+
listing = []
|
|
113
|
+
for name, module in found.items():
|
|
114
|
+
fields = []
|
|
115
|
+
for field in module.FIELDS:
|
|
116
|
+
entry = {key: field.get(key) for key in ("name", "label", "secret", "required", "placeholder", "hint")}
|
|
117
|
+
if field.get("secret"):
|
|
118
|
+
entry["state"] = config.provider_key_status(_secret_name(name, field["name"]))
|
|
119
|
+
else:
|
|
120
|
+
entry["value"] = (stored.get(name) or {}).get(field["name"], "")
|
|
121
|
+
fields.append(entry)
|
|
122
|
+
listing.append({"name": name, "label": module.LABEL, "builtin": module in BUILTIN, "fields": fields})
|
|
123
|
+
return {"selected": cfg.get("model_adapter") or "", "adapters": listing, "errors": errors, "dir": ADAPTERS_DIR}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def save(name, values):
|
|
127
|
+
"""Verifies with the adapter before storing. Secret field absent/None keeps the stored value; "" removes it.
|
|
128
|
+
name "" disconnects."""
|
|
129
|
+
if not name:
|
|
130
|
+
config.update_config(lambda cfg: cfg.update(model_adapter=""))
|
|
131
|
+
forget_status()
|
|
132
|
+
return
|
|
133
|
+
found, _errors = adapters()
|
|
134
|
+
if name not in found:
|
|
135
|
+
raise ValueError(f"Adapter {name!r} does not exist.")
|
|
136
|
+
module, values = found[name], values or {}
|
|
137
|
+
plain, secrets, options = {}, {}, {}
|
|
138
|
+
for field in module.FIELDS:
|
|
139
|
+
key = field["name"]
|
|
140
|
+
value = values.get(key)
|
|
141
|
+
if field.get("secret"):
|
|
142
|
+
if value is None:
|
|
143
|
+
try:
|
|
144
|
+
value = config.get_provider_api_key(_secret_name(name, key))
|
|
145
|
+
except secret_store.SecretProtectionError:
|
|
146
|
+
raise ValueError(f"The saved value of {field['label']} cannot be opened in this Windows profile; "
|
|
147
|
+
"paste it again.") from None
|
|
148
|
+
else:
|
|
149
|
+
secrets[key] = str(value).strip()
|
|
150
|
+
value = secrets[key]
|
|
151
|
+
if value:
|
|
152
|
+
config.check_provider_key_format(value)
|
|
153
|
+
else:
|
|
154
|
+
value = str(value or "").strip()
|
|
155
|
+
plain[key] = value
|
|
156
|
+
if field.get("required") and not value:
|
|
157
|
+
raise ValueError(f"Fill in {field['label']}.")
|
|
158
|
+
options[key] = value
|
|
159
|
+
module.verify(options)
|
|
160
|
+
|
|
161
|
+
def mutate(cfg):
|
|
162
|
+
stored = dict(cfg.get("model_adapter_options") or {})
|
|
163
|
+
stored[name] = plain
|
|
164
|
+
cfg.update(model_adapter=name, model_adapter_options=stored)
|
|
165
|
+
config.update_config(mutate)
|
|
166
|
+
for key, value in secrets.items():
|
|
167
|
+
config.set_provider_api_key(_secret_name(name, key), value)
|
|
168
|
+
forget_status()
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def remove_secret(name, field):
|
|
172
|
+
"""Deletes one stored secret without testing the connection (a revoked key must be removable)."""
|
|
173
|
+
found, _errors = adapters()
|
|
174
|
+
module = found.get(name)
|
|
175
|
+
if module is None or not any(f["name"] == field and f.get("secret") for f in module.FIELDS):
|
|
176
|
+
raise ValueError(f"{name}/{field} is not a secret adapter field.")
|
|
177
|
+
config.set_provider_api_key(_secret_name(name, field), "")
|
|
178
|
+
forget_status()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def forget_status():
|
|
182
|
+
with _lock:
|
|
183
|
+
_status.update(at=0.0, key=None, value=None)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def status():
|
|
187
|
+
"""{"status": "ready" | "not_configured" | "error", "adapter", "label", "error"}, checked every STATUS_TTL_S."""
|
|
188
|
+
try:
|
|
189
|
+
module, options = selected()
|
|
190
|
+
except Exception as exc:
|
|
191
|
+
return {"status": "error", "adapter": config.load_config().get("model_adapter"), "label": "",
|
|
192
|
+
"error": f"{type(exc).__name__}: {exc}"}
|
|
193
|
+
if module is None:
|
|
194
|
+
return {"status": "not_configured", "adapter": "", "label": "",
|
|
195
|
+
"error": "No model gateway configured."}
|
|
196
|
+
key = (module.NAME, tuple(sorted(options.items())))
|
|
197
|
+
with _lock:
|
|
198
|
+
if _status["key"] == key and time.monotonic() - _status["at"] < STATUS_TTL_S:
|
|
199
|
+
return dict(_status["value"])
|
|
200
|
+
try:
|
|
201
|
+
module.verify(options)
|
|
202
|
+
value = {"status": "ready", "adapter": module.NAME, "label": module.LABEL, "error": ""}
|
|
203
|
+
except Exception as exc:
|
|
204
|
+
value = {"status": "error", "adapter": module.NAME, "label": module.LABEL, "error": str(exc)}
|
|
205
|
+
with _lock:
|
|
206
|
+
_status.update(at=time.monotonic(), key=key, value=value)
|
|
207
|
+
return dict(value)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def fingerprint():
|
|
211
|
+
"""Changes whenever the adapter or its options change (cache key for the model catalog)."""
|
|
212
|
+
module, options = selected()
|
|
213
|
+
return (module.NAME if module else "", tuple(sorted(options.items())))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def request(route, body=None, timeout=15):
|
|
217
|
+
module, options = selected()
|
|
218
|
+
if module is None:
|
|
219
|
+
raise RuntimeError("No model gateway configured: choose an adapter in the setup screen.")
|
|
220
|
+
return module.request(options, route, body=body, timeout=timeout)
|
package/hook_decision.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Decisão do PreToolUse do Smart Tool, tomada dentro do daemon já aquecido.
|
|
2
|
+
|
|
3
|
+
O Claude Code chama a rota POST /hooks/pretool como hook `type: "http"` (sem processo por chamada); o Codex usa o
|
|
4
|
+
hook de comando `hooks/pretool_router.py`, que só repassa o payload para a mesma rota.
|
|
5
|
+
|
|
6
|
+
INVARIANTE: a saída só pode ser `{}` (neutra) ou `deny`. `permissionDecision: "allow"` pula a confirmação do usuário
|
|
7
|
+
no Claude Code, e nenhuma decisão aqui é sobre periculosidade, só sobre custo de contexto. Diagnóstico vai em
|
|
8
|
+
`systemMessage`, que não altera permissão.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
import config
|
|
14
|
+
import endpoint_sync
|
|
15
|
+
import router
|
|
16
|
+
|
|
17
|
+
WEB_TOOLS = ("WebSearch", "WebFetch")
|
|
18
|
+
SMART_TOOL_SERVER = "smart-tool"
|
|
19
|
+
REASON_MAX_CHARS = 200
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def clean_reason(reason):
|
|
23
|
+
"""The reason is free LLM text fed to the host model: one line, bounded (repository content reaches the router
|
|
24
|
+
prompt through tool_input)."""
|
|
25
|
+
text = " ".join(str(reason or "").split())
|
|
26
|
+
return text[:REASON_MAX_CHARS] or "call broad enough to be worth an indexed search"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def deny(reason):
|
|
30
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "permissionDecision": "deny",
|
|
31
|
+
"permissionDecisionReason": reason}}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def smart_tool_in_session(cwd):
|
|
35
|
+
"""Claude Code loads MCP servers from ~/.claude.json (user and per project) and the project's .mcp.json."""
|
|
36
|
+
with open(endpoint_sync.claude_json(), encoding="utf-8") as stream:
|
|
37
|
+
data = json.load(stream)
|
|
38
|
+
key = os.path.normcase(os.path.normpath(cwd or ""))
|
|
39
|
+
project = next((value for path, value in (data.get("projects") or {}).items()
|
|
40
|
+
if os.path.normcase(os.path.normpath(path)) == key), {})
|
|
41
|
+
if SMART_TOOL_SERVER in (project.get("disabledMcpServers") or []):
|
|
42
|
+
return False
|
|
43
|
+
if SMART_TOOL_SERVER in (data.get("mcpServers") or {}) or SMART_TOOL_SERVER in (project.get("mcpServers") or {}):
|
|
44
|
+
return True
|
|
45
|
+
try:
|
|
46
|
+
with open(os.path.join(cwd or "", ".mcp.json"), encoding="utf-8") as stream:
|
|
47
|
+
return SMART_TOOL_SERVER in (json.load(stream).get("mcpServers") or {})
|
|
48
|
+
except (OSError, ValueError):
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def advise(reason):
|
|
53
|
+
"""Same routing advice without blocking: the agent sees it next to the tool result and decides next time."""
|
|
54
|
+
return {"hookSpecificOutput": {"hookEventName": "PreToolUse",
|
|
55
|
+
"additionalContext": f"Smart Tool tip (the call was not blocked): {reason}"}}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def decide(payload, client, web_route):
|
|
59
|
+
"""Hook output for one PreToolUse payload, following config hook_mode (redirect | advise | off)."""
|
|
60
|
+
cfg = config.load_config()
|
|
61
|
+
mode = config.hook_mode(cfg)
|
|
62
|
+
if mode == "off" or not isinstance(payload, dict):
|
|
63
|
+
return {}
|
|
64
|
+
output = _route(payload, client, web_route, cfg)
|
|
65
|
+
reason = (output.get("hookSpecificOutput") or {}).get("permissionDecisionReason")
|
|
66
|
+
return advise(reason) if mode == "advise" and reason else output
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _route(payload, client, web_route, cfg):
|
|
70
|
+
tool_name = payload.get("tool_name", "")
|
|
71
|
+
tool_input = payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
|
|
72
|
+
cwd = payload.get("cwd")
|
|
73
|
+
router_model = cfg.get("router_model")
|
|
74
|
+
if not router_model:
|
|
75
|
+
return {}
|
|
76
|
+
if tool_name in WEB_TOOLS:
|
|
77
|
+
try:
|
|
78
|
+
present = smart_tool_in_session(cwd)
|
|
79
|
+
except (OSError, ValueError):
|
|
80
|
+
present = False
|
|
81
|
+
if not present:
|
|
82
|
+
return {}
|
|
83
|
+
decision = web_route(tool_name, str(tool_input.get("url") or ""))
|
|
84
|
+
return deny(clean_reason(decision.get("reason"))) if decision.get("redirect") else {}
|
|
85
|
+
router.CLIENT = client
|
|
86
|
+
try:
|
|
87
|
+
decision, reason = router.decide(tool_name, tool_input, router_model, cwd=cwd)
|
|
88
|
+
except Exception as exc:
|
|
89
|
+
try:
|
|
90
|
+
router.log_unavailable(tool_name, tool_input, exc)
|
|
91
|
+
except Exception:
|
|
92
|
+
pass
|
|
93
|
+
return {"systemMessage": f"Smart Tool unavailable, routing skipped: {type(exc).__name__}"}
|
|
94
|
+
if decision == "redirect":
|
|
95
|
+
return deny(f"Use smart_search instead of raw {tool_name}: {clean_reason(reason)}")
|
|
96
|
+
return {}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Hook PreToolUse de comando (Codex): cliente fino que repassa o payload ao daemon do Smart Tool (POST
|
|
3
|
+
/hooks/pretool), onde a decisão roda já aquecida (`hook_decision.py`). O Claude Code chama a mesma rota direto, como
|
|
4
|
+
hook `type: "http"`. Falha aberto: daemon fora do ar ou erro = saída neutra `{}`, nunca `allow`.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
|
|
12
|
+
sys.stdin.reconfigure(encoding="utf-8")
|
|
13
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
14
|
+
|
|
15
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
|
16
|
+
import daemon_launcher
|
|
17
|
+
|
|
18
|
+
TIMEOUT_S = 14
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _client():
|
|
22
|
+
args = sys.argv[1:]
|
|
23
|
+
return args[args.index("--client") + 1] if "--client" in args and args.index("--client") + 1 < len(args) else ""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main():
|
|
27
|
+
raw = sys.stdin.buffer.read()
|
|
28
|
+
base = daemon_launcher.daemon_url_if_up()
|
|
29
|
+
if not base:
|
|
30
|
+
daemon_launcher.start_daemon_if_down()
|
|
31
|
+
print(json.dumps({"systemMessage": "Smart Tool: daemon subindo, roteamento pulado nesta chamada."},
|
|
32
|
+
ensure_ascii=False))
|
|
33
|
+
return
|
|
34
|
+
request = urllib.request.Request(f"{base}/hooks/pretool?{urllib.parse.urlencode({'client': _client()})}",
|
|
35
|
+
data=raw, headers={"Content-Type": "application/json"}, method="POST")
|
|
36
|
+
with urllib.request.urlopen(request, timeout=TIMEOUT_S) as response:
|
|
37
|
+
output = json.loads(response.read() or b"{}")
|
|
38
|
+
print(json.dumps(output if isinstance(output, dict) else {}, ensure_ascii=False))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
if __name__ == "__main__":
|
|
42
|
+
try:
|
|
43
|
+
main()
|
|
44
|
+
except BaseException:
|
|
45
|
+
print("{}")
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Inventário somente leitura de visões, arquivos e trechos já persistidos."""
|
|
2
|
+
import re
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import indexer
|
|
6
|
+
import index_views
|
|
7
|
+
import project_identity
|
|
8
|
+
import document_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def views(root):
|
|
12
|
+
key = project_identity.project_id(root)
|
|
13
|
+
current = index_views.describe(root)
|
|
14
|
+
paths = list(Path(indexer.INDEX_DIR).glob(key + '.v-*.sqlite3'))
|
|
15
|
+
paths += [Path(indexer.INDEX_DIR)/(item+'.sqlite3') for item in
|
|
16
|
+
dict.fromkeys([key, *project_identity.legacy_ids(root)])]
|
|
17
|
+
rows = []
|
|
18
|
+
for path in dict.fromkeys(paths):
|
|
19
|
+
if not path.is_file() or not re.fullmatch(r'[a-f0-9]{16}(?:\.v-[a-f0-9]{16})?\.sqlite3', path.name):
|
|
20
|
+
continue
|
|
21
|
+
conn = indexer._readonly(str(path))
|
|
22
|
+
try:
|
|
23
|
+
meta = indexer._metadata(conn)
|
|
24
|
+
view_id = meta.get('view_id') or ('legacy' if current['git'] else 'workspace')
|
|
25
|
+
rows.append({'view_id': view_id, 'label': meta.get('view_label') or 'Legacy, no identified branch',
|
|
26
|
+
'branch': meta.get('git_branch'), 'commit': meta.get('git_commit'),
|
|
27
|
+
'current': view_id == current['view_id'], 'path': str(path),
|
|
28
|
+
'files': conn.execute('SELECT count(*) FROM manifest').fetchone()[0],
|
|
29
|
+
'chunks': conn.execute('SELECT count(*) FROM chunks').fetchone()[0],
|
|
30
|
+
'model': meta.get('model_id'), 'dimensions': meta.get('dimensions'),
|
|
31
|
+
'updated_at': meta.get('updated_at'), 'bytes': path.stat().st_size})
|
|
32
|
+
finally:
|
|
33
|
+
conn.close()
|
|
34
|
+
return sorted(rows, key=lambda row: (not row['current'], row['label']))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def inspect(root, view_id=None, file_path=None, storage_id=None):
|
|
38
|
+
if file_path is not None:
|
|
39
|
+
if not isinstance(file_path, str):
|
|
40
|
+
raise ValueError("file_path must be a string.")
|
|
41
|
+
file_path = file_path.replace(chr(92), "/").removeprefix("./")
|
|
42
|
+
available = views(root)
|
|
43
|
+
current = index_views.describe(root)
|
|
44
|
+
explicit_view = view_id is not None or storage_id is not None
|
|
45
|
+
view_id = view_id or current['view_id']
|
|
46
|
+
selected = next((row for row in available if (Path(row['path']).name==storage_id if storage_id else row['view_id']==view_id)), None)
|
|
47
|
+
if selected is None and available and not explicit_view:
|
|
48
|
+
selected = available[0]
|
|
49
|
+
if not selected:
|
|
50
|
+
return {'views': available, 'selected': None, 'files': [], 'chunks': [],
|
|
51
|
+
'message': 'This view has not been indexed yet. Run a search or use Index now.'}
|
|
52
|
+
conn = indexer._readonly(selected['path'])
|
|
53
|
+
try:
|
|
54
|
+
columns = {row[1] for row in conn.execute('PRAGMA table_info(manifest)')}
|
|
55
|
+
# Índices anteriores ao manifest incremental não têm size/mtime_ns.
|
|
56
|
+
# A inspeção é somente leitura; não migrar nem medir o arquivo de outra branch.
|
|
57
|
+
size_column = 'm.size' if 'size' in columns else 'NULL'
|
|
58
|
+
files = [{'path': path, 'chunks': count, 'hash': digest, 'size': size} for path, count, digest, size in
|
|
59
|
+
conn.execute(f'SELECT m.path,count(c.id),m.hash,{size_column} FROM manifest m LEFT JOIN chunks c ON c.path=m.path GROUP BY m.path ORDER BY m.path LIMIT 1001')]
|
|
60
|
+
chunks = []
|
|
61
|
+
if file_path is not None:
|
|
62
|
+
if not conn.execute('SELECT 1 FROM manifest WHERE path=?', (file_path,)).fetchone():
|
|
63
|
+
raise ValueError(f'File is outside the indexed view: {file_path}')
|
|
64
|
+
files = []
|
|
65
|
+
chunks = [{'start_line': start, 'end_line': end, 'text': text, 'dimensions': int(size or 0)//4,
|
|
66
|
+
'location_kind':document_text.location_kind(file_path)}
|
|
67
|
+
for start, end, text, size in conn.execute(
|
|
68
|
+
'SELECT start_line,end_line,text,length(embedding) FROM chunks WHERE path=? ORDER BY start_line LIMIT 100', (file_path,))]
|
|
69
|
+
return {'views': available, 'selected': selected, 'files': files[:1000],
|
|
70
|
+
'truncated': len(files)>1000, 'chunks': chunks, 'max_chunks_displayed': 100,
|
|
71
|
+
'current_view': index_views.public(current),
|
|
72
|
+
'message': '' if selected['current'] else 'The current view is not shown. You are inspecting the stored index of ' + selected['label'] + '.',
|
|
73
|
+
'graph_type': 'project_branch_directory_file', 'read_only': True}
|
|
74
|
+
finally:
|
|
75
|
+
conn.close()
|
package/index_profile.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import collections
|
|
2
|
+
import fnmatch
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import model_client
|
|
9
|
+
|
|
10
|
+
PROFILE_VERSION = 2
|
|
11
|
+
KINDS = ("code", "test", "doc")
|
|
12
|
+
RETRY_AFTER_S = 3600
|
|
13
|
+
MAX_DIRS = 90
|
|
14
|
+
PROSE = {"md", "txt", "docx", "rst", "adoc"}
|
|
15
|
+
# Data and markup inside a documentation folder are reports, not code (GitHub Linguist treats docs/ as documentation).
|
|
16
|
+
DOC_DATA = {"html", "htm", "json", "csv", "xml", "yaml", "yml", "svg"}
|
|
17
|
+
_NAME_DOC = re.compile(r"(^|/)(readme|changelog|license|licence|contributing|install|history|authors)(\.[^/]*)?$", re.I)
|
|
18
|
+
_DIR_DOC = re.compile(r"(^|/)(docs?|adrs?|documentation)/", re.I)
|
|
19
|
+
_TEST = re.compile(r"(^|/)(tests?|__tests__|spec|specs|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)test_[^/]+\.py$|"
|
|
20
|
+
r"(^|/)conftest\.py$|_test\.(py|go)$|\.(test|spec)\.[cm]?[jt]sx?$|(^|/)[^/]*[a-z0-9](?-i:Tests?)\.java$|"
|
|
21
|
+
r"(^|/)src/test/|(^|/)test[-_][^/]*\.[cm]?[jt]sx?$|[-_]test\.[cm]?[jt]sx?$", re.I)
|
|
22
|
+
_TEST_PROSE = re.compile(r"(^|/)(tests?|__tests__|e2e|fixtures?|testdata|test-utils?)(/|$)|(^|/)src/test/", re.I)
|
|
23
|
+
_TEXT_LINE = re.compile(r"[A-Za-zÀ-ú]{3,}\s+[A-Za-zÀ-ú]{3,}")
|
|
24
|
+
|
|
25
|
+
_TOOL = {"type": "function", "function": {
|
|
26
|
+
"name": "submit_profile", "description": "Submit the search-index profile.",
|
|
27
|
+
"parameters": {"type": "object", "required": ["groups", "languages"], "properties": {
|
|
28
|
+
"groups": {"type": "array", "items": {"type": "object", "required": ["path", "kind"], "properties": {
|
|
29
|
+
"path": {"type": "string", "description": "directory relative to root, '.' for root, or a glob like '**/NOTES.md'"},
|
|
30
|
+
"kind": {"type": "string", "enum": list(KINDS)}}}},
|
|
31
|
+
"languages": {"type": "object", "required": ["identifiers", "comments", "doc"], "properties": {
|
|
32
|
+
"identifiers": {"type": "array", "items": {"type": "string"},
|
|
33
|
+
"description": "natural languages of identifier names (functions, classes, variables), most prevalent first (BCP-47)"},
|
|
34
|
+
"comments": {"type": "array", "items": {"type": "string"},
|
|
35
|
+
"description": "natural languages of code comments and docstrings, most prevalent first (BCP-47)"},
|
|
36
|
+
"doc": {"type": "array", "items": {"type": "string"},
|
|
37
|
+
"description": "natural languages of documentation, most prevalent first (BCP-47)"}}}}}}}
|
|
38
|
+
|
|
39
|
+
_PROMPT = """You are profiling a repository for a code-search index at {root}.
|
|
40
|
+
Classify where things live, by directory, not file by file. Three kinds:
|
|
41
|
+
- code: anything the product runs or loads: source, templates, HTML pages, config, scripts, and prompt/instruction files that the product itself executes (e.g. agent/skill/command definitions of a plugin).
|
|
42
|
+
- test: automated tests and their fixtures, mocks and helpers (unit, integration, e2e), even in folders with unusual names.
|
|
43
|
+
- doc: material written for people: README, CHANGELOG, docs/, ADRs, plans, guides, notes.
|
|
44
|
+
Use the most specific path needed: a directory, or a glob for files that break the rule of their directory. Paths not listed inherit from their closest listed parent.
|
|
45
|
+
Also report the natural languages used, most prevalent first, separately for identifier names, for code comments and docstrings, and for docs. Identifiers are often English even when comments are not. Use the text samples as evidence.
|
|
46
|
+
Directories (dir, file count, extensions, sample names, text sample):
|
|
47
|
+
{rows}
|
|
48
|
+
(total directories with indexed files: {total}; only the {shown} largest are shown)"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _ext(path):
|
|
52
|
+
name = path.rsplit("/", 1)[-1]
|
|
53
|
+
return name.rsplit(".", 1)[-1].lower() if "." in name else ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _sample(root, rel):
|
|
57
|
+
try:
|
|
58
|
+
with open(os.path.join(root, rel), "rb") as stream:
|
|
59
|
+
raw = stream.read(4000).decode("utf-8", "ignore")
|
|
60
|
+
except OSError:
|
|
61
|
+
return ""
|
|
62
|
+
return " | ".join(line.strip() for line in raw.splitlines() if _TEXT_LINE.search(line))[:260]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _evidence(root, included):
|
|
66
|
+
by_dir = collections.defaultdict(list)
|
|
67
|
+
for rel in included:
|
|
68
|
+
by_dir[rel.rsplit("/", 1)[0] if "/" in rel else "."].append(rel)
|
|
69
|
+
rows = []
|
|
70
|
+
for rel_dir, files in sorted(by_dir.items(), key=lambda item: -len(item[1]))[:MAX_DIRS]:
|
|
71
|
+
names = [path.rsplit("/", 1)[-1] for path in files]
|
|
72
|
+
text = next((path for path in files if _ext(path) in PROSE | {"py", "js", "ts", "java", "html", "mjs", "cjs"}), None)
|
|
73
|
+
rows.append({"dir": rel_dir, "files": len(files),
|
|
74
|
+
"ext": dict(collections.Counter(_ext(path) or "(none)" for path in files).most_common(6)),
|
|
75
|
+
"sample": names[:5], "text_sample": _sample(root, text) if text else ""})
|
|
76
|
+
return rows, len(by_dir)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _valid(args):
|
|
80
|
+
groups, languages = args.get("groups"), args.get("languages")
|
|
81
|
+
return (isinstance(groups, list) and all(isinstance(g, dict) and isinstance(g.get("path"), str)
|
|
82
|
+
and g.get("kind") in KINDS for g in groups)
|
|
83
|
+
and isinstance(languages, dict) and all(isinstance(languages.get(key), list) and
|
|
84
|
+
all(isinstance(item, str) for item in languages[key])
|
|
85
|
+
for key in ("identifiers", "comments", "doc")))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def decide(root, included, model, deadline=None):
|
|
89
|
+
rows, total = _evidence(root, included)
|
|
90
|
+
remaining = deadline - time.monotonic() if deadline is not None else 120
|
|
91
|
+
if remaining <= 0:
|
|
92
|
+
raise TimeoutError("Profile analysis deadline exceeded.")
|
|
93
|
+
response = model_client.fetch("/v1/chat/completions", model_client.get_token(), method="POST",
|
|
94
|
+
timeout=min(120, remaining), body={
|
|
95
|
+
"model": model, "temperature": 0, "tools": [_TOOL], "tool_choice": "required",
|
|
96
|
+
"messages": [{"role": "user", "content": _PROMPT.format(
|
|
97
|
+
root=root, rows=json.dumps(rows, ensure_ascii=False), total=total, shown=len(rows))}]})
|
|
98
|
+
calls = response["choices"][0]["message"].get("tool_calls") or []
|
|
99
|
+
try:
|
|
100
|
+
args = next((json.loads(call["function"]["arguments"] or "{}") for call in calls
|
|
101
|
+
if call["function"]["name"] == "submit_profile"), None)
|
|
102
|
+
except json.JSONDecodeError as exc:
|
|
103
|
+
raise RuntimeError(f"The model returned a profile with invalid JSON: {exc}") from exc
|
|
104
|
+
if not isinstance(args, dict) or not _valid(args):
|
|
105
|
+
raise RuntimeError("The model did not define a valid profile of groups and languages.")
|
|
106
|
+
return {"version": PROFILE_VERSION, "groups": args["groups"], "languages": args["languages"],
|
|
107
|
+
"model": model, "decided_at": time.time()}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _explicit_kind(path, groups):
|
|
111
|
+
"""Kind of the most specific profile group that names this path; None when only the root group covers it."""
|
|
112
|
+
covering = [group for group in groups if _covers(path, group)]
|
|
113
|
+
return _model_kind(path, covering) if covering else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _covers(path, group):
|
|
117
|
+
pattern = group["path"].replace(chr(92), "/").removeprefix("./").strip("/") or "."
|
|
118
|
+
if pattern == ".":
|
|
119
|
+
return False
|
|
120
|
+
if any(char in pattern for char in "*?["):
|
|
121
|
+
return fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(path, pattern.replace("**/", ""))
|
|
122
|
+
return path.startswith(pattern + "/")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _model_kind(path, groups):
|
|
126
|
+
best = None
|
|
127
|
+
for group in groups:
|
|
128
|
+
pattern = group["path"].replace(chr(92), "/").removeprefix("./").strip("/") or "."
|
|
129
|
+
if any(char in pattern for char in "*?["):
|
|
130
|
+
if fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(path, pattern.replace("**/", "")):
|
|
131
|
+
return group["kind"]
|
|
132
|
+
elif pattern == "." or path.startswith(pattern + "/"):
|
|
133
|
+
depth = 0 if pattern == "." else len(pattern)
|
|
134
|
+
if best is None or depth > best[0]:
|
|
135
|
+
best = (depth, group["kind"])
|
|
136
|
+
return best[1] if best else "doc"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def current(profile):
|
|
140
|
+
return profile if (profile or {}).get("version") == PROFILE_VERSION else None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def kind(path, profile):
|
|
144
|
+
groups = (current(profile) or {}).get("groups") or []
|
|
145
|
+
ext = _ext(path)
|
|
146
|
+
if ext in PROSE:
|
|
147
|
+
if _NAME_DOC.search(path) or _DIR_DOC.search(path):
|
|
148
|
+
return "doc"
|
|
149
|
+
if _TEST_PROSE.search(path):
|
|
150
|
+
return "test"
|
|
151
|
+
return _model_kind(path, groups or [{"path": ".", "kind": "doc"}])
|
|
152
|
+
if _TEST.search(path):
|
|
153
|
+
return "test"
|
|
154
|
+
if ext in DOC_DATA and _DIR_DOC.search(path) and _explicit_kind(path, groups) != "code":
|
|
155
|
+
return "doc"
|
|
156
|
+
if not ext and _NAME_DOC.search(path):
|
|
157
|
+
return "doc"
|
|
158
|
+
return "test" if groups and _model_kind(path, groups) == "test" else "code"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def public(profile):
|
|
162
|
+
profile = current(profile)
|
|
163
|
+
if not profile:
|
|
164
|
+
return {"source": "rules", "identifier_languages": [], "comment_languages": [], "doc_languages": []}
|
|
165
|
+
languages = profile["languages"]
|
|
166
|
+
return {"source": "model", "identifier_languages": languages["identifiers"],
|
|
167
|
+
"comment_languages": languages["comments"], "doc_languages": languages["doc"]}
|