@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
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)
@@ -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'