@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.
- package/CHANGELOG.md +23 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -3
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +82 -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 +251 -0
- package/install_runtime.py +249 -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 +35 -6
- 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/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/config.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Configuração de modelos do Smart Tool, compartilhada por todos os hosts MCP.
|
|
3
|
+
|
|
4
|
+
Chaves de API ficam em `api_keys` sempre cifradas com DPAPI; texto em claro é recusado na gravação.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import paths
|
|
9
|
+
import threading
|
|
10
|
+
|
|
11
|
+
import atomic_io
|
|
12
|
+
import secret_store
|
|
13
|
+
|
|
14
|
+
# Alias de compatibilidade: a implementação mora em `atomic_io`.
|
|
15
|
+
atomic_replace = atomic_io.atomic_replace
|
|
16
|
+
|
|
17
|
+
CONFIG_DIR = paths.HOME_DIR
|
|
18
|
+
CONFIG_PATH = os.path.join(CONFIG_DIR, "config.json")
|
|
19
|
+
|
|
20
|
+
# RLock: update_config() chama load/save sob o mesmo lock; Lock travaria a própria thread.
|
|
21
|
+
_LOCK = threading.RLock()
|
|
22
|
+
|
|
23
|
+
DEFAULT_CONFIG = {
|
|
24
|
+
"router_model": "",
|
|
25
|
+
"scope_model": "",
|
|
26
|
+
"embedding_model": "",
|
|
27
|
+
"rerank_model": "",
|
|
28
|
+
"research_model": "",
|
|
29
|
+
"research_max_iterations": 3,
|
|
30
|
+
"quick_search_timeout_s": 120,
|
|
31
|
+
"site_memory_similarity_threshold": 0.35,
|
|
32
|
+
"local_models": "fallback",
|
|
33
|
+
"hook_mode": "redirect",
|
|
34
|
+
"contact": "",
|
|
35
|
+
"model_adapter": "",
|
|
36
|
+
"model_adapter_options": {},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Local models from the mcp-memory sidecar: fallback = only when the gateway has no model; off = never;
|
|
40
|
+
# prefer = even when the gateway has one (embedding quality measured equal on the rerank gold set).
|
|
41
|
+
LOCAL_MODEL_MODES = ("fallback", "off", "prefer")
|
|
42
|
+
# PreToolUse hook: redirect = deny the native tool and point to Smart Tool; advise = let it run and add the tip to the
|
|
43
|
+
# agent's context; off = no routing at all.
|
|
44
|
+
HOOK_MODES = ("redirect", "advise", "off")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def hook_mode(cfg=None):
|
|
48
|
+
value = (cfg if cfg is not None else load_config()).get("hook_mode") or DEFAULT_CONFIG["hook_mode"]
|
|
49
|
+
if value not in HOOK_MODES:
|
|
50
|
+
raise ValueError(f"Invalid hook_mode ({value!r}) in {CONFIG_PATH}: use redirect, advise or off.")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def local_models_mode(cfg=None):
|
|
55
|
+
value = (cfg if cfg is not None else load_config()).get("local_models") or DEFAULT_CONFIG["local_models"]
|
|
56
|
+
if value not in LOCAL_MODEL_MODES:
|
|
57
|
+
raise ValueError(f"Invalid local_models ({value!r}) in {CONFIG_PATH}: use fallback, off or prefer.")
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Único lugar com os limites/recomendados de research_max_iterations e
|
|
62
|
+
# site_memory_similarity_threshold — configure.py, setup_ui.py e smart_tool_daemon.py leem
|
|
63
|
+
# daqui em vez de cada um definir seu próprio intervalo (mesmo padrão de fonte única de
|
|
64
|
+
# web_search_adapters.py pros tiers de busca).
|
|
65
|
+
RESEARCH_MAX_ITERATIONS_RANGE = (1, 10)
|
|
66
|
+
QUICK_SEARCH_TIMEOUT_RANGE = (30, 360)
|
|
67
|
+
SITE_MEMORY_SIMILARITY_THRESHOLD_RANGE = (0.0, 1.0)
|
|
68
|
+
WEB_PROVIDER_NAMES = ("exa", "parallel", "keenable", "tavily", "firecrawl")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _within(value, cast, bounds, key):
|
|
72
|
+
lo, hi = bounds
|
|
73
|
+
try:
|
|
74
|
+
n = cast(value)
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
return DEFAULT_CONFIG[key]
|
|
77
|
+
return n if lo <= n <= hi else DEFAULT_CONFIG[key]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def clamp_research_max_iterations(value):
|
|
81
|
+
return _within(value, int, RESEARCH_MAX_ITERATIONS_RANGE, "research_max_iterations")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def clamp_quick_search_timeout(value):
|
|
85
|
+
return _within(value, int, QUICK_SEARCH_TIMEOUT_RANGE, "quick_search_timeout_s")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def clamp_site_memory_similarity_threshold(value):
|
|
89
|
+
return _within(value, float, SITE_MEMORY_SIMILARITY_THRESHOLD_RANGE, "site_memory_similarity_threshold")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ConfigCorruptedError(Exception):
|
|
93
|
+
"""O arquivo existe mas não é JSON de objeto legível. Distinto de "não existe":
|
|
94
|
+
`load_config()` pode cair pros defaults num arquivo ausente, mas gravar sobre
|
|
95
|
+
um arquivo ilegível apagaria configurações que ainda poderiam ser recuperadas."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _read_stored():
|
|
99
|
+
try:
|
|
100
|
+
with open(CONFIG_PATH, "r", encoding="utf-8") as f:
|
|
101
|
+
stored = json.load(f)
|
|
102
|
+
except FileNotFoundError:
|
|
103
|
+
return None
|
|
104
|
+
except (json.JSONDecodeError, UnicodeDecodeError) as exc:
|
|
105
|
+
raise ConfigCorruptedError(
|
|
106
|
+
f"{CONFIG_PATH} is not valid JSON ({exc}); fix or delete the file manually."
|
|
107
|
+
)
|
|
108
|
+
if not isinstance(stored, dict):
|
|
109
|
+
raise ConfigCorruptedError(f"{CONFIG_PATH} does not contain a JSON object; fix or delete the file manually.")
|
|
110
|
+
return stored
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_config():
|
|
114
|
+
"""Defaults mesclados com o que está em disco. Um arquivo corrompido devolve os
|
|
115
|
+
defaults em vez de levantar: leitura é caminho quente (todo tool call passa por
|
|
116
|
+
aqui) e não pode derrubar o daemon — quem for gravar usa `update_config`, que
|
|
117
|
+
recusa escrever nesse estado."""
|
|
118
|
+
with _LOCK:
|
|
119
|
+
try:
|
|
120
|
+
stored = _read_stored() or {}
|
|
121
|
+
except ConfigCorruptedError:
|
|
122
|
+
stored = {}
|
|
123
|
+
merged = dict(DEFAULT_CONFIG)
|
|
124
|
+
if "web_provider_keys" in stored:
|
|
125
|
+
raise ConfigCorruptedError(f"{CONFIG_PATH} uses the old 'web_provider_keys' format: delete that field and "
|
|
126
|
+
"enter the keys again in the setup screen.")
|
|
127
|
+
merged.update(stored)
|
|
128
|
+
return merged
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def save_config(config):
|
|
132
|
+
with _LOCK:
|
|
133
|
+
os.makedirs(CONFIG_DIR, exist_ok=True)
|
|
134
|
+
# Mantém o gravador atômico: a instalação antiga guardava segredo aqui.
|
|
135
|
+
clean = dict(config)
|
|
136
|
+
provider_keys = clean.get("api_keys") or {}
|
|
137
|
+
if (not isinstance(provider_keys, dict) or any(
|
|
138
|
+
not _known_key(name) or not secret_store.is_protected(value)
|
|
139
|
+
for name, value in provider_keys.items()
|
|
140
|
+
)):
|
|
141
|
+
raise ValueError("Keys must be protected by DPAPI.")
|
|
142
|
+
atomic_io.write_secret_text(
|
|
143
|
+
CONFIG_PATH, json.dumps(clean, indent=2, ensure_ascii=False)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def corrupted_reason():
|
|
148
|
+
"""Motivo pelo qual o arquivo em disco é ilegível, ou `None` se está legível/ausente.
|
|
149
|
+
`load_config` cai nos defaults nesse caso, então sem isto uma config truncada é
|
|
150
|
+
indistinguível de uma config vazia na hora de explicar a falha ao usuário."""
|
|
151
|
+
with _LOCK:
|
|
152
|
+
try:
|
|
153
|
+
_read_stored()
|
|
154
|
+
except ConfigCorruptedError as exc:
|
|
155
|
+
return str(exc)
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def update_config(mutate):
|
|
160
|
+
"""Load + mutate + save como uma única seção crítica, pra dois handlers HTTP
|
|
161
|
+
concorrentes nunca perderem a escrita um do outro. Levanta `ConfigCorruptedError`
|
|
162
|
+
se o arquivo em disco estiver ilegível, em vez de substituí-lo pelos defaults."""
|
|
163
|
+
with _LOCK:
|
|
164
|
+
stored = _read_stored() or {}
|
|
165
|
+
cfg = dict(DEFAULT_CONFIG)
|
|
166
|
+
cfg.update(stored)
|
|
167
|
+
# Serializado (não `dict(cfg)`): a comparação tem que enxergar mutação in-place
|
|
168
|
+
# de um valor aninhado, que uma cópia rasa esconderia. Mesmo contrato de
|
|
169
|
+
# serialização de `save_config`, pra um valor que só a comparação aceitaria não
|
|
170
|
+
# passar da guarda e estourar na gravação.
|
|
171
|
+
before = json.dumps(cfg, sort_keys=True)
|
|
172
|
+
mutate(cfg)
|
|
173
|
+
# Mutação que não muda nada não regrava.
|
|
174
|
+
if json.dumps(cfg, sort_keys=True) == before and os.path.isfile(CONFIG_PATH):
|
|
175
|
+
return cfg
|
|
176
|
+
save_config(cfg)
|
|
177
|
+
return cfg
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _known_key(name):
|
|
181
|
+
"""Web providers by name; model adapter secrets as "adapter:<adapter>:<field>" (see gateway.py)."""
|
|
182
|
+
return name in WEB_PROVIDER_NAMES or (isinstance(name, str) and name.startswith("adapter:"))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _provider_name(name):
|
|
186
|
+
if not _known_key(name):
|
|
187
|
+
raise ValueError(f"Unknown key: {name!r}")
|
|
188
|
+
return name
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_provider_api_key(name):
|
|
192
|
+
"""Devolve a chave opcional em claro só no processo que fará a chamada HTTP."""
|
|
193
|
+
name = _provider_name(name)
|
|
194
|
+
stored = (load_config().get("api_keys") or {}).get(name) or ""
|
|
195
|
+
if not stored:
|
|
196
|
+
return ""
|
|
197
|
+
if not secret_store.is_protected(stored):
|
|
198
|
+
raise secret_store.SecretProtectionError("Provider key is not protected by DPAPI.")
|
|
199
|
+
return secret_store.unprotect(stored)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def provider_key_status(name):
|
|
203
|
+
"""Estado para a UI, sem revelar a chave nem o blob DPAPI."""
|
|
204
|
+
name = _provider_name(name)
|
|
205
|
+
stored = (load_config().get("api_keys") or {}).get(name) or ""
|
|
206
|
+
if not stored:
|
|
207
|
+
return "keyless"
|
|
208
|
+
try:
|
|
209
|
+
get_provider_api_key(name)
|
|
210
|
+
except secret_store.SecretProtectionError:
|
|
211
|
+
return "unreadable"
|
|
212
|
+
return "configured"
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def check_provider_key_format(key):
|
|
216
|
+
if len(key) > 4096 or any(ord(char) < 33 for char in key) or secret_store.is_protected(key):
|
|
217
|
+
raise ValueError("Paste the provider's original API key, without spaces or line breaks.")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def set_provider_api_key(name, key):
|
|
221
|
+
"""Grava/remove chave externa sob DPAPI; nunca persiste texto puro."""
|
|
222
|
+
name = _provider_name(name)
|
|
223
|
+
if key is not None and not isinstance(key, str):
|
|
224
|
+
raise ValueError("API key must be text.")
|
|
225
|
+
key = (key or "").strip()
|
|
226
|
+
if key:
|
|
227
|
+
check_provider_key_format(key)
|
|
228
|
+
protected = secret_store.protect(key) if key else ""
|
|
229
|
+
|
|
230
|
+
def mutate(cfg):
|
|
231
|
+
keys = dict(cfg.get("api_keys") or {})
|
|
232
|
+
if protected:
|
|
233
|
+
keys[name] = protected
|
|
234
|
+
else:
|
|
235
|
+
keys.pop(name, None)
|
|
236
|
+
if keys:
|
|
237
|
+
cfg["api_keys"] = keys
|
|
238
|
+
else:
|
|
239
|
+
cfg.pop("api_keys", None)
|
|
240
|
+
|
|
241
|
+
update_config(mutate)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lifecycle do daemon HTTP do Smart Tool: self-start com lock. Ao contrário do Brain
|
|
3
|
+
Server (client-pure, nunca spawna, depende de instalador externo — o daemon que está
|
|
4
|
+
caído neste ambiente por isso mesmo), este cliente sobe o processo sozinho se estiver
|
|
5
|
+
fora do ar, protegido por lockfile contra corrida entre sessões simultâneas.
|
|
6
|
+
|
|
7
|
+
Contrato de registry (mesmo formato usado pelo probe de grafo em `capabilities.py`):
|
|
8
|
+
`~/.smart-tool/data/daemon.json` = `{"url": "http://127.0.0.1:<porta>"}` —
|
|
9
|
+
`url` é a base do daemon; o endpoint MCP em si é `<url>/mcp`.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import paths
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.request
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
RUN_DIR = paths.DATA_DIR
|
|
23
|
+
REGISTRY_PATH = os.path.join(RUN_DIR, "daemon.json")
|
|
24
|
+
LOCK_PATH = os.path.join(RUN_DIR, "daemon.lock")
|
|
25
|
+
LOCK_STALE_SECONDS = 30
|
|
26
|
+
DEFAULT_PORT = int(os.environ.get("SMART_TOOL_PORT", "8765"))
|
|
27
|
+
DAEMON_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "smart_tool_daemon.py")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_registry():
|
|
31
|
+
try:
|
|
32
|
+
with open(REGISTRY_PATH, "r", encoding="utf-8") as f:
|
|
33
|
+
info = json.load(f)
|
|
34
|
+
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
35
|
+
return None
|
|
36
|
+
if isinstance(info, dict) and info.get("url"):
|
|
37
|
+
return info
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def health(url, timeout=1):
|
|
42
|
+
"""Timeout curto porque só se fala com o loopback: um daemon vivo responde `/health`
|
|
43
|
+
em milissegundos (o servidor é multi-thread, indexação em curso não bloqueia), e o
|
|
44
|
+
caso lento é justamente o daemon morto cuja URL ainda está no registry — tempo que
|
|
45
|
+
toda tool call do agente paga antes de seguir."""
|
|
46
|
+
try:
|
|
47
|
+
with urllib.request.urlopen(url.rstrip("/") + "/health", timeout=timeout) as resp:
|
|
48
|
+
return resp.status == 200
|
|
49
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
50
|
+
return False
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _acquire_lock():
|
|
54
|
+
os.makedirs(RUN_DIR, exist_ok=True)
|
|
55
|
+
try:
|
|
56
|
+
if time.time() - os.path.getmtime(LOCK_PATH) > LOCK_STALE_SECONDS:
|
|
57
|
+
os.remove(LOCK_PATH)
|
|
58
|
+
except OSError:
|
|
59
|
+
pass
|
|
60
|
+
try:
|
|
61
|
+
fd = os.open(LOCK_PATH, os.O_CREAT | os.O_EXCL | os.O_RDWR)
|
|
62
|
+
os.close(fd)
|
|
63
|
+
return True
|
|
64
|
+
except FileExistsError:
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _release_lock():
|
|
69
|
+
try:
|
|
70
|
+
os.remove(LOCK_PATH)
|
|
71
|
+
except OSError:
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _spawn_daemon():
|
|
76
|
+
executable = Path(sys.executable)
|
|
77
|
+
if sys.platform == "win32":
|
|
78
|
+
# O venv pythonw.exe pode morrer sem iniciar o daemon. O python.exe iniciado
|
|
79
|
+
# direto abre uma aba no Windows Terminal; fechá-la mata o serviço e o tray
|
|
80
|
+
# tenta subir outra a cada polling. WScript cria um processo independente com
|
|
81
|
+
# a janela oculta, como no Passtrough.
|
|
82
|
+
if executable.name.lower() == "pythonw.exe":
|
|
83
|
+
executable = executable.with_name("python.exe")
|
|
84
|
+
fd, launcher = tempfile.mkstemp(prefix="smart-tool-hidden-", suffix=".vbs")
|
|
85
|
+
try:
|
|
86
|
+
command = (f'Chr(34) & "{executable}" & Chr(34) & " " & '
|
|
87
|
+
f'Chr(34) & "{DAEMON_SCRIPT}" & Chr(34)')
|
|
88
|
+
with os.fdopen(fd, "w", encoding="utf-16") as stream:
|
|
89
|
+
stream.write('Set sh = CreateObject("WScript.Shell")\n')
|
|
90
|
+
stream.write(f'sh.CurrentDirectory = "{Path(DAEMON_SCRIPT).parent}"\n')
|
|
91
|
+
stream.write(f'sh.Run {command}, 0, False\n')
|
|
92
|
+
result = subprocess.run(
|
|
93
|
+
["wscript.exe", "//B", "//NoLogo", launcher],
|
|
94
|
+
stdin=subprocess.DEVNULL, capture_output=True, timeout=10,
|
|
95
|
+
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
96
|
+
)
|
|
97
|
+
if result.returncode:
|
|
98
|
+
raise RuntimeError("Windows could not start the Smart Tool daemon without a window.")
|
|
99
|
+
finally:
|
|
100
|
+
Path(launcher).unlink(missing_ok=True)
|
|
101
|
+
return
|
|
102
|
+
subprocess.Popen(
|
|
103
|
+
[str(executable), DAEMON_SCRIPT],
|
|
104
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
|
|
105
|
+
stderr=subprocess.DEVNULL, close_fds=True, start_new_session=True,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def ensure_daemon_running(wait_seconds=10):
|
|
110
|
+
"""Retorna a URL base do daemon (sem `/mcp`), subindo o processo se necessário."""
|
|
111
|
+
info = read_registry()
|
|
112
|
+
if info and health(info["url"]):
|
|
113
|
+
return info["url"]
|
|
114
|
+
|
|
115
|
+
if not _acquire_lock():
|
|
116
|
+
url = _wait_for_daemon(wait_seconds)
|
|
117
|
+
if url:
|
|
118
|
+
return url
|
|
119
|
+
raise RuntimeError("Timed out waiting for another session to start the Smart Tool daemon")
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
_spawn_daemon()
|
|
123
|
+
url = _wait_for_daemon(wait_seconds)
|
|
124
|
+
if url:
|
|
125
|
+
return url
|
|
126
|
+
raise RuntimeError("Timed out starting the Smart Tool daemon")
|
|
127
|
+
finally:
|
|
128
|
+
_release_lock()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def daemon_url_if_up():
|
|
132
|
+
info = read_registry()
|
|
133
|
+
if info and health(info["url"], timeout=0.5):
|
|
134
|
+
return info["url"]
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def start_daemon_if_down():
|
|
139
|
+
"""`True` se o daemon já responde; `False` se acabou de disparar o start, sem esperar.
|
|
140
|
+
|
|
141
|
+
Para quem está num caminho sensível a latência (o hook PreToolUse roda antes de toda
|
|
142
|
+
Grep/Glob/Read/Bash): esperar o processo subir pode custar segundos — spawn de
|
|
143
|
+
processo em máquina com EDR varia muito — e quem chama tem um desfecho válido sem o
|
|
144
|
+
daemon. A garantia de daemon único não depende do lock ser mantido aqui: um segundo
|
|
145
|
+
processo falha no bind exclusivo da porta e morre sozinho."""
|
|
146
|
+
if daemon_url_if_up():
|
|
147
|
+
return True
|
|
148
|
+
if _acquire_lock():
|
|
149
|
+
# O lock NÃO é liberado aqui: ele é o freio de taxa. Expirando por idade
|
|
150
|
+
# (`LOCK_STALE_SECONDS`), um daemon que não consegue subir — porta tomada, import
|
|
151
|
+
# quebrado — resulta em uma tentativa a cada 30s, e não num processo órfão por
|
|
152
|
+
# tool call. Quem perde a corrida do bind morre sozinho (`allow_reuse_address`
|
|
153
|
+
# False), então soltar o lock não acrescentaria segurança nenhuma.
|
|
154
|
+
_spawn_daemon()
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _wait_for_daemon(wait_seconds, poll_interval=0.1):
|
|
159
|
+
deadline = time.time() + wait_seconds
|
|
160
|
+
while time.time() < deadline:
|
|
161
|
+
info = read_registry()
|
|
162
|
+
if info and health(info["url"], timeout=0.5):
|
|
163
|
+
return info["url"]
|
|
164
|
+
time.sleep(poll_interval)
|
|
165
|
+
return None
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""ReadDirectoryChangesW assíncrono: eventos nativos com parada cooperativa."""
|
|
2
|
+
import os
|
|
3
|
+
import struct
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DirectoryWatcher:
|
|
8
|
+
def __init__(self, root, on_events, on_error):
|
|
9
|
+
self.root = root
|
|
10
|
+
self.on_events = on_events
|
|
11
|
+
self.on_error = on_error
|
|
12
|
+
self._stop = threading.Event()
|
|
13
|
+
self._thread = None
|
|
14
|
+
self.ready = threading.Event()
|
|
15
|
+
|
|
16
|
+
def start(self):
|
|
17
|
+
if self._thread and self._thread.is_alive():
|
|
18
|
+
return
|
|
19
|
+
self._thread = threading.Thread(target=self._run, name="directory-events", daemon=True)
|
|
20
|
+
self._thread.start()
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def alive(self):
|
|
24
|
+
return bool(self._thread and self._thread.is_alive())
|
|
25
|
+
|
|
26
|
+
def stop(self):
|
|
27
|
+
self._stop.set()
|
|
28
|
+
if self._thread and self._thread is not threading.current_thread():
|
|
29
|
+
self._thread.join(timeout=3)
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def decode(buffer, length):
|
|
33
|
+
rows, offset = [], 0
|
|
34
|
+
while offset < length:
|
|
35
|
+
if offset + 12 > length:
|
|
36
|
+
raise ValueError("Incomplete directory event.")
|
|
37
|
+
next_offset, action, size = struct.unpack_from("<III", buffer, offset)
|
|
38
|
+
if size % 2 or offset + 12 + size > length:
|
|
39
|
+
raise ValueError("Invalid file name in event.")
|
|
40
|
+
name = buffer[offset + 12:offset + 12 + size].decode("utf-16-le")
|
|
41
|
+
rows.append({"path": name.replace("\\", "/"), "action": action})
|
|
42
|
+
if not next_offset:
|
|
43
|
+
break
|
|
44
|
+
if next_offset < 12 or offset + next_offset >= length:
|
|
45
|
+
raise ValueError("Invalid event offset.")
|
|
46
|
+
offset += next_offset
|
|
47
|
+
return rows
|
|
48
|
+
|
|
49
|
+
def _run(self):
|
|
50
|
+
if os.name != "nt":
|
|
51
|
+
self.on_error("Native events unavailable on this platform; periodic reconciliation is active.")
|
|
52
|
+
return
|
|
53
|
+
import ctypes
|
|
54
|
+
from ctypes import wintypes
|
|
55
|
+
|
|
56
|
+
class Overlapped(ctypes.Structure):
|
|
57
|
+
_fields_ = [("Internal", ctypes.c_size_t), ("InternalHigh", ctypes.c_size_t),
|
|
58
|
+
("Offset", wintypes.DWORD), ("OffsetHigh", wintypes.DWORD),
|
|
59
|
+
("hEvent", wintypes.HANDLE)]
|
|
60
|
+
|
|
61
|
+
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
62
|
+
kernel.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
|
63
|
+
ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE]
|
|
64
|
+
kernel.CreateFileW.restype = wintypes.HANDLE
|
|
65
|
+
kernel.CreateEventW.argtypes = [ctypes.c_void_p, wintypes.BOOL, wintypes.BOOL, wintypes.LPCWSTR]
|
|
66
|
+
kernel.CreateEventW.restype = wintypes.HANDLE
|
|
67
|
+
kernel.ReadDirectoryChangesW.argtypes = [wintypes.HANDLE, ctypes.c_void_p, wintypes.DWORD,
|
|
68
|
+
wintypes.BOOL, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), ctypes.POINTER(Overlapped), ctypes.c_void_p]
|
|
69
|
+
kernel.ReadDirectoryChangesW.restype = wintypes.BOOL
|
|
70
|
+
kernel.GetOverlappedResult.argtypes = [wintypes.HANDLE, ctypes.POINTER(Overlapped),
|
|
71
|
+
ctypes.POINTER(wintypes.DWORD), wintypes.BOOL]
|
|
72
|
+
kernel.GetOverlappedResult.restype = wintypes.BOOL
|
|
73
|
+
kernel.CancelIoEx.argtypes = [wintypes.HANDLE, ctypes.c_void_p]
|
|
74
|
+
kernel.CancelIoEx.restype = wintypes.BOOL
|
|
75
|
+
kernel.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
|
|
76
|
+
kernel.WaitForSingleObject.restype = wintypes.DWORD
|
|
77
|
+
kernel.ResetEvent.argtypes = [wintypes.HANDLE]
|
|
78
|
+
kernel.CloseHandle.argtypes = [wintypes.HANDLE]
|
|
79
|
+
handle, event, operation = None, None, None
|
|
80
|
+
pending = False
|
|
81
|
+
try:
|
|
82
|
+
handle = kernel.CreateFileW(self.root, 0x0001, 0x0007, None, 3,
|
|
83
|
+
0x02000000 | 0x40000000, None)
|
|
84
|
+
if handle == ctypes.c_void_p(-1).value:
|
|
85
|
+
handle = None
|
|
86
|
+
raise OSError(ctypes.get_last_error(), "Could not watch the folder.")
|
|
87
|
+
event = kernel.CreateEventW(None, True, False, None)
|
|
88
|
+
if not event:
|
|
89
|
+
raise OSError(ctypes.get_last_error(), "Could not create the watch event.")
|
|
90
|
+
buffer = ctypes.create_string_buffer(64 * 1024)
|
|
91
|
+
count = wintypes.DWORD()
|
|
92
|
+
while not self._stop.is_set():
|
|
93
|
+
kernel.ResetEvent(event)
|
|
94
|
+
operation = Overlapped()
|
|
95
|
+
operation.hEvent = event
|
|
96
|
+
ok = kernel.ReadDirectoryChangesW(handle, buffer, len(buffer), True,
|
|
97
|
+
0x0001 | 0x0002 | 0x0008 | 0x0010 | 0x0040,
|
|
98
|
+
None, ctypes.byref(operation), None)
|
|
99
|
+
if not ok and ctypes.get_last_error() != 997:
|
|
100
|
+
raise OSError(ctypes.get_last_error(), "Failed to receive folder changes.")
|
|
101
|
+
pending = True
|
|
102
|
+
self.ready.set()
|
|
103
|
+
while not self._stop.is_set():
|
|
104
|
+
status = kernel.WaitForSingleObject(event, 250)
|
|
105
|
+
if status == 0:
|
|
106
|
+
break
|
|
107
|
+
if status != 258:
|
|
108
|
+
raise OSError(ctypes.get_last_error(), "Failed while waiting for folder events.")
|
|
109
|
+
if self._stop.is_set():
|
|
110
|
+
break
|
|
111
|
+
ok = kernel.GetOverlappedResult(handle, ctypes.byref(operation), ctypes.byref(count), False)
|
|
112
|
+
pending = False
|
|
113
|
+
if not ok:
|
|
114
|
+
raise OSError(ctypes.get_last_error(), "Folder event interrupted.")
|
|
115
|
+
try:
|
|
116
|
+
rows = self.decode(buffer.raw, count.value) if count.value else []
|
|
117
|
+
except (ValueError, UnicodeError):
|
|
118
|
+
rows = []
|
|
119
|
+
self.on_events(rows, not bool(rows))
|
|
120
|
+
except Exception as exc:
|
|
121
|
+
if not self._stop.is_set():
|
|
122
|
+
self.on_error(f"{type(exc).__name__}: {exc}")
|
|
123
|
+
finally:
|
|
124
|
+
if handle and pending and operation is not None:
|
|
125
|
+
kernel.CancelIoEx(handle, ctypes.byref(operation))
|
|
126
|
+
count = wintypes.DWORD()
|
|
127
|
+
kernel.GetOverlappedResult(handle, ctypes.byref(operation), ctypes.byref(count), True)
|
|
128
|
+
if handle:
|
|
129
|
+
kernel.CloseHandle(handle)
|
|
130
|
+
if event:
|
|
131
|
+
kernel.CloseHandle(event)
|
package/document_text.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Extração local de texto DOCX, sem Office, execução de macros ou acesso externo."""
|
|
2
|
+
import io
|
|
3
|
+
import re
|
|
4
|
+
import zipfile
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
|
|
7
|
+
VERSION = 1
|
|
8
|
+
MAX_ARCHIVE_BYTES = 10 * 1024 * 1024
|
|
9
|
+
MAX_XML_BYTES = 8 * 1024 * 1024
|
|
10
|
+
MAX_TEXT_BYTES = 1024 * 1024
|
|
11
|
+
W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DocumentError(ValueError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class _DocumentTreeBuilder(ET.TreeBuilder):
|
|
19
|
+
def doctype(self, name, pubid, system):
|
|
20
|
+
# O callback também recusa DTD em XML UTF-16, antes de expandir entidades.
|
|
21
|
+
raise DocumentError('DOCX contains disallowed entities.')
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def extract_docx(data):
|
|
25
|
+
if len(data)>MAX_ARCHIVE_BYTES:
|
|
26
|
+
raise DocumentError('DOCX over the 10 MiB limit.')
|
|
27
|
+
try:
|
|
28
|
+
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
29
|
+
listing=archive.infolist()
|
|
30
|
+
entries={info.filename:info for info in listing}
|
|
31
|
+
if len(listing)>2000 or 'word/document.xml' not in entries:
|
|
32
|
+
raise DocumentError('Invalid DOCX or excessive structure.')
|
|
33
|
+
names=['word/document.xml']+sorted(name for name in entries if re.fullmatch(r'word/(?:header\d+|footer\d+|footnotes|endnotes)\.xml',name))
|
|
34
|
+
paragraphs=[];xml_bytes=0;text_bytes=0
|
|
35
|
+
for name in names:
|
|
36
|
+
info=entries[name]
|
|
37
|
+
if info.flag_bits&1 or info.compress_type not in (zipfile.ZIP_STORED,zipfile.ZIP_DEFLATED):
|
|
38
|
+
raise DocumentError('Encrypted DOCX or unsupported compression.')
|
|
39
|
+
if info.file_size>MAX_XML_BYTES-xml_bytes:
|
|
40
|
+
raise DocumentError('DOCX XML content over the limit.')
|
|
41
|
+
with archive.open(info) as stream:
|
|
42
|
+
raw=stream.read(MAX_XML_BYTES-xml_bytes+1)
|
|
43
|
+
xml_bytes+=len(raw)
|
|
44
|
+
if xml_bytes>MAX_XML_BYTES or b'<!DOCTYPE' in raw.upper() or b'<!ENTITY' in raw.upper():
|
|
45
|
+
raise DocumentError('DOCX contains excessive XML or disallowed entities.')
|
|
46
|
+
root=ET.fromstring(raw,parser=ET.XMLParser(target=_DocumentTreeBuilder()))
|
|
47
|
+
namespace=W if root.tag.startswith(W) else '{http://purl.oclc.org/ooxml/wordprocessingml/main}'
|
|
48
|
+
for paragraph in root.iter(namespace+'p'):
|
|
49
|
+
def parts(node):
|
|
50
|
+
pending=list(reversed(node))
|
|
51
|
+
while pending:
|
|
52
|
+
child=pending.pop()
|
|
53
|
+
if child.tag==namespace+'p':continue
|
|
54
|
+
if child.tag==namespace+'t':yield child.text or ''
|
|
55
|
+
elif child.tag==namespace+'tab':yield '\t'
|
|
56
|
+
elif child.tag in (namespace+'br',namespace+'cr'):yield '\n'
|
|
57
|
+
else:pending.extend(reversed(child))
|
|
58
|
+
text=''.join(parts(paragraph))
|
|
59
|
+
if text.strip():
|
|
60
|
+
text_bytes+=len(text.encode('utf-8'))+1
|
|
61
|
+
if text_bytes>MAX_TEXT_BYTES:raise DocumentError('Text extracted from DOCX exceeds 1 MiB.')
|
|
62
|
+
paragraphs.append(text)
|
|
63
|
+
return ('\n'.join(paragraphs)+'\n' if paragraphs else '').encode('utf-8')
|
|
64
|
+
except (zipfile.BadZipFile,ET.ParseError,RuntimeError,NotImplementedError,OSError) as exc:
|
|
65
|
+
raise DocumentError('Could not extract text from this DOCX.') from exc
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def location_kind(path):
|
|
69
|
+
return 'extracted_text_line' if str(path).lower().endswith('.docx') else 'source_line'
|