@allansantos-dev/smart-tool 0.0.0-stage → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/LICENSE +202 -0
- package/NOTICE +32 -0
- package/README.md +116 -2
- package/atomic_io.py +94 -0
- package/autostart.py +199 -0
- package/bin/smart-tool.mjs +86 -0
- package/browser_service_client.py +232 -0
- package/capabilities.py +63 -0
- package/client_hooks.py +192 -0
- package/code_graph.py +403 -0
- package/code_graph_java.mjs +49 -0
- package/code_graph_js.cjs +135 -0
- package/config.py +241 -0
- package/daemon_launcher.py +165 -0
- package/directory_watch.py +131 -0
- package/document_text.py +69 -0
- package/duplicates.py +313 -0
- package/embedding_cache.py +99 -0
- package/endpoint_sync.py +179 -0
- package/folder_picker.py +73 -0
- package/freshness.py +278 -0
- package/gateway.py +220 -0
- package/hook_decision.py +96 -0
- package/hooks/pretool_router.py +45 -0
- package/index_inventory.py +75 -0
- package/index_profile.py +167 -0
- package/index_scope.py +458 -0
- package/index_views.py +121 -0
- package/indexer.py +878 -0
- package/install.py +353 -0
- package/install_runtime.py +283 -0
- package/integration_metrics.py +82 -0
- package/local_embedder.py +172 -0
- package/model_adapters/__init__.py +0 -0
- package/model_adapters/openai_compatible.py +57 -0
- package/model_catalog.py +74 -0
- package/model_client.py +38 -0
- package/model_defaults.py +70 -0
- package/package.json +33 -4
- package/paths.py +6 -0
- package/project_identity.py +42 -0
- package/project_insights_ui.py +38 -0
- package/project_maps_ui.py +96 -0
- package/project_monitor.py +204 -0
- package/project_scheduler.py +61 -0
- package/project_storage.py +165 -0
- package/project_store.py +220 -0
- package/projects_ui.py +146 -0
- package/requirements.txt +8 -0
- package/research_cache.py +171 -0
- package/research_metrics.py +110 -0
- package/router.py +582 -0
- package/secret_store.py +121 -0
- package/setup_ui.py +986 -0
- package/site_memory.py +53 -0
- package/smart_tool_daemon.py +3604 -0
- package/tray.py +245 -0
- package/update_check.py +22 -0
- package/usage_meter.py +124 -0
- package/version.py +18 -0
- package/view_compare.py +68 -0
- package/web_adapters/browser/browser_service.py +302 -0
- package/web_adapters/browser/crawl4ai_search.py +236 -0
- package/web_adapters/browser/fetch_camoufox.py +28 -0
- package/web_adapters/browser/requirements.txt +2 -0
- package/web_adapters/node/adapter-common.mjs +40 -0
- package/web_adapters/node/ddg-search.mjs +89 -0
- package/web_adapters/node/open-websearch-adapter.mjs +77 -0
- package/web_adapters/node/package-lock.json +2720 -0
- package/web_adapters/node/package.json +12 -0
- package/web_adapters/node/wikipedia-search.mjs +44 -0
- package/web_document_graph.py +71 -0
- package/web_fetch.py +242 -0
- package/web_search_adapters.py +193 -0
- package/web_search_ddgs.py +42 -0
- package/web_search_external.py +288 -0
- package/web_search_health.py +88 -0
|
@@ -0,0 +1,3604 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Daemon HTTP único e persistente do Smart Tool — expõe `smart_search` via MCP
|
|
3
|
+
Streamable HTTP (JSON-RPC 2.0 sobre POST, respostas síncronas em JSON puro, sem exigir
|
|
4
|
+
SSE). Auto-anunciado em `~/.smart-tool/data/daemon.json`, no mesmo contrato de
|
|
5
|
+
registry do Brain Server (`{"url": ...}`), pra `daemon_launcher.ensure_daemon_running()`
|
|
6
|
+
health-checar e reusar entre sessões/hosts.
|
|
7
|
+
|
|
8
|
+
Modelos pelo adaptador do gateway configurado: `embeddings` (OpenAI-style, `{"model", "input"}` →
|
|
9
|
+
`data[].embedding`) e `rerank` (Cohere-style, `{"model", "query", "documents", "top_n"}` → `results[].index`).
|
|
10
|
+
"""
|
|
11
|
+
import atexit
|
|
12
|
+
import concurrent.futures
|
|
13
|
+
import contextlib
|
|
14
|
+
import contextvars
|
|
15
|
+
import itertools
|
|
16
|
+
import datetime
|
|
17
|
+
import email.utils
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
import paths
|
|
23
|
+
import re
|
|
24
|
+
import random
|
|
25
|
+
import signal
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
import threading
|
|
29
|
+
import time
|
|
30
|
+
import unicodedata
|
|
31
|
+
import urllib.error
|
|
32
|
+
import urllib.parse
|
|
33
|
+
import urllib.request
|
|
34
|
+
import uuid
|
|
35
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
36
|
+
|
|
37
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
38
|
+
import capabilities
|
|
39
|
+
import client_hooks
|
|
40
|
+
import integration_metrics
|
|
41
|
+
import config
|
|
42
|
+
import freshness
|
|
43
|
+
import model_client
|
|
44
|
+
import index_scope
|
|
45
|
+
import index_profile
|
|
46
|
+
import indexer
|
|
47
|
+
import index_views
|
|
48
|
+
import index_inventory
|
|
49
|
+
import usage_meter
|
|
50
|
+
import document_text
|
|
51
|
+
import model_defaults
|
|
52
|
+
import project_identity
|
|
53
|
+
import project_store
|
|
54
|
+
import project_scheduler
|
|
55
|
+
import project_monitor
|
|
56
|
+
import research_metrics
|
|
57
|
+
import research_cache
|
|
58
|
+
import router
|
|
59
|
+
import setup_ui
|
|
60
|
+
import site_memory
|
|
61
|
+
import web_search_adapters
|
|
62
|
+
import browser_service_client
|
|
63
|
+
import local_embedder
|
|
64
|
+
import web_search_health
|
|
65
|
+
import web_fetch
|
|
66
|
+
import version
|
|
67
|
+
import gateway
|
|
68
|
+
import hook_decision
|
|
69
|
+
import endpoint_sync
|
|
70
|
+
|
|
71
|
+
RUN_DIR = paths.DATA_DIR
|
|
72
|
+
REGISTRY_PATH = os.path.join(RUN_DIR, "daemon.json")
|
|
73
|
+
DEFAULT_PORT = int(os.environ.get("SMART_TOOL_PORT", "8765"))
|
|
74
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
75
|
+
MCP_MAX_REQUEST_BYTES = 10 * 1024 * 1024
|
|
76
|
+
_BOUND_PORT = DEFAULT_PORT
|
|
77
|
+
|
|
78
|
+
DDG_SEARCH_SCRIPT = web_search_adapters.DDG_SEARCH_SCRIPT
|
|
79
|
+
CAMOUFOX_VENV_PYTHON = web_search_adapters.CAMOUFOX_VENV_PYTHON
|
|
80
|
+
CRAWL4AI_SEARCH_SCRIPT = os.path.join(web_search_adapters.BROWSER_ADAPTER_DIR, "crawl4ai_search.py")
|
|
81
|
+
WEB_SEARCH_TIER1_TIMEOUT_S = web_search_adapters.FETCH_TIMEOUT_S
|
|
82
|
+
WEB_SEARCH_MAX_QUERY_CHARS = 500
|
|
83
|
+
SEARCH_SCRIPT_ERROR_MAX_CHARS = 500
|
|
84
|
+
_IS_WINDOWS = sys.platform == "win32"
|
|
85
|
+
_URL_RE = re.compile(r"^https?://", re.IGNORECASE)
|
|
86
|
+
_SITE_OPERATOR_RE = re.compile(r"(?<!\S)site:([a-z0-9][a-z0-9.-]*[a-z0-9]|[a-z0-9])", re.IGNORECASE)
|
|
87
|
+
|
|
88
|
+
DEEP_JOB_WORKERS = 2
|
|
89
|
+
DEEP_JOB_MAX_PAGES = 5
|
|
90
|
+
DEEP_JOB_TIMEOUT_S = 180
|
|
91
|
+
DEEP_JOB_SYNTHESIS_MAX_CHARS = 100_000
|
|
92
|
+
DEEP_JOB_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
|
93
|
+
max_workers=DEEP_JOB_WORKERS, thread_name_prefix="deep-job"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# RESEARCH_MAX_ITERATIONS é configurável (config.research_max_iterations, recomendado e
|
|
97
|
+
# validado em design/planos/smart-tool-agente-pesquisa-item1-integracao.md) — lido por
|
|
98
|
+
# rodada em _run_research_job, não fixo aqui. Teto de wall-clock segue o mesmo papel de
|
|
99
|
+
# DEEP_JOB_TIMEOUT_S pro Tier 4.
|
|
100
|
+
RESEARCH_JOB_TIMEOUT_S = 180
|
|
101
|
+
RESEARCH_CACHE_CHECK_S = 25
|
|
102
|
+
RESEARCH_LLM_RESERVE_S = 90
|
|
103
|
+
# Duas chamadas reais em 2026-09-29 esgotaram 30s antes de o job usar 40s.
|
|
104
|
+
# Cada chamada pode esperar até 60s, sempre limitada pelo prazo total acima.
|
|
105
|
+
RESEARCH_LLM_TIMEOUT_S = 60
|
|
106
|
+
RESEARCH_JOB_WORKERS = 2
|
|
107
|
+
RESEARCH_JOB_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
|
108
|
+
max_workers=RESEARCH_JOB_WORKERS, thread_name_prefix="research-job"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
WEB_CACHE_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
|
112
|
+
max_workers=2, thread_name_prefix="web-cache"
|
|
113
|
+
)
|
|
114
|
+
WEB_CACHE_CLASSIFY_TIMEOUT_S = 30
|
|
115
|
+
WEB_CACHE_PENDING_GRACE_S = 120
|
|
116
|
+
WEB_LANG_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
|
117
|
+
max_workers=4, thread_name_prefix="web-lang"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
WEB_SEARCH_DEFAULT_TIER_ORDER = web_search_adapters.DEFAULT_TIER_ORDER
|
|
121
|
+
# Sem navegador (ver web_search_adapters.FAN_OUT_TIERS): baratos o bastante pra disparar
|
|
122
|
+
# todos em paralelo em toda chamada, garantindo fonte diversa em vez de parar no primeiro
|
|
123
|
+
# que responder. 20s cobre as quatro com folga mesmo sob rede lenta (nenhuma passa de
|
|
124
|
+
# alguns segundos quando não bloqueada) sem herdar o orçamento dos tiers de navegador.
|
|
125
|
+
WEB_SEARCH_FAN_OUT_BUDGET_S = 20
|
|
126
|
+
# ~5 concurrent fan-outs (a bilingual quick search runs two) before tiers queue inside the budget.
|
|
127
|
+
WEB_SEARCH_EXECUTOR = concurrent.futures.ThreadPoolExecutor(
|
|
128
|
+
max_workers=6 * len(web_search_adapters.FAN_OUT_TIERS), thread_name_prefix="web-search"
|
|
129
|
+
)
|
|
130
|
+
WEB_PROVIDER_CIRCUIT_DEFAULT_S = 300
|
|
131
|
+
WEB_PROVIDER_CIRCUIT_MIN_S = 30
|
|
132
|
+
WEB_PROVIDER_CIRCUIT_MAX_S = 3600
|
|
133
|
+
_web_provider_circuits = {}
|
|
134
|
+
_web_provider_circuit_lock = threading.Lock()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class WebProviderRateLimited(RuntimeError):
|
|
138
|
+
def __init__(self, retry_after):
|
|
139
|
+
self.retry_after = min(WEB_PROVIDER_CIRCUIT_MAX_S, max(1, int(retry_after)))
|
|
140
|
+
super().__init__(f"HTTP 429; retrying in {self.retry_after}s")
|
|
141
|
+
|
|
142
|
+
# SITE_MEMORY_SIMILARITY_THRESHOLD é configurável (config.site_memory_similarity_threshold,
|
|
143
|
+
# recomendado a partir do spike 2: 0.42 de similaridade pra tópico próximo vs. 0.26/0.20 pra
|
|
144
|
+
# distante, com `text-embedding-3-small`) — lido em _site_memory_correlate, não fixo
|
|
145
|
+
# aqui.
|
|
146
|
+
SITE_MEMORY_MAX_TOPICS = 50
|
|
147
|
+
# Em pares reais de 2026-09-29, o reranker marcou falsos positivos de cosine
|
|
148
|
+
# em até 0,064 e pares úteis a partir de 0,148. 0,12 separa as duas amostras
|
|
149
|
+
# sem endurecer o limiar de embedding (0,35), que é só filtro de candidatos.
|
|
150
|
+
SITE_MEMORY_RERANK_MIN_SCORE = 0.12
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _parse_bounded_int(value, default, min_v, max_v, field_name):
|
|
154
|
+
if value is None:
|
|
155
|
+
return default
|
|
156
|
+
try:
|
|
157
|
+
n = int(value)
|
|
158
|
+
except (TypeError, ValueError):
|
|
159
|
+
raise ValueError(f"'{field_name}' must be an integer, got: {value!r}")
|
|
160
|
+
if n < min_v or n > max_v:
|
|
161
|
+
raise ValueError(f"'{field_name}' must be between {min_v} and {max_v}, got: {n}")
|
|
162
|
+
return n
|
|
163
|
+
|
|
164
|
+
_sessions = {}
|
|
165
|
+
_capabilities_cache = None
|
|
166
|
+
|
|
167
|
+
_jobs = {}
|
|
168
|
+
_JOBS_LOCK = threading.Lock()
|
|
169
|
+
_MAX_JOBS_RETAINED = 200
|
|
170
|
+
SMART_SEARCH_SYNC_WAIT_S = 3
|
|
171
|
+
SMART_SEARCH_MAX_PENDING = 8
|
|
172
|
+
SMART_SEARCH_EXECUTOR = project_scheduler.ProjectScheduler(workers=3)
|
|
173
|
+
_PROJECT_LOCKS = {}
|
|
174
|
+
_PROJECT_LOCKS_LOCK = threading.Lock()
|
|
175
|
+
_PROJECT_MONITOR = None
|
|
176
|
+
_PROJECT_ACTION_LOCK = threading.RLock()
|
|
177
|
+
_JOB_FUTURES = {}
|
|
178
|
+
_PERSIST_LAST = {}
|
|
179
|
+
_PROJECT_KINDS = {"smart_search", "project_index", "project_preview"}
|
|
180
|
+
_GATEWAY_STATUS = {}
|
|
181
|
+
_GATEWAY_LOCK = threading.Lock()
|
|
182
|
+
INDEX_JOB_TIMEOUT_S = 1800
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class ProjectJobCancelled(RuntimeError):
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _persist_project_job(job_id, job):
|
|
190
|
+
if job.get("kind") in _PROJECT_KINDS:
|
|
191
|
+
project_store.save_job(job_id, job)
|
|
192
|
+
usage_meter.record_job(job_id,job)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _set_job(job_id, **fields):
|
|
196
|
+
# Substituição atômica da entrada inteira (nunca mutação campo a campo, o que
|
|
197
|
+
# deixaria uma leitura concorrente ver ex. status="done" com result ainda
|
|
198
|
+
# None) — a poda de jobs antigos entra no mesmo lock pra nunca iterar o dict
|
|
199
|
+
# enquanto outra thread insere, o que rodaria RuntimeError no CPython.
|
|
200
|
+
with _JOBS_LOCK:
|
|
201
|
+
fields = {**_jobs.get(job_id, {}), **fields, "updated_at": time.time()}
|
|
202
|
+
if fields.get("status") != "pending":
|
|
203
|
+
fields["finished_at"] = time.time()
|
|
204
|
+
_jobs[job_id] = fields
|
|
205
|
+
if len(_jobs) > _MAX_JOBS_RETAINED:
|
|
206
|
+
# Nunca podar jobs "pending": um worker que ainda vai chamar
|
|
207
|
+
# _set_job de novo (sucesso ou erro) recriaria a entrada removida
|
|
208
|
+
# sem checar se já existia — o cliente veria "job_id desconhecido"
|
|
209
|
+
# num job que na verdade ainda está rodando/na fila.
|
|
210
|
+
prunable = sorted(
|
|
211
|
+
(k for k, v in _jobs.items() if v.get("status") != "pending"),
|
|
212
|
+
key=lambda k: _jobs[k].get("created_at", 0),
|
|
213
|
+
)
|
|
214
|
+
for old_id in prunable[: len(_jobs) - _MAX_JOBS_RETAINED]:
|
|
215
|
+
_jobs.pop(old_id, None)
|
|
216
|
+
_persist_project_job(job_id, fields)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _update_job(job_id, **fields):
|
|
220
|
+
if fields.get('phase'):usage_meter.phase(fields['phase'])
|
|
221
|
+
persistent = None
|
|
222
|
+
with _JOBS_LOCK:
|
|
223
|
+
current = _jobs.get(job_id)
|
|
224
|
+
if current is not None and current.get("status") == "pending":
|
|
225
|
+
updated = {**current, **fields, "updated_at": time.time()}
|
|
226
|
+
_jobs[job_id] = updated
|
|
227
|
+
if (fields.get("phase") != current.get("phase") or
|
|
228
|
+
time.monotonic() - _PERSIST_LAST.get(job_id, 0) > .5 or fields.get("cancel_requested")):
|
|
229
|
+
_PERSIST_LAST[job_id] = time.monotonic()
|
|
230
|
+
persistent = updated
|
|
231
|
+
if persistent:
|
|
232
|
+
_persist_project_job(job_id, persistent)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _project_lock(root):
|
|
236
|
+
key = project_identity.canonical_root(root)
|
|
237
|
+
with _PROJECT_LOCKS_LOCK:
|
|
238
|
+
return _PROJECT_LOCKS.setdefault(key, threading.Lock())
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
_SMART_SEARCH_PHASES = {
|
|
242
|
+
"queued": "Search queued for indexing.",
|
|
243
|
+
"waiting_project": "Waiting for another search in this project to finish.",
|
|
244
|
+
"auth": "Checking the model gateway.",
|
|
245
|
+
"scope": "Analyzing the project scope.",
|
|
246
|
+
"profile": "Classifying code, docs and languages.",
|
|
247
|
+
"listing": "Listing project files.",
|
|
248
|
+
"indexing": "Indexing project files.",
|
|
249
|
+
"indexing_retry": "Waiting to retry embeddings.",
|
|
250
|
+
"embedding_query": "Preparing the semantic query.",
|
|
251
|
+
"searching": "Searching the index.",
|
|
252
|
+
"reranking": "Ranking results.",
|
|
253
|
+
"model_probe": "Validating the embedding model.",
|
|
254
|
+
"preview": "Checking scope files and rules.",
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _smart_search_job_payload(job_id):
|
|
259
|
+
with _JOBS_LOCK:
|
|
260
|
+
job = dict(_jobs.get(job_id) or {})
|
|
261
|
+
if not job:
|
|
262
|
+
job = project_store.job(job_id) or {}
|
|
263
|
+
if job.get("kind") not in _PROJECT_KINDS:
|
|
264
|
+
raise ValueError("Unknown or expired smart_search job_id; run the search again.")
|
|
265
|
+
payload = {"job_id": job_id, "status": job["status"], "kind": job.get("kind"),
|
|
266
|
+
"project_id": job.get("project_id"), "updated_at": job.get("updated_at")}
|
|
267
|
+
if job["status"] == "pending":
|
|
268
|
+
processed = job.get("processed_files")
|
|
269
|
+
total = job.get("total_files")
|
|
270
|
+
phase = job.get("phase", "queued")
|
|
271
|
+
message = _SMART_SEARCH_PHASES.get(phase, "Search in progress.")
|
|
272
|
+
if phase == "indexing" and total is not None:
|
|
273
|
+
message = f"Indexing file {processed or 0} of {total}." if total else "Index up to date; preparing the search."
|
|
274
|
+
elif phase == "indexing_retry":
|
|
275
|
+
message = (f"Embedding batch was slow; attempt {job.get('retry_attempt', 2)} "
|
|
276
|
+
f"of {EMBED_MAX_ATTEMPTS}. Files done: {processed or 0} of {total or 0}.")
|
|
277
|
+
payload.update({
|
|
278
|
+
"phase": phase,
|
|
279
|
+
"message": message,
|
|
280
|
+
"next_tool": "smart_search_result",
|
|
281
|
+
"processed_files": processed,
|
|
282
|
+
"total_files": total,
|
|
283
|
+
"elapsed_s": round(time.time() - job["created_at"], 1),
|
|
284
|
+
"retry_after_s": 3,
|
|
285
|
+
"cancel_requested": bool(job.get("cancel_requested")),
|
|
286
|
+
})
|
|
287
|
+
elif job["status"] == "done":
|
|
288
|
+
payload["result"] = job["result"]
|
|
289
|
+
if job.get("warning") and job["warning"] not in str(job["result"]):
|
|
290
|
+
payload["warning"] = job["warning"]
|
|
291
|
+
else:
|
|
292
|
+
payload["error"] = job.get("error") or "Search did not complete."
|
|
293
|
+
payload["resumable"] = bool(job.get("arguments"))
|
|
294
|
+
if job.get("stats"):
|
|
295
|
+
payload["stats"] = job["stats"]
|
|
296
|
+
if job.get("failure_phase"):
|
|
297
|
+
payload["failure_phase"] = job["failure_phase"]
|
|
298
|
+
if job.get('view'):
|
|
299
|
+
payload['view'] = job['view']
|
|
300
|
+
return payload
|
|
301
|
+
|
|
302
|
+
TOOLS = [
|
|
303
|
+
{
|
|
304
|
+
"name": "project_manage",
|
|
305
|
+
"description": "Manages folders and indexing. list/status show project_id, storage, scope and jobs. graph reads the indexed view: coverage, references and static calls in Java, Angular, Python and JS/TS/JSX/TSX; includes HTML/CSS/Markdown references and TXT/DOCX coverage; with file_path, narrows to that file's symbols, imports and calls. inspect lists the files of a view_id; with file_path, returns only that file's chunks. usage shows consumption and reuse; duplicates lists duplicated functions (identical, near-identical and semantic bodies, min_similarity default 0.90; below that most pairs measured were false positives), excluding tests unless include_tests=true; fix real duplicates; only dismiss with duplicates_dismiss (finding_id, reason=false_positive|intentional, note) what is not a duplicate or is a copy kept on purpose: the finding stays hidden until the code changes, and duplicates_restore reopens it; integration shows the MCP client, hook and tool usage metrics; install_hook_preview shows the change that would install the hook redirecting Grep/Read to smart_search and WebSearch/WebFetch to web_search/web_fetch in this session's client and returns a plan_id; install_hook writes it with that plan_id and confirm=true, with a backup, only after the user approves the change; search_limits saves the project's default top_k per block ({code, test, doc}, each 1-20, total at most 20); storage shows disk usage and views; pin_view protects a view; cleanup_preview and cleanup_commit remove selected views after confirmation; compare compares two stored views, without checkout or embeddings. register adds a folder; preview checks the scope; index/rebuild update; pause/cancel keep checkpoints; resume continues; policy picks on_search/eager; scope adjusts the scope; remove deletes only the local index. Jobs via smart_search_result.",
|
|
306
|
+
"inputSchema": {
|
|
307
|
+
"type": "object", "properties": {
|
|
308
|
+
"action": {"type": "string", "enum": ["list", "status", "register", "preview", "index", "rebuild", "pause", "cancel", "resume", "watch", "scope", "remove", "relocate", "probe", "policy", "inspect", "graph", "usage", "storage", "pin_view", "cleanup_preview", "cleanup_commit", "compare", "search_limits", "integration", "install_hook_preview", "install_hook", "duplicates", "duplicates_dismiss", "duplicates_restore"]},
|
|
309
|
+
"finding_id": {"type": "string"},
|
|
310
|
+
"reason": {"type": "string", "enum": ["false_positive", "intentional"]},
|
|
311
|
+
"note": {"type": "string"},
|
|
312
|
+
"include_dismissed": {"type": "boolean", "default": False},
|
|
313
|
+
"min_similarity": {"type": "number", "minimum": 0.5, "maximum": 1, "default": 0.9,
|
|
314
|
+
"description": "Semantic cutoff for duplicates; keep 0.90: below it most pairs were false positives in measurement"},
|
|
315
|
+
"include_tests": {"type": "boolean", "default": False},
|
|
316
|
+
"limit": {"type": "integer", "minimum": 1, "maximum": 200, "default": 30},
|
|
317
|
+
"days": {"type":"integer","minimum":1,"maximum":366},
|
|
318
|
+
"top_k": {"type": "object", "additionalProperties": False, "properties": {
|
|
319
|
+
"code": {"type": "integer", "minimum": 1, "maximum": 20},
|
|
320
|
+
"test": {"type": "integer", "minimum": 1, "maximum": 20},
|
|
321
|
+
"doc": {"type": "integer", "minimum": 1, "maximum": 20}}},
|
|
322
|
+
"storage_id":{"type":"string"},"storage_ids":{"type":"array","items":{"type":"string"}},
|
|
323
|
+
"pinned":{"type":"boolean"},"clear_cache":{"type":"boolean"},"plan_id":{"type":"string"},"confirm":{"type":"boolean"},
|
|
324
|
+
"left_view":{"type":"string"},"right_view":{"type":"string"},"relations":{"type":"boolean"},
|
|
325
|
+
"update_mode": {"type": "string", "enum": ["on_search", "eager"]},
|
|
326
|
+
"view_id": {"type": "string"}, "file_path": {"type": "string"},
|
|
327
|
+
"project_root": {"type": "string"}, "project_id": {"type": "string"},
|
|
328
|
+
"job_id": {"type": "string"}, "watch": {"type": "boolean"},
|
|
329
|
+
"manual": {"type": "boolean"}, "force_scope": {"type": "boolean"},
|
|
330
|
+
"include": {"type": "array", "items": {"type": "string"}},
|
|
331
|
+
"exclude": {"type": "array", "items": {"type": "string"}},
|
|
332
|
+
"user_exclude": {"type": "array", "items": {"type": "string"}}
|
|
333
|
+
}, "required": ["action"], "additionalProperties": False
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
"name": "smart_search",
|
|
338
|
+
"description": (
|
|
339
|
+
"Semantic search (embed+rerank) over the project's index. Use instead of "
|
|
340
|
+
"raw grep/read when the search has no clear scope (e.g. a folder with node_modules). "
|
|
341
|
+
"Results come in three blocks, code, test and doc, plus the project profile with the natural "
|
|
342
|
+
"languages of identifiers, comments and docs (also in project_manage list/status as search_profile). "
|
|
343
|
+
"Fill query_identifiers with identifier and function-name terms in the identifiers' language and "
|
|
344
|
+
"query_comments with the same request in the comments' language; when the profile shows two "
|
|
345
|
+
"languages both are required, or explain in single_language_reason. Put documentation searches in "
|
|
346
|
+
"doc_query. Code and test results list the likeliest functions as name:line in symbols. Tune top_k per block for the project (project_manage search_limits). Fast searches return results directly. If work takes longer than a few seconds, "
|
|
347
|
+
"a pending job_id is returned; call smart_search_result with that ID until done."
|
|
348
|
+
),
|
|
349
|
+
"inputSchema": {
|
|
350
|
+
"type": "object",
|
|
351
|
+
"properties": {
|
|
352
|
+
"project_root": {"type": "string", "description": "Absolute path to the project root"},
|
|
353
|
+
"query_identifiers": {"type": "string", "description": "Code and test search written with identifier and function-name terms, in the identifiers' language; required unless group is doc"},
|
|
354
|
+
"query_comments": {"type": "string", "description": "The same search in the other language detected in the project (comments); required whenever the profile lists more than one language"},
|
|
355
|
+
"doc_query": {"type": "string", "description": "Documentation search, in the docs' language; defaults to query_comments"},
|
|
356
|
+
"single_language_reason": {"type": "string", "description": "Only to skip query_comments: at least 15 characters on why one language is right here (e.g. searching literal UI text)"},
|
|
357
|
+
"group": {"type": "string", "enum": ["code", "test", "doc", "all"], "default": "all",
|
|
358
|
+
"description": "Which blocks to return"},
|
|
359
|
+
"top_k": {"anyOf": [{"type": "integer", "minimum": 1, "maximum": 20},
|
|
360
|
+
{"type": "object", "additionalProperties": False, "properties": {
|
|
361
|
+
"code": {"type": "integer", "minimum": 1, "maximum": 20},
|
|
362
|
+
"test": {"type": "integer", "minimum": 1, "maximum": 20},
|
|
363
|
+
"doc": {"type": "integer", "minimum": 1, "maximum": 20}}}],
|
|
364
|
+
"description": "Results per block: one number for all blocks or {code, test, doc}; each 1-20, total at most 20. Defaults to the project's search_limits, else 5"},
|
|
365
|
+
},
|
|
366
|
+
"required": ["project_root"],
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
"name": "smart_search_result",
|
|
371
|
+
"description": (
|
|
372
|
+
"Checks the progress and result of a background smart_search. "
|
|
373
|
+
"Pass the job_id returned by smart_search; while pending, wait retry_after_s and check again."
|
|
374
|
+
),
|
|
375
|
+
"inputSchema": {
|
|
376
|
+
"type": "object",
|
|
377
|
+
"properties": {
|
|
378
|
+
"job_id": {"type": "string", "description": "job_id returned by smart_search"},
|
|
379
|
+
},
|
|
380
|
+
"required": ["job_id"],
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
"name": "web_search",
|
|
385
|
+
"description": (
|
|
386
|
+
"Web search with curated results (title/url/snippet) - use instead of "
|
|
387
|
+
"the native WebSearch: several sources in parallel, the query in two languages and a cache shared across sessions. Starts with free HTTP "
|
|
388
|
+
"sources; escalates to Tavily, Firecrawl and, if needed, Camoufox/Startpage "
|
|
389
|
+
"only when results are insufficient. Tavily and Firecrawl take "
|
|
390
|
+
"optional API keys in the local setup. Classifies how volatile the findings are "
|
|
391
|
+
"and validates the cache before reusing it; npm versions check the registry and "
|
|
392
|
+
"OSV advisories. With depth='deep', runs an asynchronous "
|
|
393
|
+
"multi-page crawl of ONE site (Tier 4). With depth='research', runs "
|
|
394
|
+
"several search rounds on its own until it decides it has enough on the topic "
|
|
395
|
+
"(both return a job_id immediately - use web_search_result to fetch the "
|
|
396
|
+
"result later). research returns findings, sources, volatility and cache state. "
|
|
397
|
+
"'quick' returns results and, when something degraded (gateway, embedding, rerank, one language), warnings."
|
|
398
|
+
),
|
|
399
|
+
"inputSchema": {
|
|
400
|
+
"type": "object",
|
|
401
|
+
"properties": {
|
|
402
|
+
"query": {"type": "string", "description": "Search query, or the topic in natural language when depth='research'"},
|
|
403
|
+
"query_en": {"type": "string", "description": (
|
|
404
|
+
"Required with depth='quick': the same query written in English (repeat query if it is already "
|
|
405
|
+
"in English). Both run in parallel: English brings the official docs and the "
|
|
406
|
+
"original language keeps local references.")},
|
|
407
|
+
"num": {"type": "integer", "description": "How many results to return (or per round, with depth='research')", "default": 8},
|
|
408
|
+
"scope": {
|
|
409
|
+
"type": "string",
|
|
410
|
+
"description": "Optional hint about the target (e.g. 'site with bot detection', 'recent news') so the router picks the tier",
|
|
411
|
+
},
|
|
412
|
+
"depth": {
|
|
413
|
+
"type": "string",
|
|
414
|
+
"enum": ["quick", "deep", "research"],
|
|
415
|
+
"description": (
|
|
416
|
+
"'quick' (default): synchronous search on the fast tiers. 'deep': "
|
|
417
|
+
"asynchronous multi-page crawl (Tier 4) for the full spec of a "
|
|
418
|
+
"repository/system/site — always asynchronous, always Tier 4. "
|
|
419
|
+
"'research': given a broad topic, runs several search rounds "
|
|
420
|
+
"(configurable cap) deciding on its own when it has enough to "
|
|
421
|
+
"answer — returns "
|
|
422
|
+
"structured findings (findings/gaps) + sources, not a finished "
|
|
423
|
+
"answer (the calling agent writes the final answer)."
|
|
424
|
+
),
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
"required": ["query"],
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
"name": "web_fetch",
|
|
432
|
+
"description": (
|
|
433
|
+
"Reads a known URL and returns only the answer to the prompt, written by the gateway's mini model - use instead of "
|
|
434
|
+
"the native WebFetch. The page is cached for 24 h: another prompt on the same URL does not download it again. "
|
|
435
|
+
"JavaScript-built pages are rendered in the browser. Does not read localhost, internal networks or PDFs."
|
|
436
|
+
),
|
|
437
|
+
"inputSchema": {
|
|
438
|
+
"type": "object",
|
|
439
|
+
"properties": {
|
|
440
|
+
"url": {"type": "string", "description": "Full http(s) URL of the page"},
|
|
441
|
+
"prompt": {"type": "string", "description": "What to extract or answer from the page"},
|
|
442
|
+
},
|
|
443
|
+
"required": ["url", "prompt"],
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
"name": "web_search_result",
|
|
448
|
+
"description": (
|
|
449
|
+
"Checks the status/result of a deep research job "
|
|
450
|
+
"(web_search with depth='deep' or depth='research')."
|
|
451
|
+
),
|
|
452
|
+
"inputSchema": {
|
|
453
|
+
"type": "object",
|
|
454
|
+
"properties": {
|
|
455
|
+
"job_id": {"type": "string", "description": "job_id returned by web_search with depth='deep' or depth='research'"},
|
|
456
|
+
},
|
|
457
|
+
"required": ["job_id"],
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
]
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
_UNSAFE_UNICODE_CATEGORIES = ("Cc", "Cf") # controle (ex.: ESC) e formatação (ex.: RTL override, zero-width)
|
|
464
|
+
_SAFE_CONTROL_CHARS = {"\t", "\n", "\r"} # whitespace comum em texto real multi-linha, não o vetor de injeção visado
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _sanitize_text(text):
|
|
468
|
+
# Neutraliza caractere por caractere (não basta envolver em aspas: um
|
|
469
|
+
# caractere de formatação Unicode como RTL override afeta a renderização no
|
|
470
|
+
# terminal mesmo dentro de uma string JSON válida) — texto vindo de scraping
|
|
471
|
+
# de terceiros, síntese de LLM, ou stderr de subprocesso não é input
|
|
472
|
+
# confiável, e o destino final é um terminal/CLI. Tab/newline/CR ficam de
|
|
473
|
+
# fora do conjunto neutralizado: são whitespace comum, não o vetor de
|
|
474
|
+
# injeção (ESC/ANSI, RTL override) que esta função visa.
|
|
475
|
+
return "".join(
|
|
476
|
+
ch if ch in _SAFE_CONTROL_CHARS or unicodedata.category(ch) not in _UNSAFE_UNICODE_CATEGORIES
|
|
477
|
+
else f"\\u{ord(ch):04x}"
|
|
478
|
+
for ch in str(text)
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
_YAML_LEADING_INDICATORS = set("[]{}!&*#|>'\"%@`,?:-") # indicadores reservados no início de um plain scalar YAML
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _yaml_scalar(value):
|
|
486
|
+
if value is None:
|
|
487
|
+
return "null"
|
|
488
|
+
if isinstance(value, bool):
|
|
489
|
+
return "true" if value else "false"
|
|
490
|
+
if isinstance(value, (int, float)):
|
|
491
|
+
return str(value)
|
|
492
|
+
text = _sanitize_text(value)
|
|
493
|
+
needs_quoting = (
|
|
494
|
+
text == "" or text != text.strip() or any(ch in text for ch in ":\n#\"'")
|
|
495
|
+
or text[0] in _YAML_LEADING_INDICATORS
|
|
496
|
+
)
|
|
497
|
+
if needs_quoting:
|
|
498
|
+
return json.dumps(text, ensure_ascii=False)
|
|
499
|
+
return text
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _to_yaml(results):
|
|
503
|
+
if not results:
|
|
504
|
+
return "[]\n"
|
|
505
|
+
lines = []
|
|
506
|
+
for item in results:
|
|
507
|
+
first = True
|
|
508
|
+
for key, value in item.items():
|
|
509
|
+
prefix = "- " if first else " "
|
|
510
|
+
lines.append(f"{prefix}{key}: {_yaml_scalar(value)}")
|
|
511
|
+
first = False
|
|
512
|
+
return "\n".join(lines) + "\n"
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _sanitize_value_deep(value):
|
|
516
|
+
if isinstance(value, str):
|
|
517
|
+
return _sanitize_text(value)
|
|
518
|
+
if isinstance(value, dict):
|
|
519
|
+
return {k: _sanitize_value_deep(v) for k, v in value.items()}
|
|
520
|
+
if isinstance(value, list):
|
|
521
|
+
return [_sanitize_value_deep(v) for v in value]
|
|
522
|
+
return value
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _to_json_sanitized(obj):
|
|
526
|
+
# Sanitiza só os valores string ANTES de serializar — nunca o texto JSON já
|
|
527
|
+
# pronto: json.dumps(indent=2) insere seus próprios caracteres de quebra de
|
|
528
|
+
# linha/indentação, que são categoria Cc igual a um caractere de controle
|
|
529
|
+
# malicioso; sanitizar o texto final trocaria essa formatação legítima por
|
|
530
|
+
# um escape visível, quebrando a estrutura do JSON.
|
|
531
|
+
return json.dumps(_sanitize_value_deep(obj), ensure_ascii=False, indent=2)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
EMBED_BATCH_MAX_CHARS = indexer.REINDEX_BATCH_MAX_CHARS
|
|
535
|
+
EMBED_BATCH_MAX_ITEMS = indexer.REINDEX_BATCH_MAX_ITEMS
|
|
536
|
+
EMBED_REQUEST_TIMEOUT_S = 60
|
|
537
|
+
EMBED_MAX_ATTEMPTS = 3
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _embed_batches(texts, max_chars=EMBED_BATCH_MAX_CHARS, max_items=EMBED_BATCH_MAX_ITEMS):
|
|
541
|
+
batch, batch_chars = [], 0
|
|
542
|
+
for text in texts:
|
|
543
|
+
if batch and (batch_chars + len(text) > max_chars or len(batch) >= max_items):
|
|
544
|
+
yield batch
|
|
545
|
+
batch, batch_chars = [], 0
|
|
546
|
+
batch.append(text)
|
|
547
|
+
batch_chars += len(text)
|
|
548
|
+
if batch:
|
|
549
|
+
yield batch
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _transient_embed_error(exc):
|
|
553
|
+
if isinstance(exc, urllib.error.HTTPError):
|
|
554
|
+
if exc.code == 429:
|
|
555
|
+
try:
|
|
556
|
+
detail = exc.read(4096).decode("utf-8", "replace").casefold()
|
|
557
|
+
except Exception:
|
|
558
|
+
detail = ""
|
|
559
|
+
if any(marker in detail for marker in ("insufficient_quota", "budget_exceeded", "budget exceeded", "budget has been exceeded")):
|
|
560
|
+
return False
|
|
561
|
+
return exc.code in (408, 429, 500, 502, 503, 504)
|
|
562
|
+
return isinstance(exc, (TimeoutError, ConnectionError, urllib.error.URLError))
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _embed_retry_delay(exc, attempt):
|
|
566
|
+
value = (getattr(exc, "headers", None) or {}).get("Retry-After")
|
|
567
|
+
if value:
|
|
568
|
+
try:
|
|
569
|
+
seconds = float(value)
|
|
570
|
+
except ValueError:
|
|
571
|
+
try:
|
|
572
|
+
seconds = (email.utils.parsedate_to_datetime(value) - datetime.datetime.now(datetime.timezone.utc)).total_seconds()
|
|
573
|
+
except (ValueError, TypeError):
|
|
574
|
+
seconds = 0
|
|
575
|
+
if seconds > 30:
|
|
576
|
+
raise RuntimeError(f"The gateway asked to back off for {int(seconds)} s. Resume the job after that.")
|
|
577
|
+
return max(0.1, seconds)
|
|
578
|
+
return min(8, 2 ** (attempt - 1)) + random.uniform(0, .25)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _embed(model, texts, token, deadline=None, on_retry=None, cancel_check=None):
|
|
582
|
+
if local_embedder.is_local(model):
|
|
583
|
+
return local_embedder.embed(model, texts, deadline=deadline, cancel_check=cancel_check, on_retry=on_retry)
|
|
584
|
+
vectors = []
|
|
585
|
+
dimensions = None
|
|
586
|
+
for batch in _embed_batches(texts):
|
|
587
|
+
max_attempts = EMBED_MAX_ATTEMPTS if deadline is None or on_retry is not None else 1
|
|
588
|
+
for attempt in range(1, max_attempts + 1):
|
|
589
|
+
if cancel_check:
|
|
590
|
+
cancel_check()
|
|
591
|
+
timeout = (min(EMBED_REQUEST_TIMEOUT_S, _research_time_left(deadline))
|
|
592
|
+
if deadline is not None else EMBED_REQUEST_TIMEOUT_S)
|
|
593
|
+
try:
|
|
594
|
+
response = model_client.fetch(
|
|
595
|
+
"/v1/embeddings", token, method="POST", body={"model": model, "input": batch},
|
|
596
|
+
timeout=timeout,
|
|
597
|
+
)
|
|
598
|
+
except Exception as exc:
|
|
599
|
+
if attempt >= max_attempts or not _transient_embed_error(exc):
|
|
600
|
+
raise
|
|
601
|
+
if on_retry:
|
|
602
|
+
on_retry(attempt + 1, len(batch))
|
|
603
|
+
delay = _embed_retry_delay(exc, attempt)
|
|
604
|
+
if deadline is not None and delay >= _research_time_left(deadline):
|
|
605
|
+
raise TimeoutError("Not enough time left to honor the gateway backoff.")
|
|
606
|
+
if cancel_check:
|
|
607
|
+
until = time.monotonic() + delay
|
|
608
|
+
while time.monotonic() < until:
|
|
609
|
+
cancel_check()
|
|
610
|
+
time.sleep(min(.2, max(0, until - time.monotonic())))
|
|
611
|
+
else:
|
|
612
|
+
time.sleep(delay)
|
|
613
|
+
continue
|
|
614
|
+
if cancel_check:
|
|
615
|
+
cancel_check()
|
|
616
|
+
rows = response.get("data", []) if isinstance(response, dict) else []
|
|
617
|
+
if not isinstance(rows, list) or len(rows) != len(batch):
|
|
618
|
+
raise RuntimeError("The gateway returned a different number of embeddings than the input.")
|
|
619
|
+
ordered = [None] * len(batch)
|
|
620
|
+
for row in rows:
|
|
621
|
+
if not isinstance(row, dict):
|
|
622
|
+
raise indexer.IndexVectorError("Invalid embedding row.")
|
|
623
|
+
idx = row.get("index", 0 if len(batch) == 1 else None)
|
|
624
|
+
if type(idx) is not int or not 0 <= idx < len(batch) or ordered[idx] is not None:
|
|
625
|
+
raise indexer.IndexVectorError("Embedding indexes missing, duplicated or outside the batch.")
|
|
626
|
+
vector = row.get("embedding")
|
|
627
|
+
dimensions = indexer.validate_vector(vector, dimensions)
|
|
628
|
+
ordered[idx] = vector
|
|
629
|
+
vectors.extend(ordered)
|
|
630
|
+
break
|
|
631
|
+
return vectors
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _rerank(model, query, documents, top_n, token, deadline=None):
|
|
635
|
+
if not documents:
|
|
636
|
+
return []
|
|
637
|
+
top_n = min(top_n, len(documents))
|
|
638
|
+
timeout = min(15, _research_time_left(deadline)) if deadline is not None else 15
|
|
639
|
+
response = model_client.fetch(
|
|
640
|
+
"/v1/rerank", token, method="POST",
|
|
641
|
+
body={"model": model, "query": query, "documents": documents, "top_n": top_n},
|
|
642
|
+
timeout=timeout,
|
|
643
|
+
)
|
|
644
|
+
rows = response.get("results", []) if isinstance(response, dict) else []
|
|
645
|
+
if not isinstance(rows, list) or not rows:
|
|
646
|
+
raise ValueError("The reranker returned no valid results.")
|
|
647
|
+
seen = set()
|
|
648
|
+
for row in rows:
|
|
649
|
+
idx = row.get("index") if isinstance(row, dict) else None
|
|
650
|
+
score = row.get("relevance_score", 0) if isinstance(row, dict) else None
|
|
651
|
+
if (type(idx) is not int or not 0 <= idx < len(documents) or idx in seen or
|
|
652
|
+
not isinstance(score, (int, float)) or not math.isfinite(score)):
|
|
653
|
+
raise ValueError("The reranker returned an invalid index or score.")
|
|
654
|
+
seen.add(idx)
|
|
655
|
+
return rows[:top_n]
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
LOG_PATH = os.path.join(RUN_DIR, "daemon.log")
|
|
659
|
+
LOG_MAX_BYTES = 1024 * 1024
|
|
660
|
+
_log_lock = threading.Lock()
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _log(message):
|
|
664
|
+
"""Diagnóstico em arquivo, não em stderr: o daemon é sempre iniciado detached, com
|
|
665
|
+
stderr em DEVNULL (`daemon_launcher._spawn_daemon`, `pythonw` do autostart), então um
|
|
666
|
+
`print` só existiria no caso em que ninguém está com problema. Rotação por tamanho
|
|
667
|
+
porque nada limpa este arquivo."""
|
|
668
|
+
# Sanitiza aqui, e não em cada chamador: quase toda mensagem carrega texto de origem
|
|
669
|
+
# remota (corpo de resposta, mensagem de exceção), e o arquivo é lido por humano no
|
|
670
|
+
# terminal e pelo próprio agente — sequência ANSI/override de direção intacta ali é o
|
|
671
|
+
# mesmo vetor que `_sanitize_text` fecha nas respostas.
|
|
672
|
+
line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {_sanitize_text(str(message))}\n"
|
|
673
|
+
with _log_lock:
|
|
674
|
+
try:
|
|
675
|
+
os.makedirs(RUN_DIR, exist_ok=True)
|
|
676
|
+
if os.path.exists(LOG_PATH) and os.path.getsize(LOG_PATH) >= LOG_MAX_BYTES:
|
|
677
|
+
os.replace(LOG_PATH, LOG_PATH + ".1")
|
|
678
|
+
with open(LOG_PATH, "a", encoding="utf-8") as f:
|
|
679
|
+
f.write(line)
|
|
680
|
+
except OSError:
|
|
681
|
+
pass
|
|
682
|
+
if sys.stderr:
|
|
683
|
+
try:
|
|
684
|
+
sys.stderr.write(f"[smart-tool] {message}\n")
|
|
685
|
+
sys.stderr.flush()
|
|
686
|
+
except (OSError, ValueError):
|
|
687
|
+
pass
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
_WEB_WARNINGS = contextvars.ContextVar("web_warnings", default=None)
|
|
691
|
+
_WEB_TRACE = contextvars.ContextVar("web_trace", default=None)
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _web_trace(**fields):
|
|
695
|
+
trace = _WEB_TRACE.get()
|
|
696
|
+
if trace is not None:
|
|
697
|
+
trace.update(fields)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _web_warn(message):
|
|
701
|
+
warnings = _WEB_WARNINGS.get()
|
|
702
|
+
if warnings is not None and message not in warnings:
|
|
703
|
+
warnings.append(message)
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
def _web_model_warn(message):
|
|
707
|
+
if not model_client.OFFLINE.get():
|
|
708
|
+
_web_warn(message)
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _gateway_problem():
|
|
712
|
+
try:
|
|
713
|
+
_require_valid_credential()
|
|
714
|
+
except Exception as exc:
|
|
715
|
+
return _project_error(exc)
|
|
716
|
+
return None
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def _require_valid_credential():
|
|
720
|
+
current = gateway.status()
|
|
721
|
+
if current["status"] != "ready":
|
|
722
|
+
raise RuntimeError(f"Model gateway is not ready: {current['error']}")
|
|
723
|
+
model_defaults.ensure_recommended_models(True)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def _job_checkpoint(job_id):
|
|
727
|
+
with _JOBS_LOCK:
|
|
728
|
+
job = _jobs.get(job_id) or {}
|
|
729
|
+
if job.get("cancel_requested"):
|
|
730
|
+
raise ProjectJobCancelled("Operation cancelled. Confirmed checkpoints were kept.")
|
|
731
|
+
root = (job.get('arguments') or {}).get('project_root')
|
|
732
|
+
if root:
|
|
733
|
+
index_views.assert_current(index_views.describe(root))
|
|
734
|
+
if job.get("deadline") and time.monotonic() >= job["deadline"]:
|
|
735
|
+
raise TimeoutError("Job timed out. Resume it to reuse the files already confirmed.")
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def _job_deadline(job_id):
|
|
739
|
+
with _JOBS_LOCK:
|
|
740
|
+
return (_jobs.get(job_id) or {}).get("deadline")
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
class ScopeNeedsAgent(RuntimeError):
|
|
744
|
+
pass
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
class RequestTooLarge(ValueError):
|
|
748
|
+
pass
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _project_error(exc):
|
|
752
|
+
if isinstance(exc, ScopeNeedsAgent):
|
|
753
|
+
return _sanitize_text(str(exc))[:2500]
|
|
754
|
+
if isinstance(exc, urllib.error.HTTPError):
|
|
755
|
+
return f"Gateway HTTP {exc.code}. " + (
|
|
756
|
+
"Rate limit or quota reached; check the gateway key and resume after the backoff."
|
|
757
|
+
if exc.code == 429 else "The call did not complete; check the phase and try resuming.")
|
|
758
|
+
return _sanitize_text(f"{type(exc).__name__}: {exc}")[:600]
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _agent_scope_request(root, scope, problem):
|
|
762
|
+
"""Without a scope model the calling agent decides the scope through project_manage(action='scope')."""
|
|
763
|
+
dirs = index_scope._top_level_dirs(root)
|
|
764
|
+
files = index_scope._root_level_files(root, index_scope._exclude_matcher(index_scope._ALWAYS_EXCLUDE_FILES))
|
|
765
|
+
listed = []
|
|
766
|
+
for name in dirs:
|
|
767
|
+
if sum(len(item) + 2 for item in listed) + len(name) > 800:
|
|
768
|
+
break
|
|
769
|
+
listed.append(name)
|
|
770
|
+
shown = ", ".join(listed) + (f" (+{len(dirs) - len(listed)})" if len(listed) < len(dirs) else "")
|
|
771
|
+
reason = f"model gateway unavailable ({problem})" if problem else "no scope model configured"
|
|
772
|
+
stale = " The saved scope needs review (structure or version changed)." if scope else ""
|
|
773
|
+
return (f"Project scope not set: {reason}.{stale} Set it yourself and repeat the search: "
|
|
774
|
+
f"project_manage(action='scope', project_id='{project_identity.project_id(root)}', "
|
|
775
|
+
"include=[folders with the project's code, tests and docs], exclude=[generated, dependencies, "
|
|
776
|
+
f"data, builds]). Root folders: {shown or 'none'}. Loose files at the root: {len(files)} "
|
|
777
|
+
"(always included unless listed in exclude).")
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _prepare_project_index(arguments, job_id, cfg, token, query_vector=None, preview=False, model=None):
|
|
781
|
+
root = arguments["project_root"]
|
|
782
|
+
project = project_store.get(project_identity.project_id(root)) or {}
|
|
783
|
+
checkpoint = lambda: _job_checkpoint(job_id)
|
|
784
|
+
checkpoint()
|
|
785
|
+
_update_job(job_id, phase="scope")
|
|
786
|
+
scope = index_scope.load_scope(root)
|
|
787
|
+
scope_model = cfg.get("scope_model") or cfg.get("router_model")
|
|
788
|
+
if arguments.get("force_scope") or (project.get('scope_dirty') and not (scope or {}).get('manual')) or scope is None or index_scope.needs_rescan(root, scope):
|
|
789
|
+
problem = _gateway_problem() if scope_model else None
|
|
790
|
+
if not scope_model or problem:
|
|
791
|
+
raise ScopeNeedsAgent(_agent_scope_request(root, scope, problem))
|
|
792
|
+
scope = index_scope.decide_scope(root, scope_model, force=True,
|
|
793
|
+
deadline=_job_deadline(job_id), cancel_check=checkpoint)
|
|
794
|
+
_update_job(job_id, phase="listing")
|
|
795
|
+
included = index_scope.resolve_included_files(root, scope, cancel_check=checkpoint)
|
|
796
|
+
failed_at = (scope.get("profile_error") or {}).get("at", 0)
|
|
797
|
+
if ((scope.get("profile") or {}).get("version") != index_profile.PROFILE_VERSION
|
|
798
|
+
and time.time() - failed_at >= index_profile.RETRY_AFTER_S and scope_model):
|
|
799
|
+
_update_job(job_id, phase="profile")
|
|
800
|
+
try:
|
|
801
|
+
profile = index_profile.decide(root, included, scope_model, deadline=_job_deadline(job_id))
|
|
802
|
+
except ProjectJobCancelled:
|
|
803
|
+
raise
|
|
804
|
+
except Exception as exc:
|
|
805
|
+
scope = {**scope, "profile_error": {"at": time.time(), "error": _project_error(exc)}}
|
|
806
|
+
else:
|
|
807
|
+
scope = {key: value for key, value in scope.items() if key != "profile_error"}
|
|
808
|
+
scope["profile"] = profile
|
|
809
|
+
index_scope.save_scope(root, scope)
|
|
810
|
+
if preview:
|
|
811
|
+
_update_job(job_id, phase="preview", processed_files=0, total_files=len(included))
|
|
812
|
+
eligible, skipped, characters = 0, {}, 0
|
|
813
|
+
for i, rel in enumerate(included, 1):
|
|
814
|
+
checkpoint()
|
|
815
|
+
data, reason = indexer.read_indexable(root, rel)
|
|
816
|
+
if data is None:
|
|
817
|
+
skipped[reason] = skipped.get(reason, 0) + 1
|
|
818
|
+
else:
|
|
819
|
+
eligible += 1
|
|
820
|
+
characters += len(data)
|
|
821
|
+
_update_job(job_id, phase="preview", processed_files=i, total_files=len(included))
|
|
822
|
+
result = {"included_files": len(included), "eligible_files": eligible,
|
|
823
|
+
"bytes": characters, "skipped": skipped, "scope": scope,
|
|
824
|
+
"sample_paths": included[:30]}
|
|
825
|
+
project_store.update(project_identity.project_id(root), preview=result)
|
|
826
|
+
return result
|
|
827
|
+
model = model or local_embedder.resolve(cfg.get("embedding_model"), indexer.info(root).get("model_id"))
|
|
828
|
+
lexical = model == indexer.LEXICAL_MODEL
|
|
829
|
+
if not lexical and query_vector is None:
|
|
830
|
+
_update_job(job_id, phase="model_probe")
|
|
831
|
+
query_vector = _embed(model, ["Smart Tool: indexing dimension check."], token,
|
|
832
|
+
deadline=_job_deadline(job_id), cancel_check=checkpoint,
|
|
833
|
+
on_retry=lambda attempt, items: _update_job(job_id, phase="indexing_retry",
|
|
834
|
+
retry_attempt=attempt, batch_items=items))[0]
|
|
835
|
+
checkpoint()
|
|
836
|
+
_update_job(job_id, phase="indexing")
|
|
837
|
+
current = indexer.info(root)
|
|
838
|
+
full_check = bool(arguments.get("full_check") or project.get("needs_full_check") or
|
|
839
|
+
time.time() - current.get("full_scan_at", 0) >= project_monitor.FULL_CHECK_S)
|
|
840
|
+
stats = indexer.reindex(
|
|
841
|
+
root, included,
|
|
842
|
+
None if lexical else lambda texts: _embed(model, texts, token, deadline=_job_deadline(job_id),
|
|
843
|
+
cancel_check=checkpoint, on_retry=lambda attempt, items: _update_job(
|
|
844
|
+
job_id, phase="indexing_retry", retry_attempt=attempt, batch_items=items)),
|
|
845
|
+
on_progress=lambda processed, total: _update_job(
|
|
846
|
+
job_id, phase="indexing", processed_files=processed, total_files=total),
|
|
847
|
+
model_id=model, expected_dimensions=None if lexical else len(query_vector),
|
|
848
|
+
force_rebuild=bool(arguments.get("force_rebuild")), full_check=full_check,
|
|
849
|
+
changed_paths=set(arguments.get("changed_paths") or []) | set(project.get('dirty_paths', [])), cancel_check=checkpoint,
|
|
850
|
+
)
|
|
851
|
+
_update_job(job_id, stats=stats, index_updated=stats.get("ready", True))
|
|
852
|
+
project_store.update(project_identity.project_id(root), index=indexer.info(root),
|
|
853
|
+
last_stats=stats, last_checked=time.time(), scope_summary={
|
|
854
|
+
"include": scope.get("include", []), "exclude": scope.get("exclude", []),
|
|
855
|
+
"user_exclude": scope.get("user_exclude", []), "manual": scope.get("manual", False)})
|
|
856
|
+
if not stats.get("ready", True):
|
|
857
|
+
raise RuntimeError(f"{stats.get('failed', 0)} file(s) could not be read consistently. Resume indexing.")
|
|
858
|
+
project_store.confirm_changes(project_identity.project_id(root), project.get('dirty_seq', 0))
|
|
859
|
+
if lexical:
|
|
860
|
+
stats["warning"] = LEXICAL_WARNING
|
|
861
|
+
return stats
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _grouped_yaml(profile, blocks, warning):
|
|
865
|
+
lines = ["profile:"] + [f" {key}: {json.dumps(_sanitize_value_deep(value), ensure_ascii=False) if isinstance(value, list) else _yaml_scalar(value)}"
|
|
866
|
+
for key, value in profile.items()]
|
|
867
|
+
if warning:
|
|
868
|
+
lines.append(f"warning: {_yaml_scalar(warning)}")
|
|
869
|
+
for group, results in blocks.items():
|
|
870
|
+
lines.append(f"{group}:" if results else f"{group}: []")
|
|
871
|
+
lines += [" " + line for line in _to_yaml(results).rstrip("\n").split("\n")] if results else []
|
|
872
|
+
return "\n".join(lines) + "\n"
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
LOCAL_EMBEDDER_DOWN_WARNING = ("The mcp-memory local embedder is configured but not responding: index and search are lexical only "
|
|
876
|
+
"until it is back (check the mcp-memory embedding service).")
|
|
877
|
+
LEXICAL_WARNING = ("No embedding model: index and search are lexical only (exact words, no synonyms or cross-language matches). "
|
|
878
|
+
"Configure an embedding_model for semantic search.")
|
|
879
|
+
|
|
880
|
+
|
|
881
|
+
def _perform_smart_search(arguments, job_id):
|
|
882
|
+
return contextvars.copy_context().run(_perform_smart_search_isolated, arguments, job_id)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _perform_smart_search_isolated(arguments, job_id):
|
|
886
|
+
_update_job(job_id, phase="auth")
|
|
887
|
+
problem = _gateway_problem()
|
|
888
|
+
root = arguments["project_root"]
|
|
889
|
+
groups = index_profile.KINDS if arguments["group"] == "all" else (arguments["group"],)
|
|
890
|
+
limits = arguments["top_k"]
|
|
891
|
+
candidate_pool = 40 if arguments.get("single_language_reason") else 20
|
|
892
|
+
queries = {"code": arguments["query"], "test": arguments["query"], "doc": arguments["doc_query"]}
|
|
893
|
+
texts = list(dict.fromkeys(queries[group] for group in groups))
|
|
894
|
+
cfg = config.load_config()
|
|
895
|
+
model = local_embedder.resolve(cfg.get("embedding_model"), indexer.info(root).get("model_id"))
|
|
896
|
+
warnings = []
|
|
897
|
+
vectors = {}
|
|
898
|
+
search_model = model
|
|
899
|
+
if problem:
|
|
900
|
+
model_client.OFFLINE.set(problem)
|
|
901
|
+
token = "" if problem else model_client.get_token()
|
|
902
|
+
if model == indexer.LEXICAL_MODEL:
|
|
903
|
+
warnings.append(LOCAL_EMBEDDER_DOWN_WARNING if local_embedder.configured() and config.local_models_mode(cfg) != "off"
|
|
904
|
+
else LEXICAL_WARNING)
|
|
905
|
+
try:
|
|
906
|
+
_prepare_project_index(arguments, job_id, cfg, token, model=model)
|
|
907
|
+
except ProjectJobCancelled:
|
|
908
|
+
raise
|
|
909
|
+
except Exception as exc:
|
|
910
|
+
if not indexer.info(root).get("chunks"):
|
|
911
|
+
raise
|
|
912
|
+
warnings.append("Lexical index not updated; results from the last ready index. " + _project_error(exc))
|
|
913
|
+
search_model = None
|
|
914
|
+
elif problem and not local_embedder.is_local(model):
|
|
915
|
+
if not indexer.info(root).get("chunks"):
|
|
916
|
+
raise RuntimeError(problem)
|
|
917
|
+
warnings.append("Model gateway unavailable; lexical results from the last ready index, no rerank and no index update. " + problem)
|
|
918
|
+
search_model = None
|
|
919
|
+
else:
|
|
920
|
+
_update_job(job_id, phase="embedding_query")
|
|
921
|
+
try:
|
|
922
|
+
vectors = dict(zip(texts, _embed(model, texts, token, deadline=_job_deadline(job_id),
|
|
923
|
+
cancel_check=lambda: _job_checkpoint(job_id),
|
|
924
|
+
on_retry=lambda attempt, items: _update_job(job_id, phase="indexing_retry",
|
|
925
|
+
retry_attempt=attempt, batch_items=items))))
|
|
926
|
+
except ProjectJobCancelled:
|
|
927
|
+
raise
|
|
928
|
+
except Exception as exc:
|
|
929
|
+
if not indexer.info(root).get("chunks"):
|
|
930
|
+
raise
|
|
931
|
+
warnings.append("Embedding unavailable; lexical results from the last ready index. The update was not confirmed. " + _project_error(exc))
|
|
932
|
+
search_model = None
|
|
933
|
+
if vectors:
|
|
934
|
+
try:
|
|
935
|
+
_prepare_project_index(arguments, job_id, cfg, token, query_vector=next(iter(vectors.values())),
|
|
936
|
+
model=model)
|
|
937
|
+
except ProjectJobCancelled:
|
|
938
|
+
raise
|
|
939
|
+
except Exception as exc:
|
|
940
|
+
if not indexer.info(root).get("chunks"):
|
|
941
|
+
raise
|
|
942
|
+
warnings.append("Index update failed; results from the last ready index. " + _project_error(exc))
|
|
943
|
+
search_model = None
|
|
944
|
+
if indexer.info(root).get("model_id") != model:
|
|
945
|
+
vectors = {}
|
|
946
|
+
if vectors and not cfg.get("rerank_model"):
|
|
947
|
+
warnings.append("Rerank not configured: hybrid lexical/vector order, less precise at the top. Configure a rerank_model.")
|
|
948
|
+
_job_checkpoint(job_id)
|
|
949
|
+
saved_scope = index_scope.load_scope(root) or {}
|
|
950
|
+
profile = index_profile.current(saved_scope.get("profile"))
|
|
951
|
+
if not profile and saved_scope.get("profile_error"):
|
|
952
|
+
warnings.append("Code, test and doc profile unavailable; groups come from fixed rules only, without languages. "
|
|
953
|
+
"Retrying within 1 h. " + saved_scope["profile_error"]["error"])
|
|
954
|
+
if arguments.get("setup_notice"):
|
|
955
|
+
warnings.append(arguments["setup_notice"])
|
|
956
|
+
if arguments.get("single_language_reason"):
|
|
957
|
+
warnings.append("Single-language search: " + arguments["single_language_reason"])
|
|
958
|
+
|
|
959
|
+
try:
|
|
960
|
+
import code_graph
|
|
961
|
+
symbol_index, symbol_note = (code_graph.cached_symbols(root) if any(group != "doc" for group in groups)
|
|
962
|
+
else ({}, None))
|
|
963
|
+
except Exception as exc:
|
|
964
|
+
symbol_index, symbol_note = None, f"Function analysis failed: {_project_error(exc)}"
|
|
965
|
+
if symbol_index is None and any(group != "doc" for group in groups):
|
|
966
|
+
warnings.append(symbol_note or "Per-chunk functions are being prepared; they will show on the next search.")
|
|
967
|
+
elif symbol_note or (symbol_index or {}).get("note"):
|
|
968
|
+
warnings.append(symbol_note or symbol_index["note"])
|
|
969
|
+
query_terms = _symbol_terms(" ".join(queries[group] for group in groups if group != "doc"))
|
|
970
|
+
_update_job(job_id, phase="searching")
|
|
971
|
+
found = {group: indexer.search(root, vectors.get(queries[group]), top_k=max(limits[group] * 4, candidate_pool),
|
|
972
|
+
query=queries[group], model_id=search_model,
|
|
973
|
+
include=lambda path, group=group: index_profile.kind(path, profile) == group)
|
|
974
|
+
for group in groups}
|
|
975
|
+
|
|
976
|
+
def search_block(group):
|
|
977
|
+
top_k = limits[group]
|
|
978
|
+
query, vector, candidates = queries[group], vectors.get(queries[group]), found[group]
|
|
979
|
+
ordered = [(score, "hybrid" if vector is not None else "lexical", row) for score, *row in candidates[:top_k]]
|
|
980
|
+
problem = None
|
|
981
|
+
if cfg.get("rerank_model") and candidates and vector is not None:
|
|
982
|
+
_update_job(job_id, phase="reranking")
|
|
983
|
+
try:
|
|
984
|
+
reranked = _rerank(cfg["rerank_model"], query, [c[4] for c in candidates], top_k, token,
|
|
985
|
+
deadline=_job_deadline(job_id))
|
|
986
|
+
if not reranked or any(type(item.get("index")) is not int or not 0 <= item["index"] < len(candidates)
|
|
987
|
+
for item in reranked):
|
|
988
|
+
raise ValueError("Invalid index in the reranker response.")
|
|
989
|
+
ordered = [(float(item["relevance_score"]), "rerank", candidates[item["index"]][1:]) for item in reranked]
|
|
990
|
+
except Exception as exc:
|
|
991
|
+
problem = f"Rerank unavailable for {group}; hybrid lexical/vector order kept. " + _project_error(exc)
|
|
992
|
+
rows = [{"path": path, "lines": f"{start}-{end}", "score": round(score, 6), "snippet": text[:500],
|
|
993
|
+
"ranking": ranking, "location_kind": document_text.location_kind(path),
|
|
994
|
+
**({"symbols": _chunk_symbols(symbol_index, path, start, end, text, query_terms)}
|
|
995
|
+
if symbol_index and group != "doc" else {})}
|
|
996
|
+
for score, ranking, (path, start, end, text) in ordered[:top_k]]
|
|
997
|
+
return rows, problem
|
|
998
|
+
|
|
999
|
+
with concurrent.futures.ThreadPoolExecutor(len(groups)) as pool:
|
|
1000
|
+
futures = {group: pool.submit(contextvars.copy_context().run, search_block, group) for group in groups}
|
|
1001
|
+
blocks = {}
|
|
1002
|
+
for group, future in futures.items():
|
|
1003
|
+
blocks[group], problem = future.result()
|
|
1004
|
+
if problem:
|
|
1005
|
+
warnings.append(problem)
|
|
1006
|
+
_job_checkpoint(job_id)
|
|
1007
|
+
warning = " ".join(warnings)
|
|
1008
|
+
if warning:
|
|
1009
|
+
_update_job(job_id, warning=warning)
|
|
1010
|
+
return _grouped_yaml(index_profile.public(profile), blocks, warning)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _perform_project_index(arguments, job_id, preview=False):
|
|
1014
|
+
return contextvars.copy_context().run(_perform_project_index_isolated, arguments, job_id, preview)
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
def _perform_project_index_isolated(arguments, job_id, preview=False):
|
|
1018
|
+
_update_job(job_id, phase="auth")
|
|
1019
|
+
problem = _gateway_problem()
|
|
1020
|
+
cfg = config.load_config()
|
|
1021
|
+
if problem:
|
|
1022
|
+
model = local_embedder.resolve(cfg.get("embedding_model"), indexer.info(arguments["project_root"]).get("model_id"))
|
|
1023
|
+
if model != indexer.LEXICAL_MODEL and not local_embedder.is_local(model):
|
|
1024
|
+
raise RuntimeError(problem)
|
|
1025
|
+
model_client.OFFLINE.set(problem)
|
|
1026
|
+
return _prepare_project_index(arguments, job_id, cfg, "", preview=preview, model=model)
|
|
1027
|
+
return _prepare_project_index(arguments, job_id, cfg, model_client.get_token(), preview=preview)
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _run_project_job(job_id, arguments, kind):
|
|
1031
|
+
try:
|
|
1032
|
+
with index_views.pin(arguments['project_root']) as view:
|
|
1033
|
+
with usage_meter.job(arguments['project_root'],view,job_id):
|
|
1034
|
+
return _run_project_job_in_view(job_id, arguments, kind)
|
|
1035
|
+
except Exception as exc:
|
|
1036
|
+
with _JOBS_LOCK:
|
|
1037
|
+
pending = (_jobs.get(job_id) or {}).get('status') == 'pending'
|
|
1038
|
+
if pending:
|
|
1039
|
+
_set_job(job_id, status='error', error=_project_error(exc), failure_phase='git_view')
|
|
1040
|
+
project_store.update(project_identity.project_id(arguments['project_root']), status='error', last_error=_project_error(exc))
|
|
1041
|
+
raise
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _run_project_job_in_view(job_id, arguments, kind):
|
|
1045
|
+
with _JOBS_LOCK:
|
|
1046
|
+
job = dict(_jobs[job_id])
|
|
1047
|
+
project_id = job["project_id"]
|
|
1048
|
+
project = project_store.get(project_id) or {}
|
|
1049
|
+
captured_seq = project.get("dirty_seq", 0)
|
|
1050
|
+
_update_job(job_id, deadline=time.monotonic() + INDEX_JOB_TIMEOUT_S, started_at=time.time())
|
|
1051
|
+
try:
|
|
1052
|
+
_job_checkpoint(job_id)
|
|
1053
|
+
view = index_views.describe(arguments['project_root'])
|
|
1054
|
+
if arguments.get('_requested_view') and arguments['_requested_view'] != index_views.public(view):
|
|
1055
|
+
raise index_views.ViewChanged('The branch or commit changed while the job was waiting. Start again on the current view.')
|
|
1056
|
+
_update_job(job_id, view=index_views.public(view))
|
|
1057
|
+
with _project_lock(arguments["project_root"]):
|
|
1058
|
+
result = (_perform_smart_search(arguments, job_id) if kind == "smart_search"
|
|
1059
|
+
else _perform_project_index(arguments, job_id, preview=kind == "project_preview"))
|
|
1060
|
+
_set_job(job_id, status="done", phase="done", result=result, error=None)
|
|
1061
|
+
current = project_store.get(project_id) or {}
|
|
1062
|
+
with _JOBS_LOCK:
|
|
1063
|
+
final = dict(_jobs.get(job_id) or {})
|
|
1064
|
+
if kind == "project_preview":
|
|
1065
|
+
state = "preview_ready" if not current.get("enabled") else "paused" if current.get("paused") else "dirty" if current.get("dirty_seq", 0) > current.get("indexed_seq", 0) else "ready"
|
|
1066
|
+
project_store.update(project_id, status=state, last_error="", last_job_id=job_id)
|
|
1067
|
+
else:
|
|
1068
|
+
updated = final.get("index_updated", False)
|
|
1069
|
+
dirty = current.get("dirty_seq", 0) > captured_seq
|
|
1070
|
+
state = ("paused" if current.get("paused") else "dirty" if dirty else
|
|
1071
|
+
"degraded" if final.get("warning") else "ready")
|
|
1072
|
+
fields = {"status": state, "last_error": "", "last_warning": final.get("warning", ""),
|
|
1073
|
+
"last_job_id": job_id, "failures": 0, "next_retry_at": 0}
|
|
1074
|
+
if updated:
|
|
1075
|
+
fields.update(indexed_seq=captured_seq, last_indexed=time.time())
|
|
1076
|
+
project_store.update(project_id, **fields)
|
|
1077
|
+
_log(f"project_job {job_id} {project_id} {kind} done elapsed={time.time()-job['created_at']:.2f}s")
|
|
1078
|
+
return result
|
|
1079
|
+
except Exception as exc:
|
|
1080
|
+
with _JOBS_LOCK:
|
|
1081
|
+
failed_phase = (_jobs.get(job_id) or {}).get("phase", "queued")
|
|
1082
|
+
cancelled = isinstance(exc, ProjectJobCancelled)
|
|
1083
|
+
error = _project_error(exc)
|
|
1084
|
+
_set_job(job_id, status="cancelled" if cancelled else "error", phase="cancelled" if cancelled else "error",
|
|
1085
|
+
failure_phase=failed_phase, result=None, error=error)
|
|
1086
|
+
current = project_store.get(project_id) or {}
|
|
1087
|
+
failures = current.get("failures", 0) + 1
|
|
1088
|
+
state = "paused" if current.get("paused") else "interrupted" if cancelled else "needs_config" if failed_phase == "auth" else "error"
|
|
1089
|
+
project_store.update(project_id, status=state, last_error=error, last_job_id=job_id,
|
|
1090
|
+
failures=failures, next_retry_at=time.time() + min(900, 30 * 2 ** min(failures, 5)))
|
|
1091
|
+
_log(f"project_job {job_id} {project_id} {kind} {state} phase={failed_phase} error={error}")
|
|
1092
|
+
raise
|
|
1093
|
+
finally:
|
|
1094
|
+
with _JOBS_LOCK:
|
|
1095
|
+
_JOB_FUTURES.pop(job_id, None)
|
|
1096
|
+
_PERSIST_LAST.pop(job_id, None)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
def _run_smart_search_job(job_id, arguments):
|
|
1100
|
+
return _run_project_job(job_id, arguments, "smart_search")
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _submit_project_job(kind, arguments):
|
|
1104
|
+
with _PROJECT_ACTION_LOCK:
|
|
1105
|
+
return _submit_project_job_locked(kind, arguments)
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def _submit_project_job_locked(kind, arguments):
|
|
1109
|
+
root = project_identity.display_root(arguments["project_root"])
|
|
1110
|
+
arguments = {**arguments, "project_root": root}
|
|
1111
|
+
arguments['_requested_view'] = index_views.public(index_views.describe(root, fresh=True))
|
|
1112
|
+
project = project_store.register(root)
|
|
1113
|
+
request_key = json.dumps([project["id"], kind, arguments], sort_keys=True, ensure_ascii=False)
|
|
1114
|
+
job_id = uuid.uuid4().hex
|
|
1115
|
+
with _JOBS_LOCK:
|
|
1116
|
+
existing = next((key for key, value in _jobs.items() if value.get("kind") in _PROJECT_KINDS
|
|
1117
|
+
and value.get("status") == "pending" and value.get("request_key") == request_key), None)
|
|
1118
|
+
if existing:
|
|
1119
|
+
return existing, None
|
|
1120
|
+
if sum(value.get("kind") in _PROJECT_KINDS and value.get("status") == "pending" for value in _jobs.values()) >= SMART_SEARCH_MAX_PENDING:
|
|
1121
|
+
raise RuntimeError("Too many project operations in progress; wait for one to finish.")
|
|
1122
|
+
payload = {"kind": kind, "project_id": project["id"], "status": "pending", "phase": "queued",
|
|
1123
|
+
"created_at": time.time(), "request_key": request_key, "arguments": arguments,
|
|
1124
|
+
"processed_files": None, "total_files": None, "result": None, "error": None}
|
|
1125
|
+
_persist_project_job(job_id, payload)
|
|
1126
|
+
_jobs[job_id] = payload
|
|
1127
|
+
project_store.update(project["id"], status="queued", last_job_id=job_id,
|
|
1128
|
+
enabled=kind != "project_preview" or project.get("enabled", False))
|
|
1129
|
+
try:
|
|
1130
|
+
future = SMART_SEARCH_EXECUTOR.submit(root, _run_project_job, job_id, arguments, kind)
|
|
1131
|
+
with _JOBS_LOCK:
|
|
1132
|
+
if not future.done():
|
|
1133
|
+
_JOB_FUTURES[job_id] = future
|
|
1134
|
+
def clear_future(_future):
|
|
1135
|
+
with _JOBS_LOCK:
|
|
1136
|
+
_JOB_FUTURES.pop(job_id, None)
|
|
1137
|
+
future.add_done_callback(clear_future)
|
|
1138
|
+
except Exception as exc:
|
|
1139
|
+
_set_job(job_id, status="error", phase="error", error=_project_error(exc))
|
|
1140
|
+
raise
|
|
1141
|
+
return job_id, future
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
SEARCH_LIMIT_TOTAL = 20
|
|
1145
|
+
SEARCH_LIMIT_DEFAULT = 5
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _limit_value(raw, field_name):
|
|
1149
|
+
if isinstance(raw, bool) or not isinstance(raw, (int, float, str)):
|
|
1150
|
+
raise ValueError(f"'{field_name}' must be an integer from 1 to {SEARCH_LIMIT_TOTAL} or an object {{code, test, doc}}.")
|
|
1151
|
+
try:
|
|
1152
|
+
number = float(raw)
|
|
1153
|
+
except (ValueError, OverflowError):
|
|
1154
|
+
raise ValueError(f"'{field_name}' must be an integer, got: {raw!r}")
|
|
1155
|
+
if not number.is_integer():
|
|
1156
|
+
raise ValueError(f"'{field_name}' must be an integer, got: {raw!r}")
|
|
1157
|
+
return _parse_bounded_int(int(number), default=SEARCH_LIMIT_DEFAULT, min_v=1, max_v=SEARCH_LIMIT_TOTAL,
|
|
1158
|
+
field_name=field_name)
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _search_limits(value, groups, saved=None):
|
|
1162
|
+
if value is None:
|
|
1163
|
+
value = saved or {}
|
|
1164
|
+
if not isinstance(value, dict):
|
|
1165
|
+
value = _limit_value(value, "top_k")
|
|
1166
|
+
if isinstance(value, dict) and set(value) - set(index_profile.KINDS):
|
|
1167
|
+
raise ValueError("top_k only accepts the keys code, test and doc.")
|
|
1168
|
+
limits = {}
|
|
1169
|
+
for group in groups:
|
|
1170
|
+
raw = value if not isinstance(value, dict) else value.get(group, (saved or {}).get(group, SEARCH_LIMIT_DEFAULT))
|
|
1171
|
+
limits[group] = _limit_value(raw, f"top_k.{group}")
|
|
1172
|
+
if sum(limits.values()) > SEARCH_LIMIT_TOTAL:
|
|
1173
|
+
raise ValueError(f"The top_k total across the requested blocks is {sum(limits.values())}; the maximum is {SEARCH_LIMIT_TOTAL}.")
|
|
1174
|
+
return limits
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
_GENERIC_SYMBOLS = {"callback", "constructor", "anonymous"}
|
|
1178
|
+
_CONTAINER_KINDS = {"module", "template", "class", "interface"}
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _symbol_terms(text):
|
|
1182
|
+
return {word.casefold() for word in re.findall(r"[A-Za-zÀ-ú]{3,}", re.sub(r"([a-z])([A-Z])", r"\1 \2", text))}
|
|
1183
|
+
|
|
1184
|
+
|
|
1185
|
+
def _chunk_symbols(entry, path, start, end, text, query_terms, limit=3):
|
|
1186
|
+
if path not in entry["parsed"]:
|
|
1187
|
+
return "(file not analyzed by the graph)"
|
|
1188
|
+
lines = text.splitlines()
|
|
1189
|
+
overlapping = [s for s in entry["symbols"].get(path, []) if s["start_line"] <= end and s["end_line"] >= start
|
|
1190
|
+
and s.get("kind") not in _CONTAINER_KINDS]
|
|
1191
|
+
inner = any(s["start_line"] >= start for s in overlapping)
|
|
1192
|
+
picked = []
|
|
1193
|
+
for symbol in overlapping:
|
|
1194
|
+
name = symbol["name"].split("(")[0].split(".")[-1]
|
|
1195
|
+
if (name in _GENERIC_SYMBOLS or name.startswith("<") or "@" in name
|
|
1196
|
+
or (inner and symbol["start_line"] < start and symbol["end_line"] > end)):
|
|
1197
|
+
continue
|
|
1198
|
+
body = "\n".join(lines[max(symbol["start_line"] - start, 0):max(min(symbol["end_line"], end) - start + 1, 0)])
|
|
1199
|
+
picked.append((-len(query_terms & _symbol_terms(name + " " + body)), symbol["start_line"], name))
|
|
1200
|
+
return ", ".join(f"{name}:{line}" for _overlap, line, name in sorted(picked)[:limit])
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _primary_language(tags):
|
|
1204
|
+
return (tags[0].split("-")[0].casefold() if tags else "")
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _optional_text(arguments, name):
|
|
1208
|
+
value = arguments.get(name)
|
|
1209
|
+
if value is None:
|
|
1210
|
+
return None
|
|
1211
|
+
if not isinstance(value, str) or not value.strip() or len(value) > 4000:
|
|
1212
|
+
raise ValueError(f"{name} must be non-empty text of at most 4,000 characters.")
|
|
1213
|
+
try:
|
|
1214
|
+
value.encode("utf-8")
|
|
1215
|
+
except UnicodeEncodeError:
|
|
1216
|
+
raise ValueError(f"{name} contains an invalid character (lone UTF-16 surrogate).")
|
|
1217
|
+
return value.strip()
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
_SMART_SEARCH_FIELDS = {"project_root", "query_identifiers", "query_comments", "doc_query",
|
|
1221
|
+
"single_language_reason", "group", "top_k"}
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _reject_kind(message):
|
|
1225
|
+
for prefix, kind in (("Unknown fields", "unknown_field"), ("This project has identifiers", "language"),
|
|
1226
|
+
("'top_k", "limits"), ("The top_k total", "limits"), ("top_k", "limits"),
|
|
1227
|
+
("single_language_reason", "reason")):
|
|
1228
|
+
if message.startswith(prefix):
|
|
1229
|
+
return kind
|
|
1230
|
+
return "other"
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
def _handle_smart_search(arguments):
|
|
1234
|
+
try:
|
|
1235
|
+
arguments = _smart_search_arguments(arguments)
|
|
1236
|
+
except ValueError as exc:
|
|
1237
|
+
_metric("reject", kind=_reject_kind(str(exc)), message=str(exc)[:200])
|
|
1238
|
+
raise
|
|
1239
|
+
job_id, future = _submit_project_job("smart_search", arguments)
|
|
1240
|
+
if future is None:
|
|
1241
|
+
_metric("pending", reason="queued")
|
|
1242
|
+
return _to_json_sanitized(_smart_search_job_payload(job_id))
|
|
1243
|
+
try:
|
|
1244
|
+
result = future.result(timeout=SMART_SEARCH_SYNC_WAIT_S)
|
|
1245
|
+
except concurrent.futures.TimeoutError:
|
|
1246
|
+
if not future.done():
|
|
1247
|
+
_metric("pending", reason="sync_timeout")
|
|
1248
|
+
return _to_json_sanitized(_smart_search_job_payload(job_id))
|
|
1249
|
+
result = future.result()
|
|
1250
|
+
_mark_notice_delivered(job_id)
|
|
1251
|
+
return result
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def _mark_notice_delivered(job_id):
|
|
1255
|
+
session, _client = _session_client()
|
|
1256
|
+
with _JOBS_LOCK:
|
|
1257
|
+
job = _jobs.get(job_id) or {}
|
|
1258
|
+
if session and job.get("status") == "done" and (job.get("arguments") or {}).get("setup_notice"):
|
|
1259
|
+
session["notice_delivered"] = True
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def _smart_search_arguments(arguments):
|
|
1263
|
+
if not isinstance(arguments, dict):
|
|
1264
|
+
raise ValueError("smart_search requires its arguments as a JSON object")
|
|
1265
|
+
project_root = arguments.get("project_root")
|
|
1266
|
+
if not isinstance(project_root, str) or not os.path.isdir(project_root):
|
|
1267
|
+
raise ValueError("smart_search requires 'project_root' pointing to an existing folder")
|
|
1268
|
+
group = arguments.get("group", "all")
|
|
1269
|
+
if group not in (*index_profile.KINDS, "all"):
|
|
1270
|
+
raise ValueError("group must be code, test, doc or all.")
|
|
1271
|
+
groups = index_profile.KINDS if group == "all" else (group,)
|
|
1272
|
+
unknown = set(arguments) - _SMART_SEARCH_FIELDS
|
|
1273
|
+
if unknown:
|
|
1274
|
+
raise ValueError(f"Unknown fields in smart_search: {', '.join(sorted(unknown))}. "
|
|
1275
|
+
f"Accepted: {', '.join(sorted(_SMART_SEARCH_FIELDS))}.")
|
|
1276
|
+
identifiers = _optional_text(arguments, "query_identifiers")
|
|
1277
|
+
comments = _optional_text(arguments, "query_comments")
|
|
1278
|
+
doc_query = _optional_text(arguments, "doc_query")
|
|
1279
|
+
reason = _optional_text(arguments, "single_language_reason")
|
|
1280
|
+
if reason and len(reason) < 15:
|
|
1281
|
+
raise ValueError("single_language_reason must explain in at least 15 characters why a single language is enough.")
|
|
1282
|
+
if not identifiers and (group != "doc" or not (doc_query or comments)):
|
|
1283
|
+
raise ValueError("smart_search requires 'query_identifiers' (or 'doc_query' when group=doc).")
|
|
1284
|
+
profile = index_profile.current((index_scope.load_scope(project_root) or {}).get("profile"))
|
|
1285
|
+
public = index_profile.public(profile)
|
|
1286
|
+
identifier_language = _primary_language(public["identifier_languages"])
|
|
1287
|
+
other_language = next((tag for tag in public["comment_languages"] + public["identifier_languages"]
|
|
1288
|
+
if _primary_language([tag]) != identifier_language), None)
|
|
1289
|
+
if identifier_language and other_language and group != "doc" and not comments and not reason:
|
|
1290
|
+
raise ValueError(
|
|
1291
|
+
f"This project has identifiers in {', '.join(public['identifier_languages'])} and comments in "
|
|
1292
|
+
f"{', '.join(public['comment_languages'])}. Fill query_identifiers with identifier terms in "
|
|
1293
|
+
f"{public['identifier_languages'][0]} and query_comments with the same search in {other_language}; "
|
|
1294
|
+
f"docs in {', '.join(public['doc_languages']) or 'undetected language'} go in doc_query. "
|
|
1295
|
+
"To search in a single language, explain why in single_language_reason.")
|
|
1296
|
+
project = project_store.get(project_identity.project_id(project_root)) or {}
|
|
1297
|
+
limits = _search_limits(arguments.get("top_k"), groups, project.get("search_limits"))
|
|
1298
|
+
query = " ".join(part for part in (identifiers, comments) if part) or doc_query
|
|
1299
|
+
arguments = {"project_root": project_root, "query": query, "top_k": limits, "group": group,
|
|
1300
|
+
"doc_query": doc_query or comments or identifiers,
|
|
1301
|
+
**({"single_language_reason": reason} if reason else {})}
|
|
1302
|
+
session, client = _session_client()
|
|
1303
|
+
if session is not None and "hook_notice" not in session:
|
|
1304
|
+
state = client_hooks.status(client)
|
|
1305
|
+
_metric("hook_status", installed=state["installed"], client_name=state["name"], error=state["error"])
|
|
1306
|
+
session["hook_notice"] = client_hooks.notice(state) or ""
|
|
1307
|
+
if session is not None and session["hook_notice"] and not session.get("notice_delivered"):
|
|
1308
|
+
arguments["setup_notice"] = session["hook_notice"]
|
|
1309
|
+
_metric("search", group=group, top_k=limits, query_comments=bool(comments), single_language_reason=reason)
|
|
1310
|
+
return arguments
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def _handle_smart_search_result(arguments):
|
|
1314
|
+
job_id = arguments.get("job_id") if isinstance(arguments, dict) else None
|
|
1315
|
+
if not job_id:
|
|
1316
|
+
raise ValueError("smart_search_result requires 'job_id'")
|
|
1317
|
+
payload = _smart_search_job_payload(job_id)
|
|
1318
|
+
_mark_notice_delivered(job_id)
|
|
1319
|
+
return _to_json_sanitized(payload)
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def _project_payload(project):
|
|
1323
|
+
data = dict(project)
|
|
1324
|
+
data['dismissed_duplicates'] = len(data.get('dismissed_duplicates') or {})
|
|
1325
|
+
data['view'] = index_views.public(index_views.describe(project['root']))
|
|
1326
|
+
data['update_mode'] = project.get('update_mode', 'on_search')
|
|
1327
|
+
data["index"] = indexer.info(project["root"])
|
|
1328
|
+
if data['view']['git']:
|
|
1329
|
+
data['last_indexed'] = data['index'].get('updated_at')
|
|
1330
|
+
if (project.get('last_stats') or {}).get('view', {}).get('view_id') != data['view']['view_id']:
|
|
1331
|
+
data['last_stats'] = {}
|
|
1332
|
+
if project.get('enabled') and project.get('status') == 'ready' and (
|
|
1333
|
+
not data['index'].get('exists') or data['index'].get('git_commit') != data['view'].get('commit')):
|
|
1334
|
+
data['status'] = 'dirty'
|
|
1335
|
+
data["scope"] = index_scope.load_scope(project["root"])
|
|
1336
|
+
data["search_profile"] = index_profile.public(index_profile.current((data["scope"] or {}).get("profile")))
|
|
1337
|
+
data["search_limits"] = _search_limits(None, index_profile.KINDS, project.get("search_limits"))
|
|
1338
|
+
data["jobs"] = [_smart_search_job_payload(row["job_id"])
|
|
1339
|
+
for row in project_store.jobs(project["id"], limit=12)]
|
|
1340
|
+
data["active_jobs"] = _active_project_jobs(project["id"])
|
|
1341
|
+
return data
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def _active_project_jobs(project_id):
|
|
1345
|
+
with _JOBS_LOCK:
|
|
1346
|
+
return [key for key, value in _jobs.items() if value.get("project_id") == project_id
|
|
1347
|
+
and value.get("status") == "pending"]
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def _cancel_project_job(job_id):
|
|
1351
|
+
with _JOBS_LOCK:
|
|
1352
|
+
job = dict(_jobs.get(job_id) or {})
|
|
1353
|
+
future = _JOB_FUTURES.get(job_id)
|
|
1354
|
+
if job.get("status") != "pending":
|
|
1355
|
+
return
|
|
1356
|
+
_update_job(job_id, cancel_requested=True)
|
|
1357
|
+
if future and future.cancel():
|
|
1358
|
+
_set_job(job_id, status="cancelled", phase="cancelled", result=None,
|
|
1359
|
+
error="Operation cancelled before it started; resume it whenever you want.")
|
|
1360
|
+
with _JOBS_LOCK:
|
|
1361
|
+
_JOB_FUTURES.pop(job_id, None)
|
|
1362
|
+
|
|
1363
|
+
|
|
1364
|
+
def _change_dismissal(key, project, arguments, dismiss):
|
|
1365
|
+
import duplicates
|
|
1366
|
+
finding = arguments.get("finding_id")
|
|
1367
|
+
if not isinstance(finding, str) or not re.fullmatch(r"[ens][0-9a-f]{16}", finding):
|
|
1368
|
+
raise ValueError("finding_id must be the id of a finding returned by action=duplicates.")
|
|
1369
|
+
with _PROJECT_ACTION_LOCK:
|
|
1370
|
+
current = dict(project_store.get(key).get("dismissed_duplicates") or {})
|
|
1371
|
+
if not dismiss:
|
|
1372
|
+
if current.pop(finding, None) is None:
|
|
1373
|
+
raise ValueError(f"{finding} is not dismissed in this project.")
|
|
1374
|
+
project_store.update(key, dismissed_duplicates=current)
|
|
1375
|
+
_metric("duplicate_restored", finding_type=duplicates.TYPE_BY_PREFIX[finding[0]])
|
|
1376
|
+
return {"finding_id": finding, "dismissed": False}
|
|
1377
|
+
kind = duplicates.LAST_FINDINGS.get(project["root"], {}).get(finding)
|
|
1378
|
+
if kind is None:
|
|
1379
|
+
raise ValueError(f"{finding} is not in this project's latest analysis; run action=duplicates and use an id from it.")
|
|
1380
|
+
reason = arguments.get("reason")
|
|
1381
|
+
if reason not in duplicates.DISMISS_REASONS:
|
|
1382
|
+
raise ValueError("reason must be false_positive (not a duplicate) or intentional (copy kept on purpose).")
|
|
1383
|
+
note = _optional_text(arguments, "note")
|
|
1384
|
+
if not note or not 15 <= len(note) <= duplicates.NOTE_MAX:
|
|
1385
|
+
raise ValueError(f"note must explain in 15 to {duplicates.NOTE_MAX} characters why this finding should not be fixed.")
|
|
1386
|
+
current[finding] = {"reason": reason, "note": note, "dismissed_at": time.time(), "finding_type": kind}
|
|
1387
|
+
project_store.update(key, dismissed_duplicates=current)
|
|
1388
|
+
_metric("duplicate_dismissed", finding_type=kind, reason=reason, note=note)
|
|
1389
|
+
return {"finding_id": finding, "dismissed": True, "reason": reason, "finding_type": kind}
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def _find_duplicates(root, arguments, dismissed):
|
|
1393
|
+
import duplicates
|
|
1394
|
+
similarity = arguments.get("min_similarity", duplicates.DEFAULT_MIN_SIMILARITY)
|
|
1395
|
+
if isinstance(similarity, bool) or not isinstance(similarity, (int, float)) or not 0.5 <= similarity <= 1:
|
|
1396
|
+
raise ValueError("min_similarity must be a number between 0.5 and 1.0 (default 0.90).")
|
|
1397
|
+
include_tests = arguments.get("include_tests", False)
|
|
1398
|
+
if type(include_tests) is not bool:
|
|
1399
|
+
raise ValueError("include_tests must be true or false.")
|
|
1400
|
+
limit = _parse_bounded_int(arguments.get("limit"), default=30, min_v=1, max_v=200, field_name="limit")
|
|
1401
|
+
configured_model = local_embedder.resolve(config.load_config().get("embedding_model"),
|
|
1402
|
+
indexer.info(root).get("model_id"))
|
|
1403
|
+
if configured_model == indexer.LEXICAL_MODEL:
|
|
1404
|
+
configured_model = None
|
|
1405
|
+
view = index_views.describe(root)
|
|
1406
|
+
|
|
1407
|
+
def embed(model, texts):
|
|
1408
|
+
with usage_meter.job(root, view, "duplicates"):
|
|
1409
|
+
return _embed(model, texts, model_client.get_token())
|
|
1410
|
+
|
|
1411
|
+
include_dismissed = arguments.get("include_dismissed", False)
|
|
1412
|
+
if type(include_dismissed) is not bool:
|
|
1413
|
+
raise ValueError("include_dismissed must be true or false.")
|
|
1414
|
+
return duplicates.find(root, embed, configured_model, float(similarity), include_tests, limit, dismissed, include_dismissed)
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _project_action(arguments):
|
|
1418
|
+
if not isinstance(arguments, dict):
|
|
1419
|
+
raise ValueError("Pass the action in a JSON object.")
|
|
1420
|
+
action = arguments.get("action", "list")
|
|
1421
|
+
if action == "probe":
|
|
1422
|
+
return _probe_index_models()
|
|
1423
|
+
if action in ('graph','compare','duplicates','duplicates_dismiss','duplicates_restore'):
|
|
1424
|
+
# Análise somente leitura pode demorar; não prende o lock global de ações.
|
|
1425
|
+
key=arguments.get('project_id')
|
|
1426
|
+
if not isinstance(key,str) or not re.fullmatch(r'[a-f0-9]{16}',key):
|
|
1427
|
+
raise ValueError('Pass a valid project_id.')
|
|
1428
|
+
project=project_store.get(key)
|
|
1429
|
+
if not project:
|
|
1430
|
+
raise ValueError('Project not registered.')
|
|
1431
|
+
if action=='duplicates':
|
|
1432
|
+
return _find_duplicates(project['root'], arguments, project.get('dismissed_duplicates') or {})
|
|
1433
|
+
if action in ('duplicates_dismiss','duplicates_restore'):
|
|
1434
|
+
return _change_dismissal(key, project, arguments, action == 'duplicates_dismiss')
|
|
1435
|
+
if action=='graph':
|
|
1436
|
+
import code_graph
|
|
1437
|
+
return code_graph.build(project['root'],arguments.get('view_id'),file_path=arguments.get('file_path'))
|
|
1438
|
+
import view_compare
|
|
1439
|
+
return view_compare.compare(project['root'],arguments.get('left_view'),arguments.get('right_view'),
|
|
1440
|
+
arguments.get('file_path'),arguments.get('relations',True))
|
|
1441
|
+
# O seletor tem seu próprio ciclo de mensagens e não segura a fila de projetos.
|
|
1442
|
+
if action == "pick":
|
|
1443
|
+
import folder_picker
|
|
1444
|
+
root = folder_picker.choose_folder()
|
|
1445
|
+
if not root:
|
|
1446
|
+
return {"cancelled": True}
|
|
1447
|
+
arguments = {"action": "register", "project_root": root}
|
|
1448
|
+
with _PROJECT_ACTION_LOCK:
|
|
1449
|
+
project = project_store.get(arguments['project_id']) if isinstance(arguments.get('project_id'), str) else None
|
|
1450
|
+
root = project['root'] if project else arguments.get('project_root')
|
|
1451
|
+
with index_views.pin(root) if isinstance(root, str) and os.path.isdir(root) else contextlib.nullcontext():
|
|
1452
|
+
result = _project_action_locked(arguments)
|
|
1453
|
+
if _PROJECT_MONITOR and arguments.get("action") not in (None, "list", "status", "inspect", "usage", "storage", "pin_view", "cleanup_preview", "cleanup_commit", "search_limits", "integration", "install_hook_preview", "install_hook"):
|
|
1454
|
+
_PROJECT_MONITOR.refresh()
|
|
1455
|
+
return result
|
|
1456
|
+
|
|
1457
|
+
|
|
1458
|
+
def _probe_index_models():
|
|
1459
|
+
global _GATEWAY_STATUS
|
|
1460
|
+
with _GATEWAY_LOCK:
|
|
1461
|
+
cfg = config.load_config()
|
|
1462
|
+
embedding = local_embedder.resolve(cfg.get("embedding_model"))
|
|
1463
|
+
embedding = None if embedding == indexer.LEXICAL_MODEL else embedding
|
|
1464
|
+
local_down = (config.local_models_mode(cfg) == "prefer" and local_embedder.configured()
|
|
1465
|
+
and not local_embedder.info())
|
|
1466
|
+
models = [embedding, cfg.get("rerank_model")]
|
|
1467
|
+
if _GATEWAY_STATUS.get("models") == models and time.time() - _GATEWAY_STATUS.get("checked_at", 0) < 30:
|
|
1468
|
+
return {"gateway": dict(_GATEWAY_STATUS), "cached": True}
|
|
1469
|
+
result = {"checked_at": time.time(), "models": models}
|
|
1470
|
+
try:
|
|
1471
|
+
if not local_embedder.is_local(embedding) or cfg.get("rerank_model"):
|
|
1472
|
+
_require_valid_credential()
|
|
1473
|
+
token = "" if local_embedder.is_local(embedding) and not cfg.get("rerank_model") else model_client.get_token()
|
|
1474
|
+
except Exception as exc:
|
|
1475
|
+
result.update(status="needs_config", error=_project_error(exc))
|
|
1476
|
+
else:
|
|
1477
|
+
for kind, model in zip(("embedding", "rerank"), models):
|
|
1478
|
+
if not model:
|
|
1479
|
+
result[kind] = {"status": "not_configured"}
|
|
1480
|
+
continue
|
|
1481
|
+
if kind == "embedding" and local_down:
|
|
1482
|
+
result[kind] = {"status": "error", "error": "Prefer-local mode: the mcp-memory local embedder is not "
|
|
1483
|
+
"responding; local indexes have no vector part until it is back.",
|
|
1484
|
+
"elapsed_s": 0.0}
|
|
1485
|
+
continue
|
|
1486
|
+
start = time.monotonic()
|
|
1487
|
+
try:
|
|
1488
|
+
if kind == "embedding":
|
|
1489
|
+
vector = _embed(model, ["Smart Tool: indexing diagnostics."], token, deadline=start + 60)[0]
|
|
1490
|
+
result[kind] = {"status": "ok", "dimensions": len(vector)}
|
|
1491
|
+
else:
|
|
1492
|
+
_rerank(model, "indexing", ["Indexing files for search."], 1, token, deadline=start + 30)
|
|
1493
|
+
result[kind] = {"status": "ok"}
|
|
1494
|
+
except Exception as exc:
|
|
1495
|
+
result[kind] = {"status": "error", "error": _project_error(exc)}
|
|
1496
|
+
result[kind]["elapsed_s"] = round(time.monotonic() - start, 2)
|
|
1497
|
+
result["status"] = "ok" if result.get("embedding", {}).get("status") == "ok" and result.get("rerank", {}).get("status") in ("ok", "not_configured") else "error"
|
|
1498
|
+
_GATEWAY_STATUS = result
|
|
1499
|
+
return {"gateway": result, "cached": False}
|
|
1500
|
+
|
|
1501
|
+
|
|
1502
|
+
def _project_action_locked(arguments):
|
|
1503
|
+
action = arguments.get("action", "list")
|
|
1504
|
+
if action == "integration":
|
|
1505
|
+
_session, client = _session_client()
|
|
1506
|
+
return {"current_client": client_hooks.status(client), **integration_metrics.summary(arguments.get("days", 7))}
|
|
1507
|
+
if action == "install_hook_preview":
|
|
1508
|
+
_session, client = _session_client()
|
|
1509
|
+
return client_hooks.preview(client)
|
|
1510
|
+
if action == "install_hook":
|
|
1511
|
+
_session, client = _session_client()
|
|
1512
|
+
result = client_hooks.install(client, arguments.get("plan_id"), arguments.get("confirm"))
|
|
1513
|
+
_metric("install_hook", target=result["client"], changed=result["changed"])
|
|
1514
|
+
return result
|
|
1515
|
+
if action == "list":
|
|
1516
|
+
return {"projects": [_project_payload(p) for p in project_store.all_projects()],
|
|
1517
|
+
"storage_dir": indexer.INDEX_DIR,
|
|
1518
|
+
"gateway": dict(_GATEWAY_STATUS),
|
|
1519
|
+
"monitor": {"debounce_s": project_monitor.DEBOUNCE_S,
|
|
1520
|
+
"reconcile_s": project_monitor.RECONCILE_S}}
|
|
1521
|
+
if action == "register":
|
|
1522
|
+
root = arguments.get("project_root")
|
|
1523
|
+
if not isinstance(root, str) or not os.path.isabs(root):
|
|
1524
|
+
raise ValueError("Pass the folder's absolute path.")
|
|
1525
|
+
project = project_store.register(root)
|
|
1526
|
+
return {"project": _project_payload(project)}
|
|
1527
|
+
key = arguments.get("project_id")
|
|
1528
|
+
if not isinstance(key, str) or not re.fullmatch(r"[a-f0-9]{16}", key):
|
|
1529
|
+
raise ValueError("Pass a valid project_id; use the list action to look it up.")
|
|
1530
|
+
project = project_store.get(key)
|
|
1531
|
+
if not project:
|
|
1532
|
+
raise ValueError("Project not registered.")
|
|
1533
|
+
if action == "status":
|
|
1534
|
+
return {"project": _project_payload(project)}
|
|
1535
|
+
if action == "search_limits":
|
|
1536
|
+
if not isinstance(arguments.get("top_k"), dict):
|
|
1537
|
+
raise ValueError("Pass top_k as an object {code, test, doc}; each value 1-20, total at most 20.")
|
|
1538
|
+
limits = _search_limits(arguments["top_k"], index_profile.KINDS, project.get("search_limits"))
|
|
1539
|
+
project_store.update(key, search_limits=limits)
|
|
1540
|
+
return {"project_id": key, "search_limits": limits}
|
|
1541
|
+
if action == 'inspect':
|
|
1542
|
+
return index_inventory.inspect(project['root'], arguments.get('view_id'), arguments.get('file_path'))
|
|
1543
|
+
if action=='usage':
|
|
1544
|
+
return usage_meter.summary(key,arguments.get('days',30),arguments.get('view_id'))
|
|
1545
|
+
if action=='storage':
|
|
1546
|
+
import project_storage
|
|
1547
|
+
return project_storage.inventory(project)
|
|
1548
|
+
active = _active_project_jobs(key)
|
|
1549
|
+
if action in ('pin_view','cleanup_preview','cleanup_commit'):
|
|
1550
|
+
if active:raise ValueError('Wait for this project\'s jobs to finish before changing storage.')
|
|
1551
|
+
import project_storage
|
|
1552
|
+
if action=='pin_view':return project_storage.pin(project,arguments.get('storage_id'),arguments.get('pinned'))
|
|
1553
|
+
if action=='cleanup_preview':return project_storage.preview(project,arguments.get('storage_ids'),arguments.get('clear_cache',True))
|
|
1554
|
+
if arguments.get('confirm') is not True:raise ValueError('Confirm the cleanup preview before applying it.')
|
|
1555
|
+
if not isinstance(arguments.get('plan_id'),str):raise ValueError('Pass the preview identifier.')
|
|
1556
|
+
return project_storage.commit(project,arguments['plan_id'])
|
|
1557
|
+
if action == 'policy':
|
|
1558
|
+
mode = arguments.get('update_mode')
|
|
1559
|
+
if mode not in ('on_search', 'eager'):
|
|
1560
|
+
raise ValueError('Choose on_search or eager for updates.')
|
|
1561
|
+
project_store.update(key, update_mode=mode)
|
|
1562
|
+
if mode == 'on_search':
|
|
1563
|
+
for job_id in active:
|
|
1564
|
+
with _JOBS_LOCK:
|
|
1565
|
+
origin = ((_jobs.get(job_id) or {}).get('arguments') or {}).get('_origin')
|
|
1566
|
+
if origin == 'monitor':
|
|
1567
|
+
_cancel_project_job(job_id)
|
|
1568
|
+
elif action in ("pause", "cancel"):
|
|
1569
|
+
project_store.update(key, paused=True, status="paused")
|
|
1570
|
+
requested = arguments.get("job_id")
|
|
1571
|
+
if requested and requested not in active:
|
|
1572
|
+
raise ValueError("Job is not active in this project.")
|
|
1573
|
+
for job_id in ([requested] if requested else active):
|
|
1574
|
+
_cancel_project_job(job_id)
|
|
1575
|
+
elif action == "watch":
|
|
1576
|
+
value = arguments.get("watch")
|
|
1577
|
+
if type(value) is not bool:
|
|
1578
|
+
raise ValueError("watch must be true or false.")
|
|
1579
|
+
project_store.update(key, watch=value, paused=False, next_retry_at=0)
|
|
1580
|
+
if value and project.get("enabled"):
|
|
1581
|
+
project_store.mark_dirty(key, "Watching enabled; checking the folder.")
|
|
1582
|
+
elif action in ("preview", "index", "rebuild", "resume"):
|
|
1583
|
+
if active:
|
|
1584
|
+
return {"job": _smart_search_job_payload(active[0])}
|
|
1585
|
+
options = {"project_root": project["root"]}
|
|
1586
|
+
kind = "project_preview" if action == "preview" else "project_index"
|
|
1587
|
+
if action == "resume":
|
|
1588
|
+
previous = project_store.job(arguments.get("job_id") or project.get("last_job_id"))
|
|
1589
|
+
if previous and previous.get("project_id") == key and previous.get("status") in ("interrupted", "error", "cancelled"):
|
|
1590
|
+
kind, options = previous["kind"], dict(previous["arguments"])
|
|
1591
|
+
if action == "rebuild":
|
|
1592
|
+
options.update(force_rebuild=True, full_check=True)
|
|
1593
|
+
if arguments.get("force_scope"):
|
|
1594
|
+
options["force_scope"] = True
|
|
1595
|
+
project_store.update(key, paused=False, next_retry_at=0)
|
|
1596
|
+
job_id, _future = _submit_project_job(kind, options)
|
|
1597
|
+
return {"job": _smart_search_job_payload(job_id)}
|
|
1598
|
+
elif action == "scope":
|
|
1599
|
+
if active:
|
|
1600
|
+
raise ValueError("Wait for or cancel the job before changing the scope.")
|
|
1601
|
+
manual = arguments.get("manual", True)
|
|
1602
|
+
if type(manual) is not bool:
|
|
1603
|
+
raise ValueError("manual must be true or false.")
|
|
1604
|
+
scope = dict(index_scope.load_scope(project["root"]) or {"include": ["."], "exclude": []})
|
|
1605
|
+
for field in ("include", "exclude", "user_exclude"):
|
|
1606
|
+
values = arguments.get(field, scope.get(field, []))
|
|
1607
|
+
if not isinstance(values, list) or len(values) > 200 or any(
|
|
1608
|
+
not isinstance(v, str) or not v.strip() or len(v) > 1000 for v in values):
|
|
1609
|
+
raise ValueError(f"{field} must contain at most 200 relative paths or patterns.")
|
|
1610
|
+
for value in values:
|
|
1611
|
+
if os.path.isabs(value) or ".." in value.replace("\\", "/").split("/"):
|
|
1612
|
+
raise ValueError("The scope must stay inside the project folder.")
|
|
1613
|
+
if field == "include" and not project_identity.within_root(project["root"], os.path.join(project["root"], value)):
|
|
1614
|
+
raise ValueError("Include points outside the folder.")
|
|
1615
|
+
scope[field] = values
|
|
1616
|
+
scope.update(manual=manual, scope_version=index_scope.SCOPE_VERSION if manual else 0)
|
|
1617
|
+
scope.pop("profile", None)
|
|
1618
|
+
scope.pop("profile_error", None)
|
|
1619
|
+
index_scope.save_scope(project["root"], scope)
|
|
1620
|
+
project_store.mark_dirty(key, "Scope changed; update or check the preview.")
|
|
1621
|
+
project_store.update(key, preview=None)
|
|
1622
|
+
elif action == "remove":
|
|
1623
|
+
if active:
|
|
1624
|
+
raise ValueError("Cancel the jobs and wait for them to stop before removing the index.")
|
|
1625
|
+
project_store.update(key, watch=False, paused=True, enabled=False)
|
|
1626
|
+
removed = indexer.remove_index(project["root"])
|
|
1627
|
+
import glob
|
|
1628
|
+
for scope_id in set([key, *project_identity.legacy_ids(project["root"])]):
|
|
1629
|
+
for path in glob.glob(os.path.join(index_scope.SCOPE_DIR, scope_id + '*.scope.json')):
|
|
1630
|
+
if re.fullmatch(re.escape(scope_id) + r'(?:\.v-[a-f0-9]{16})?\.scope\.json', os.path.basename(path)):
|
|
1631
|
+
os.remove(project_identity.state_path(path, index_scope.SCOPE_DIR))
|
|
1632
|
+
project_store.forget(key)
|
|
1633
|
+
return {"removed_index_files": len(removed), "source_files_preserved": True}
|
|
1634
|
+
elif action == "relocate":
|
|
1635
|
+
if active:
|
|
1636
|
+
raise ValueError("Wait for the jobs to stop before relocating the folder.")
|
|
1637
|
+
root = arguments.get("project_root")
|
|
1638
|
+
if not isinstance(root, str) or not os.path.isabs(root) or not os.path.isdir(root):
|
|
1639
|
+
raise ValueError("Pass the new absolute path of an existing folder.")
|
|
1640
|
+
root = project_identity.display_root(root)
|
|
1641
|
+
new_key = project_identity.project_id(root)
|
|
1642
|
+
if new_key == key:
|
|
1643
|
+
return {"project": _project_payload(project)}
|
|
1644
|
+
if project_store.get(new_key) or indexer.existing_db_path(root):
|
|
1645
|
+
raise ValueError("The target is already registered or indexed. Remove the target registration before relocating.")
|
|
1646
|
+
indexer.copy_project_indexes(project['root'], root)
|
|
1647
|
+
import glob
|
|
1648
|
+
import atomic_io
|
|
1649
|
+
for old_key in dict.fromkeys([key, *project_identity.legacy_ids(project['root'])]):
|
|
1650
|
+
for source in glob.glob(os.path.join(index_scope.SCOPE_DIR, old_key + '*.scope.json')):
|
|
1651
|
+
suffix = os.path.basename(source)[len(old_key):]
|
|
1652
|
+
if re.fullmatch(r'(?:\.v-[a-f0-9]{16})?\.scope\.json', suffix):
|
|
1653
|
+
target = project_identity.state_path(os.path.join(index_scope.SCOPE_DIR, new_key + suffix), index_scope.SCOPE_DIR)
|
|
1654
|
+
if not os.path.exists(target):
|
|
1655
|
+
with open(source, encoding='utf-8') as stream:
|
|
1656
|
+
value = json.load(stream)
|
|
1657
|
+
value['root'] = project_identity.canonical_root(root)
|
|
1658
|
+
atomic_io.write_secret_text(target, json.dumps(value, ensure_ascii=False))
|
|
1659
|
+
moved = project_store.relocate(key, root)
|
|
1660
|
+
with _JOBS_LOCK:
|
|
1661
|
+
for old_job in [j for j, data in _jobs.items() if data.get("project_id") == key]:
|
|
1662
|
+
_jobs.pop(old_job, None)
|
|
1663
|
+
return {"project": _project_payload(moved), "previous_index_preserved": True}
|
|
1664
|
+
else:
|
|
1665
|
+
raise ValueError("Unknown project management action.")
|
|
1666
|
+
return {"project": _project_payload(project_store.get(key))}
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
def _handle_project_manage(arguments):
|
|
1670
|
+
# MCP não abre janelas; o seletor nativo pertence apenas à interface local.
|
|
1671
|
+
if isinstance(arguments, dict) and arguments.get("action") == "pick":
|
|
1672
|
+
raise ValueError("Use register with project_root or pick the folder from the tray.")
|
|
1673
|
+
return _to_json_sanitized(_project_action(arguments))
|
|
1674
|
+
|
|
1675
|
+
|
|
1676
|
+
def _enqueue_monitored_project(project_id, **options):
|
|
1677
|
+
with _PROJECT_ACTION_LOCK:
|
|
1678
|
+
project = project_store.get(project_id)
|
|
1679
|
+
if not project or project.get("paused") or not project.get("enabled") or not project.get("watch") or project.get('update_mode','on_search') != 'eager':
|
|
1680
|
+
return
|
|
1681
|
+
return _submit_project_job("project_index", {"project_root": project["root"], '_origin': 'monitor', **options})
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def _run_search_script(executable, script_path, args, timeout_s):
|
|
1685
|
+
# Popen direto (não subprocess.run) porque timeout precisa matar a árvore de
|
|
1686
|
+
# processo inteira: Tier 2/3/4 sobem um browser real (Chromium/Firefox) como
|
|
1687
|
+
# filho do processo do script, e um kill só do processo direto no timeout
|
|
1688
|
+
# deixaria o browser órfão rodando em background. CREATE_NO_WINDOW junto de
|
|
1689
|
+
# CREATE_NEW_PROCESS_GROUP: o daemon roda sem console (DETACHED_PROCESS em
|
|
1690
|
+
# daemon_launcher), então sem essa flag cada subprocesso de busca abriria sua
|
|
1691
|
+
# própria janela de console visível — user-facing a cada tier tentado.
|
|
1692
|
+
popen_kwargs = (
|
|
1693
|
+
{"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW}
|
|
1694
|
+
if _IS_WINDOWS else {"start_new_session": True}
|
|
1695
|
+
)
|
|
1696
|
+
proc = subprocess.Popen(
|
|
1697
|
+
[executable, script_path, *args],
|
|
1698
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace",
|
|
1699
|
+
env={**os.environ, "SMART_TOOL_USER_AGENT": version.user_agent(config.load_config().get("contact"))},
|
|
1700
|
+
**popen_kwargs,
|
|
1701
|
+
)
|
|
1702
|
+
try:
|
|
1703
|
+
stdout, stderr = proc.communicate(timeout=timeout_s)
|
|
1704
|
+
except subprocess.TimeoutExpired:
|
|
1705
|
+
if _IS_WINDOWS:
|
|
1706
|
+
subprocess.run(
|
|
1707
|
+
["taskkill", "/PID", str(proc.pid), "/T", "/F"], capture_output=True, text=True,
|
|
1708
|
+
creationflags=subprocess.CREATE_NO_WINDOW,
|
|
1709
|
+
)
|
|
1710
|
+
else:
|
|
1711
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
1712
|
+
proc.wait(timeout=5)
|
|
1713
|
+
raise RuntimeError(f"timed out ({timeout_s}s)")
|
|
1714
|
+
if proc.returncode != 0:
|
|
1715
|
+
if script_path == web_search_adapters.EXTERNAL_SEARCH_SCRIPT and proc.returncode == 75:
|
|
1716
|
+
match = re.search(r"retry_after=(\d+)", stderr)
|
|
1717
|
+
seconds = int(match.group(1)) if match else WEB_PROVIDER_CIRCUIT_DEFAULT_S
|
|
1718
|
+
raise WebProviderRateLimited(seconds)
|
|
1719
|
+
# Últimos N chars, não o dump inteiro: bibliotecas como crawl4ai emitem
|
|
1720
|
+
# logging interno verboso (init, contexto de código, stack trace) no
|
|
1721
|
+
# stderr — a linha de erro relevante fica no final, não no início.
|
|
1722
|
+
stderr_text = stderr.strip() or f"{os.path.basename(script_path)} exited with code {proc.returncode}"
|
|
1723
|
+
raise RuntimeError(stderr_text[-SEARCH_SCRIPT_ERROR_MAX_CHARS:])
|
|
1724
|
+
return json.loads(stdout)
|
|
1725
|
+
|
|
1726
|
+
|
|
1727
|
+
def _web_search_tier_order(query, scope, router_model, candidate_tiers):
|
|
1728
|
+
tier_order = list(candidate_tiers)
|
|
1729
|
+
if not router_model:
|
|
1730
|
+
return tier_order
|
|
1731
|
+
try:
|
|
1732
|
+
health_summary = web_search_health.summary_for_tiers(WEB_SEARCH_DEFAULT_TIER_ORDER)
|
|
1733
|
+
first_tier, _reason = router.decide_web_search_tier(query, scope, health_summary, router_model)
|
|
1734
|
+
except Exception:
|
|
1735
|
+
return tier_order
|
|
1736
|
+
# O router escolhe entre TODOS os tiers (contrato de router.py não muda por chamador),
|
|
1737
|
+
# mas aqui só o subconjunto pedido é válido — uma escolha fora de `candidate_tiers`
|
|
1738
|
+
# (ex.: um tier do grupo paralelo, quando isto é chamado só pro fallback sequencial)
|
|
1739
|
+
# é ignorada e a ordem default do subconjunto prevalece, nunca um erro.
|
|
1740
|
+
if first_tier in tier_order:
|
|
1741
|
+
tier_order.remove(first_tier)
|
|
1742
|
+
tier_order.insert(0, first_tier)
|
|
1743
|
+
return tier_order
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def _domain_of(url):
|
|
1747
|
+
try:
|
|
1748
|
+
netloc = urllib.parse.urlparse(url).netloc
|
|
1749
|
+
except ValueError:
|
|
1750
|
+
return ""
|
|
1751
|
+
return netloc.lower().split(":")[0]
|
|
1752
|
+
|
|
1753
|
+
|
|
1754
|
+
def _site_domains_from_query(query):
|
|
1755
|
+
return tuple(dict.fromkeys(match.lower().rstrip(".") for match in _SITE_OPERATOR_RE.findall(query or "")))
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
def _url_in_site_domains(url, domains):
|
|
1759
|
+
try:
|
|
1760
|
+
host = (urllib.parse.urlparse(url).hostname or "").lower().rstrip(".")
|
|
1761
|
+
except ValueError:
|
|
1762
|
+
return False
|
|
1763
|
+
return any(host == domain or host.endswith("." + domain) for domain in domains)
|
|
1764
|
+
|
|
1765
|
+
|
|
1766
|
+
def _filter_site_results(rows, domains):
|
|
1767
|
+
if not domains:
|
|
1768
|
+
return rows
|
|
1769
|
+
return [row for row in rows if _url_in_site_domains(row.get("url", ""), domains)]
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
def _with_site_operator(query, domains):
|
|
1773
|
+
if len(domains) != 1:
|
|
1774
|
+
return query
|
|
1775
|
+
base = " ".join(_SITE_OPERATOR_RE.sub("", query).split())
|
|
1776
|
+
return f"{base} site:{domains[0]}"
|
|
1777
|
+
|
|
1778
|
+
|
|
1779
|
+
def _site_memory_correlate(query, records=None, deadline=None, include_content=True):
|
|
1780
|
+
# Fail-open: qualquer exceção devolve (set(), []).
|
|
1781
|
+
try:
|
|
1782
|
+
if records is None:
|
|
1783
|
+
records = site_memory.snapshot()
|
|
1784
|
+
if not records:
|
|
1785
|
+
return set(), []
|
|
1786
|
+
cfg = config.load_config()
|
|
1787
|
+
embedding_model = cfg.get("embedding_model")
|
|
1788
|
+
rerank_model = cfg.get("rerank_model")
|
|
1789
|
+
if not embedding_model or not rerank_model:
|
|
1790
|
+
_web_model_warn("Site memory off (sources that helped before are not prioritized): embedding_model or rerank_model missing.")
|
|
1791
|
+
return set(), []
|
|
1792
|
+
threshold = config.clamp_site_memory_similarity_threshold(cfg.get("site_memory_similarity_threshold"))
|
|
1793
|
+
topics = list({r["topic"] for r in records})[:SITE_MEMORY_MAX_TOPICS]
|
|
1794
|
+
token = model_client.get_token()
|
|
1795
|
+
timed = {"deadline": deadline} if deadline is not None else {}
|
|
1796
|
+
vectors = _embed(embedding_model, [query] + topics, token, **timed)
|
|
1797
|
+
query_vec, topic_vecs = vectors[0], vectors[1:]
|
|
1798
|
+
scored_topics = [
|
|
1799
|
+
(topic, indexer.cosine_similarity(query_vec, vec)) for topic, vec in zip(topics, topic_vecs)
|
|
1800
|
+
]
|
|
1801
|
+
candidates = [topic for topic, score in scored_topics if score >= threshold]
|
|
1802
|
+
if not candidates:
|
|
1803
|
+
research_metrics.log_site_memory(query, threshold, scored_topics, recalled=0)
|
|
1804
|
+
return set(), []
|
|
1805
|
+
reranked = _rerank(rerank_model, query, candidates, len(candidates), token, **timed)
|
|
1806
|
+
rerank_scores = {}
|
|
1807
|
+
for item in reranked:
|
|
1808
|
+
idx = item.get("index")
|
|
1809
|
+
if isinstance(idx, int) and 0 <= idx < len(candidates):
|
|
1810
|
+
rerank_scores[candidates[idx]] = float(item.get("relevance_score") or 0)
|
|
1811
|
+
similar_topics = {
|
|
1812
|
+
topic for topic, score in rerank_scores.items()
|
|
1813
|
+
if score >= SITE_MEMORY_RERANK_MIN_SCORE
|
|
1814
|
+
}
|
|
1815
|
+
if not similar_topics:
|
|
1816
|
+
research_metrics.log_site_memory(
|
|
1817
|
+
query, threshold, scored_topics, recalled=0,
|
|
1818
|
+
rerank_scores=rerank_scores, rerank_threshold=SITE_MEMORY_RERANK_MIN_SCORE,
|
|
1819
|
+
)
|
|
1820
|
+
return set(), []
|
|
1821
|
+
matching = [r for r in records if r["topic"] in similar_topics]
|
|
1822
|
+
boost_domains = {r["domain"] for r in matching}
|
|
1823
|
+
if not include_content:
|
|
1824
|
+
research_metrics.log_site_memory(
|
|
1825
|
+
query, threshold, scored_topics, recalled=0,
|
|
1826
|
+
rerank_scores=rerank_scores, rerank_threshold=SITE_MEMORY_RERANK_MIN_SCORE,
|
|
1827
|
+
)
|
|
1828
|
+
return boost_domains, []
|
|
1829
|
+
seen_urls = set()
|
|
1830
|
+
recalled = []
|
|
1831
|
+
for r in matching:
|
|
1832
|
+
if r["url"] in seen_urls:
|
|
1833
|
+
continue
|
|
1834
|
+
seen_urls.add(r["url"])
|
|
1835
|
+
recalled.append({
|
|
1836
|
+
"title": r["title"], "url": r["url"], "snippet": r["snippet"],
|
|
1837
|
+
"tier": "memoria", "recalled_from_topic": r["topic"],
|
|
1838
|
+
})
|
|
1839
|
+
research_metrics.log_site_memory(
|
|
1840
|
+
query, threshold, scored_topics, recalled=len(recalled),
|
|
1841
|
+
rerank_scores=rerank_scores, rerank_threshold=SITE_MEMORY_RERANK_MIN_SCORE,
|
|
1842
|
+
)
|
|
1843
|
+
return boost_domains, recalled
|
|
1844
|
+
except Exception as exc:
|
|
1845
|
+
_web_model_warn(f"Site memory failed on this call: {type(exc).__name__}: {exc}"[:300])
|
|
1846
|
+
return set(), []
|
|
1847
|
+
|
|
1848
|
+
|
|
1849
|
+
def _remember_site_results(query, tier_name, raw_results):
|
|
1850
|
+
# Fail-open, mesmo contrato de _record_tier_health.
|
|
1851
|
+
try:
|
|
1852
|
+
for item in raw_results:
|
|
1853
|
+
domain = _domain_of(item.get("url", ""))
|
|
1854
|
+
if not domain:
|
|
1855
|
+
continue
|
|
1856
|
+
site_memory.record(
|
|
1857
|
+
query, domain, tier_name,
|
|
1858
|
+
title=item.get("title", ""), url=item.get("url", ""), snippet=item.get("snippet", ""),
|
|
1859
|
+
)
|
|
1860
|
+
except Exception:
|
|
1861
|
+
pass
|
|
1862
|
+
|
|
1863
|
+
|
|
1864
|
+
def _provider_config_id(tier_name):
|
|
1865
|
+
keys = config.load_config().get("api_keys") or {}
|
|
1866
|
+
blob = keys.get(tier_name, "") if isinstance(keys, dict) else ""
|
|
1867
|
+
return hashlib.sha256(str(blob).encode("utf-8")).digest()
|
|
1868
|
+
|
|
1869
|
+
|
|
1870
|
+
def _provider_circuit_wait(tier_name, config_id):
|
|
1871
|
+
with _web_provider_circuit_lock:
|
|
1872
|
+
state = _web_provider_circuits.get(tier_name)
|
|
1873
|
+
if not state or state[1] != config_id:
|
|
1874
|
+
_web_provider_circuits.pop(tier_name, None)
|
|
1875
|
+
return 0
|
|
1876
|
+
left = state[0] - time.monotonic()
|
|
1877
|
+
if left <= 0:
|
|
1878
|
+
_web_provider_circuits.pop(tier_name, None)
|
|
1879
|
+
return 0
|
|
1880
|
+
return left
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
def _open_provider_circuit(tier_name, config_id, seconds):
|
|
1884
|
+
with _web_provider_circuit_lock:
|
|
1885
|
+
_web_provider_circuits[tier_name] = (
|
|
1886
|
+
time.monotonic() + min(WEB_PROVIDER_CIRCUIT_MAX_S, max(WEB_PROVIDER_CIRCUIT_MIN_S, seconds)),
|
|
1887
|
+
config_id,
|
|
1888
|
+
)
|
|
1889
|
+
|
|
1890
|
+
|
|
1891
|
+
class TierSkipped(RuntimeError):
|
|
1892
|
+
"""Self-imposed pause (provider circuit open): not a failure of the provider."""
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
BROWSER_SERVICE = browser_service_client.BrowserService(web_search_adapters.CAMOUFOX_VENV_PYTHON,
|
|
1896
|
+
web_search_adapters.BROWSER_SERVICE_SCRIPT)
|
|
1897
|
+
BROWSER_CAPTCHA_PAUSE_S = 3600
|
|
1898
|
+
_BROWSER_CIRCUIT_ID = b"browser-service"
|
|
1899
|
+
|
|
1900
|
+
|
|
1901
|
+
def _run_browser_tier(tier_name, adapter, query, num, timeout):
|
|
1902
|
+
wait = _provider_circuit_wait(tier_name, _BROWSER_CIRCUIT_ID)
|
|
1903
|
+
if wait:
|
|
1904
|
+
raise browser_service_client.BrowserSkipped(f"{tier_name}: paused after a captcha for {int(wait) + 1}s more")
|
|
1905
|
+
try:
|
|
1906
|
+
raw_results = BROWSER_SERVICE.search(adapter["service_engine"], query, num, timeout)
|
|
1907
|
+
except browser_service_client.BrowserCaptcha:
|
|
1908
|
+
_open_provider_circuit(tier_name, _BROWSER_CIRCUIT_ID, BROWSER_CAPTCHA_PAUSE_S)
|
|
1909
|
+
raise
|
|
1910
|
+
if not raw_results:
|
|
1911
|
+
raise RuntimeError("zero results")
|
|
1912
|
+
return raw_results[:num]
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
def _run_tier(tier_name, query, num, timeout_s=None):
|
|
1916
|
+
adapter = web_search_adapters.SPECS[tier_name]
|
|
1917
|
+
if "service_engine" in adapter:
|
|
1918
|
+
timeout = adapter["timeout_s"] if timeout_s is None else min(adapter["timeout_s"], timeout_s)
|
|
1919
|
+
return _run_browser_tier(tier_name, adapter, query, num, timeout)
|
|
1920
|
+
config_id = None
|
|
1921
|
+
if tier_name in config.WEB_PROVIDER_NAMES:
|
|
1922
|
+
config_id = _provider_config_id(tier_name)
|
|
1923
|
+
wait = _provider_circuit_wait(tier_name, config_id)
|
|
1924
|
+
if wait:
|
|
1925
|
+
raise TierSkipped(f"{tier_name}: quota backoff for {int(wait) + 1}s more")
|
|
1926
|
+
args = [query, "--num", str(num), *adapter["extra_args"]]
|
|
1927
|
+
timeout = adapter["timeout_s"] if timeout_s is None else min(adapter["timeout_s"], timeout_s)
|
|
1928
|
+
try:
|
|
1929
|
+
raw_results = _run_search_script(adapter["executable"], adapter["script"], args, timeout)
|
|
1930
|
+
except WebProviderRateLimited as exc:
|
|
1931
|
+
if config_id is not None:
|
|
1932
|
+
_open_provider_circuit(tier_name, config_id, exc.retry_after)
|
|
1933
|
+
raise
|
|
1934
|
+
if not raw_results:
|
|
1935
|
+
raise RuntimeError("zero results")
|
|
1936
|
+
return raw_results[:num]
|
|
1937
|
+
|
|
1938
|
+
|
|
1939
|
+
def _merge_diverse_results(collected, num, boost_domains=None):
|
|
1940
|
+
# Round-robin entre as fontes que responderam, não concatenado por ordem de
|
|
1941
|
+
# chegada -- senão a fonte mais rápida preenche os `num` resultados sozinha e as
|
|
1942
|
+
# outras nunca aparecem na resposta, o oposto do propósito do fan-out. `boost_domains`
|
|
1943
|
+
# (memória por site, best-effort) só reordena dentro de cada tier -- a fonte diversa
|
|
1944
|
+
# do round-robin em si nunca é comprometida por causa da memória.
|
|
1945
|
+
if boost_domains:
|
|
1946
|
+
collected = [
|
|
1947
|
+
(tier, sorted(raw, key=lambda item: _domain_of(item.get("url", "")) not in boost_domains))
|
|
1948
|
+
for tier, raw in collected
|
|
1949
|
+
]
|
|
1950
|
+
rank = {tier: i for i, tier in enumerate(web_search_adapters.MERGE_PRIORITY)}
|
|
1951
|
+
collected = sorted(collected, key=lambda pair: rank.get(pair[0], len(rank)))
|
|
1952
|
+
iterators = [iter(raw) for _, raw in collected]
|
|
1953
|
+
tier_names = [tier for tier, _ in collected]
|
|
1954
|
+
seen_urls = set()
|
|
1955
|
+
merged = []
|
|
1956
|
+
active = list(range(len(iterators)))
|
|
1957
|
+
while active and len(merged) < num:
|
|
1958
|
+
for idx in list(active):
|
|
1959
|
+
try:
|
|
1960
|
+
item = next(iterators[idx])
|
|
1961
|
+
except StopIteration:
|
|
1962
|
+
active.remove(idx)
|
|
1963
|
+
continue
|
|
1964
|
+
url = item.get("url", "")
|
|
1965
|
+
if url and url in seen_urls:
|
|
1966
|
+
continue
|
|
1967
|
+
if url:
|
|
1968
|
+
seen_urls.add(url)
|
|
1969
|
+
entry = {
|
|
1970
|
+
"title": item.get("title", ""), "url": url,
|
|
1971
|
+
"snippet": item.get("snippet", ""), "tier": tier_names[idx],
|
|
1972
|
+
}
|
|
1973
|
+
if "recalled_from_topic" in item:
|
|
1974
|
+
entry["recalled_from_topic"] = item["recalled_from_topic"]
|
|
1975
|
+
merged.append(entry)
|
|
1976
|
+
if len(merged) >= num:
|
|
1977
|
+
break
|
|
1978
|
+
return merged
|
|
1979
|
+
|
|
1980
|
+
|
|
1981
|
+
def _fan_out_web_search(query, num, budget_s=None, site_domains=()):
|
|
1982
|
+
# Dispara todo o grupo `fan_out=True` de uma vez em WEB_SEARCH_EXECUTOR (pool
|
|
1983
|
+
# compartilhado, nunca fechado) em vez de num ThreadPoolExecutor local: um `with`
|
|
1984
|
+
# local bloquearia no `__exit__` até o tier mais lento terminar, mesmo depois do
|
|
1985
|
+
# orçamento já ter sido atingido — aqui, o que não responde a tempo continua
|
|
1986
|
+
# rodando sozinho em background (só telemetria, via done_callback) e a resposta
|
|
1987
|
+
# sai sem esperar por ele.
|
|
1988
|
+
futures = {}
|
|
1989
|
+
for tier_name in web_search_adapters.FAN_OUT_TIERS:
|
|
1990
|
+
fut = WEB_SEARCH_EXECUTOR.submit(_run_tier, tier_name, query, num)
|
|
1991
|
+
futures[fut] = tier_name
|
|
1992
|
+
|
|
1993
|
+
def _record_on_done(tier_name):
|
|
1994
|
+
def _cb(fut):
|
|
1995
|
+
try:
|
|
1996
|
+
fut.result()
|
|
1997
|
+
_record_tier_health(tier_name, success=True)
|
|
1998
|
+
except (TierSkipped, browser_service_client.BrowserSkipped):
|
|
1999
|
+
pass
|
|
2000
|
+
except Exception:
|
|
2001
|
+
_record_tier_health(tier_name, success=False)
|
|
2002
|
+
return _cb
|
|
2003
|
+
|
|
2004
|
+
for fut, tier_name in futures.items():
|
|
2005
|
+
fut.add_done_callback(_record_on_done(tier_name))
|
|
2006
|
+
|
|
2007
|
+
wait_s = WEB_SEARCH_FAN_OUT_BUDGET_S if budget_s is None else min(WEB_SEARCH_FAN_OUT_BUDGET_S, budget_s)
|
|
2008
|
+
done, _pending = concurrent.futures.wait(futures.keys(), timeout=wait_s)
|
|
2009
|
+
collected = []
|
|
2010
|
+
errors = []
|
|
2011
|
+
for fut in done:
|
|
2012
|
+
tier_name = futures[fut]
|
|
2013
|
+
try:
|
|
2014
|
+
raw_results = fut.result()
|
|
2015
|
+
raw_results = _filter_site_results(raw_results, site_domains)
|
|
2016
|
+
if not raw_results:
|
|
2017
|
+
continue
|
|
2018
|
+
collected.append((tier_name, raw_results))
|
|
2019
|
+
_remember_site_results(query, tier_name, raw_results)
|
|
2020
|
+
except Exception as exc:
|
|
2021
|
+
errors.append(_sanitize_text(f"{tier_name}: {type(exc).__name__}: {exc}"))
|
|
2022
|
+
return collected, errors
|
|
2023
|
+
|
|
2024
|
+
|
|
2025
|
+
WEB_SEARCH_FAILED_KEY = "tool:web_search"
|
|
2026
|
+
|
|
2027
|
+
|
|
2028
|
+
def _handle_web_search(arguments):
|
|
2029
|
+
return contextvars.copy_context().run(_handle_web_search_isolated, arguments)
|
|
2030
|
+
|
|
2031
|
+
|
|
2032
|
+
def _handle_web_search_isolated(arguments):
|
|
2033
|
+
try:
|
|
2034
|
+
return _handle_web_search_body(arguments)
|
|
2035
|
+
except Exception as exc:
|
|
2036
|
+
web_fetch.mark_failed(WEB_SEARCH_FAILED_KEY)
|
|
2037
|
+
trace = _WEB_TRACE.get()
|
|
2038
|
+
if trace is not None and "started" in trace:
|
|
2039
|
+
started = trace.pop("started")
|
|
2040
|
+
_metric("web_search", error=type(exc).__name__, elapsed_s=round(time.monotonic() - started, 2), **trace)
|
|
2041
|
+
raise
|
|
2042
|
+
|
|
2043
|
+
|
|
2044
|
+
def _handle_web_search_body(arguments):
|
|
2045
|
+
query = arguments.get("query")
|
|
2046
|
+
if not query:
|
|
2047
|
+
raise ValueError("web_search requires 'query'")
|
|
2048
|
+
if len(query) > WEB_SEARCH_MAX_QUERY_CHARS:
|
|
2049
|
+
raise ValueError(f"'query' too long ({len(query)} characters) — maximum {WEB_SEARCH_MAX_QUERY_CHARS}")
|
|
2050
|
+
scope = arguments.get("scope")
|
|
2051
|
+
depth = arguments.get("depth")
|
|
2052
|
+
num = _parse_bounded_int(arguments.get("num"), default=8, min_v=1, max_v=20, field_name="num")
|
|
2053
|
+
query_en = arguments.get("query_en")
|
|
2054
|
+
if depth in (None, "quick"):
|
|
2055
|
+
if not isinstance(query_en, str) or not query_en.strip():
|
|
2056
|
+
raise ValueError("web_search with depth='quick' requires 'query_en': the query in English "
|
|
2057
|
+
"(repeat query if it is already in English)")
|
|
2058
|
+
if len(query_en) > WEB_SEARCH_MAX_QUERY_CHARS:
|
|
2059
|
+
raise ValueError(f"'query_en' too long ({len(query_en)} characters) — maximum {WEB_SEARCH_MAX_QUERY_CHARS}")
|
|
2060
|
+
|
|
2061
|
+
if depth in ("deep", "research"):
|
|
2062
|
+
_require_valid_credential()
|
|
2063
|
+
if depth == "deep":
|
|
2064
|
+
# Modo determinístico por código, não decidido por modelo: profundidade
|
|
2065
|
+
# "deep" é sempre assíncrona e sempre Tier 4 — ver ADR 0006.
|
|
2066
|
+
job_id = uuid.uuid4().hex
|
|
2067
|
+
_set_job(job_id, status="pending", result=None, error=None, created_at=time.time())
|
|
2068
|
+
try:
|
|
2069
|
+
DEEP_JOB_EXECUTOR.submit(_run_deep_job, job_id, query, scope)
|
|
2070
|
+
except Exception as exc:
|
|
2071
|
+
# Se o submit() em si falhar (pool fechado/saturado), o job já foi
|
|
2072
|
+
# registrado como "pending" — sem isto, ficaria assim pra sempre,
|
|
2073
|
+
# já que nenhum worker nunca chegaria a chamar _set_job de novo.
|
|
2074
|
+
_set_job(job_id, status="error", result=None, error=_sanitize_text(f"{type(exc).__name__}: {exc}"), created_at=time.time())
|
|
2075
|
+
return _to_json_sanitized({"job_id": job_id, "status": "pending"})
|
|
2076
|
+
|
|
2077
|
+
if depth == "research":
|
|
2078
|
+
job_id = uuid.uuid4().hex
|
|
2079
|
+
_set_job(job_id, status="pending", result=None, error=None, created_at=time.time())
|
|
2080
|
+
try:
|
|
2081
|
+
RESEARCH_JOB_EXECUTOR.submit(_run_research_job, job_id, query, num, scope)
|
|
2082
|
+
except Exception as exc:
|
|
2083
|
+
_set_job(job_id, status="error", result=None, error=_sanitize_text(f"{type(exc).__name__}: {exc}"), created_at=time.time())
|
|
2084
|
+
return _to_json_sanitized({"job_id": job_id, "status": "pending"})
|
|
2085
|
+
|
|
2086
|
+
warnings = []
|
|
2087
|
+
_WEB_WARNINGS.set(warnings)
|
|
2088
|
+
_WEB_TRACE.set({"started": time.monotonic(), "cache": "fresh", "probe": False,
|
|
2089
|
+
"bilingual": _normalized(query_en) != _normalized(query)})
|
|
2090
|
+
problem = _gateway_problem()
|
|
2091
|
+
_web_trace(degraded=bool(problem))
|
|
2092
|
+
if problem:
|
|
2093
|
+
model_client.OFFLINE.set(problem)
|
|
2094
|
+
warnings.append("Model gateway unavailable: web sources only, no coverage assessment, "
|
|
2095
|
+
"no similarity reuse and no volatility judgment. " + problem)
|
|
2096
|
+
cfg = config.load_config()
|
|
2097
|
+
timeout_s = config.clamp_quick_search_timeout(cfg.get("quick_search_timeout_s"))
|
|
2098
|
+
deadline = time.monotonic() + timeout_s
|
|
2099
|
+
cache_query = query if _normalized(query_en) == _normalized(query) else f"{query} | {query_en}"
|
|
2100
|
+
try:
|
|
2101
|
+
cached, topic_vector = _validated_cache_result(
|
|
2102
|
+
"quick", cache_query, scope, cfg, "" if problem else model_client.get_token(), deadline, requested_num=num,
|
|
2103
|
+
)
|
|
2104
|
+
except Exception as exc:
|
|
2105
|
+
_log(f"research cache unavailable on this call: {type(exc).__name__}: {exc}")
|
|
2106
|
+
_web_warn(f"Research cache unavailable on this call: {type(exc).__name__}: {exc}"[:300])
|
|
2107
|
+
cached, topic_vector = None, None
|
|
2108
|
+
if cached:
|
|
2109
|
+
entry, status = cached
|
|
2110
|
+
_web_trace(cache=status, volatility=(entry.get("classification") or {}).get("volatility"))
|
|
2111
|
+
return _quick_output(_annotate_quick_results(
|
|
2112
|
+
entry["result"]["rows"][:num], entry["classification"], _cache_trace(entry, status),
|
|
2113
|
+
), warnings)
|
|
2114
|
+
direct_security = freshness.npm_version_security_only(query)
|
|
2115
|
+
if freshness.npm_version_only(query) or direct_security:
|
|
2116
|
+
evidence = freshness.npm_registry_evidence(freshness.npm_package(query))
|
|
2117
|
+
if evidence:
|
|
2118
|
+
results = [evidence]
|
|
2119
|
+
if direct_security:
|
|
2120
|
+
osv = freshness.npm_osv_evidence(evidence["npm_package"], evidence["npm_version"])
|
|
2121
|
+
if osv:
|
|
2122
|
+
results.append(osv)
|
|
2123
|
+
if not direct_security or (len(results) == 2 and num >= 2):
|
|
2124
|
+
results = results[:num]
|
|
2125
|
+
classification = _store_fresh_research(
|
|
2126
|
+
"quick", cache_query, scope, {"rows": results, "requested_num": num},
|
|
2127
|
+
results, cfg, topic_vector,
|
|
2128
|
+
)
|
|
2129
|
+
return _quick_output(_annotate_quick_results(results, classification), warnings)
|
|
2130
|
+
results = _bilingual_search(query, query_en, num, scope, deadline)
|
|
2131
|
+
results = _with_npm_evidence(query, results, num)
|
|
2132
|
+
classification = _store_fresh_research(
|
|
2133
|
+
"quick", cache_query, scope, {"rows": results, "requested_num": num}, results, cfg,
|
|
2134
|
+
topic_vector,
|
|
2135
|
+
)
|
|
2136
|
+
return _quick_output(_annotate_quick_results(results, classification), warnings)
|
|
2137
|
+
|
|
2138
|
+
|
|
2139
|
+
def _quick_output(rows, warnings):
|
|
2140
|
+
if not rows:
|
|
2141
|
+
web_fetch.mark_failed(WEB_SEARCH_FAILED_KEY)
|
|
2142
|
+
trace = _WEB_TRACE.get()
|
|
2143
|
+
if trace is not None:
|
|
2144
|
+
started = trace.pop("started")
|
|
2145
|
+
_metric("web_search", warnings=len(warnings), elapsed_s=round(time.monotonic() - started, 2), **trace)
|
|
2146
|
+
lines = ["results:" if rows else "results: []"]
|
|
2147
|
+
lines += [" " + line for line in _to_yaml(rows).rstrip("\n").split("\n")] if rows else []
|
|
2148
|
+
if warnings:
|
|
2149
|
+
lines += ["warnings:"] + [f" - {_yaml_scalar(warning)}" for warning in warnings]
|
|
2150
|
+
return "\n".join(lines) + "\n"
|
|
2151
|
+
|
|
2152
|
+
|
|
2153
|
+
def _research_time_left(deadline):
|
|
2154
|
+
if deadline is None:
|
|
2155
|
+
return None
|
|
2156
|
+
left = deadline - time.monotonic()
|
|
2157
|
+
if left <= 0:
|
|
2158
|
+
raise TimeoutError("total research time exhausted")
|
|
2159
|
+
return left
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
def _web_results_need_more(query, results, num, scope, model, assess_semantic, deadline=None):
|
|
2163
|
+
"""Decide se outra fonte acrescentaria cobertura útil à busca rápida."""
|
|
2164
|
+
if not results:
|
|
2165
|
+
return True
|
|
2166
|
+
domains = {_domain_of(row.get("url", "")) for row in results}
|
|
2167
|
+
domains.discard("")
|
|
2168
|
+
if sum(bool((row.get("snippet") or "").strip()) for row in results) < max(1, len(results) // 2):
|
|
2169
|
+
return True
|
|
2170
|
+
if assess_semantic and model:
|
|
2171
|
+
try:
|
|
2172
|
+
left = _research_time_left(deadline)
|
|
2173
|
+
if left is not None and left < 0.5:
|
|
2174
|
+
raise TimeoutError("search assessment deadline exhausted")
|
|
2175
|
+
need_more, _reason = router.assess_web_search_results(
|
|
2176
|
+
query, results, num, scope, model, timeout=min(12, left) if left else 12,
|
|
2177
|
+
)
|
|
2178
|
+
return need_more
|
|
2179
|
+
except Exception as exc:
|
|
2180
|
+
_web_model_warn(f"Model coverage assessment unavailable; mechanical criterion used: {type(exc).__name__}: {exc}"[:300])
|
|
2181
|
+
# `num` é teto, não uma meta obrigatória. Em research o pesquisador e o
|
|
2182
|
+
# crítico julgam cobertura e diversidade depois da busca; exigir dois
|
|
2183
|
+
# domínios aqui nunca terminaria uma consulta restrita por `site:`.
|
|
2184
|
+
minimum = min(num, 3 if assess_semantic else 2)
|
|
2185
|
+
return len(results) < minimum or (assess_semantic and len(domains) < min(num, 2))
|
|
2186
|
+
|
|
2187
|
+
|
|
2188
|
+
def _memory_snapshot():
|
|
2189
|
+
try:
|
|
2190
|
+
return site_memory.snapshot()
|
|
2191
|
+
except Exception:
|
|
2192
|
+
return []
|
|
2193
|
+
|
|
2194
|
+
|
|
2195
|
+
def _search_web_once(query, num, scope, deadline=None, assess_semantic=False,
|
|
2196
|
+
site_domains=None, allow_recall=True, memory_before=None, single_browser=False):
|
|
2197
|
+
"""Busca uma rodada; amplia fontes só quando o resultado segue insuficiente."""
|
|
2198
|
+
# O fan-out grava achados em site_memory. Congelar o estado antes dele impede
|
|
2199
|
+
# comparar a query com os próprios resultados recém-gravados (score 1,0).
|
|
2200
|
+
if memory_before is None:
|
|
2201
|
+
memory_before = _memory_snapshot()
|
|
2202
|
+
domains = _site_domains_from_query(query) if site_domains is None else tuple(site_domains)
|
|
2203
|
+
fanout_options = {"budget_s": _research_time_left(deadline)}
|
|
2204
|
+
if domains:
|
|
2205
|
+
fanout_options["site_domains"] = domains
|
|
2206
|
+
collected, errors = _fan_out_web_search(query, num, **fanout_options)
|
|
2207
|
+
if domains:
|
|
2208
|
+
collected = [(tier, filtered) for tier, raw in collected
|
|
2209
|
+
if (filtered := _filter_site_results(raw, domains))]
|
|
2210
|
+
try:
|
|
2211
|
+
_research_time_left(deadline)
|
|
2212
|
+
except TimeoutError:
|
|
2213
|
+
if collected:
|
|
2214
|
+
return _merge_diverse_results(collected, num)
|
|
2215
|
+
raise
|
|
2216
|
+
correlation_options = {"deadline": deadline} if deadline is not None else {}
|
|
2217
|
+
if not allow_recall:
|
|
2218
|
+
correlation_options["include_content"] = False
|
|
2219
|
+
boost_domains, recalled = _site_memory_correlate(query, memory_before, **correlation_options)
|
|
2220
|
+
recalled = _filter_site_results(recalled, domains)
|
|
2221
|
+
if recalled and allow_recall:
|
|
2222
|
+
# `recalled` entra como tier "memoria" no round-robin/dedup de _merge_diverse_results.
|
|
2223
|
+
collected.append(("memoria", recalled))
|
|
2224
|
+
results = _merge_diverse_results(collected, num, boost_domains) if collected else []
|
|
2225
|
+
cfg = config.load_config()
|
|
2226
|
+
router_model = cfg.get("router_model")
|
|
2227
|
+
if not _web_results_need_more(query, results, num, scope, router_model, assess_semantic, deadline):
|
|
2228
|
+
return results
|
|
2229
|
+
|
|
2230
|
+
# Chave opcional: os adaptadores escolhem keyless quando nenhuma foi configurada.
|
|
2231
|
+
# Provedores com cota por IP não participam do fan-out em toda chamada.
|
|
2232
|
+
for tier_name in web_search_adapters.HTTP_FALLBACK_TIERS:
|
|
2233
|
+
try:
|
|
2234
|
+
left = _research_time_left(deadline)
|
|
2235
|
+
except TimeoutError:
|
|
2236
|
+
if results:
|
|
2237
|
+
return results
|
|
2238
|
+
raise
|
|
2239
|
+
try:
|
|
2240
|
+
raw_results = _filter_site_results(
|
|
2241
|
+
_run_tier(tier_name, query, num, timeout_s=left), domains,
|
|
2242
|
+
)
|
|
2243
|
+
if not raw_results:
|
|
2244
|
+
raise RuntimeError("zero results in the requested domain")
|
|
2245
|
+
except Exception as exc:
|
|
2246
|
+
if not isinstance(exc, TierSkipped):
|
|
2247
|
+
_record_tier_health(tier_name, success=False)
|
|
2248
|
+
errors.append(_sanitize_text(f"{tier_name}: {type(exc).__name__}: {exc}"))
|
|
2249
|
+
continue
|
|
2250
|
+
_record_tier_health(tier_name, success=True)
|
|
2251
|
+
_remember_site_results(query, tier_name, raw_results)
|
|
2252
|
+
collected.insert(0, (tier_name, raw_results))
|
|
2253
|
+
results = _merge_diverse_results(collected, num, boost_domains)
|
|
2254
|
+
if not _web_results_need_more(query, results, num, scope, router_model, assess_semantic, deadline):
|
|
2255
|
+
return results
|
|
2256
|
+
|
|
2257
|
+
# Navegadores só entram se as fontes HTTP ainda não cobrem a consulta.
|
|
2258
|
+
try:
|
|
2259
|
+
left = _research_time_left(deadline)
|
|
2260
|
+
except TimeoutError:
|
|
2261
|
+
if results:
|
|
2262
|
+
return results
|
|
2263
|
+
raise
|
|
2264
|
+
# Quick mode keeps camoufox first: a startpage chosen first runs until the deadline when it fails.
|
|
2265
|
+
browser_model = router_model if not single_browser and (left is None or left > 45) else None
|
|
2266
|
+
tier_order = _web_search_tier_order(query, scope, browser_model, web_search_adapters.BROWSER_FALLBACK_TIERS)
|
|
2267
|
+
for tier_name in tier_order:
|
|
2268
|
+
try:
|
|
2269
|
+
left = _research_time_left(deadline)
|
|
2270
|
+
except TimeoutError:
|
|
2271
|
+
if results:
|
|
2272
|
+
return results
|
|
2273
|
+
raise
|
|
2274
|
+
try:
|
|
2275
|
+
raw_results = _filter_site_results(
|
|
2276
|
+
_run_tier(tier_name, query, num, timeout_s=left), domains,
|
|
2277
|
+
)
|
|
2278
|
+
if not raw_results:
|
|
2279
|
+
raise RuntimeError("zero results in the requested domain")
|
|
2280
|
+
except Exception as exc:
|
|
2281
|
+
if not isinstance(exc, (TierSkipped, browser_service_client.BrowserSkipped)):
|
|
2282
|
+
_record_tier_health(tier_name, success=False)
|
|
2283
|
+
errors.append(_sanitize_text(f"{tier_name}: {type(exc).__name__}: {exc}"))
|
|
2284
|
+
continue
|
|
2285
|
+
_record_tier_health(tier_name, success=True)
|
|
2286
|
+
_remember_site_results(query, tier_name, raw_results)
|
|
2287
|
+
collected.insert(0, (tier_name, raw_results))
|
|
2288
|
+
results = _merge_diverse_results(collected, num, boost_domains)
|
|
2289
|
+
# Quick mode: a failing browser runs until the deadline (83-99 s measured), so the next one only covers a failure.
|
|
2290
|
+
if single_browser or not _web_results_need_more(query, results, num, scope, router_model, assess_semantic, deadline):
|
|
2291
|
+
return results
|
|
2292
|
+
|
|
2293
|
+
if results:
|
|
2294
|
+
return results
|
|
2295
|
+
|
|
2296
|
+
raise RuntimeError("All search tiers failed - " + "; ".join(errors))
|
|
2297
|
+
|
|
2298
|
+
|
|
2299
|
+
def _cache_namespace(depth, scope):
|
|
2300
|
+
normalized = " ".join(str(scope or "").casefold().split())
|
|
2301
|
+
return depth + "|" + hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
|
2302
|
+
|
|
2303
|
+
|
|
2304
|
+
def _cache_match(depth, query, scope, cfg, token, deadline):
|
|
2305
|
+
"""Exato primeiro; aproximação semântica exige confirmação do mesmo fato."""
|
|
2306
|
+
entries = research_cache.candidates(_cache_namespace(depth, scope), query)
|
|
2307
|
+
if not entries:
|
|
2308
|
+
return None, None
|
|
2309
|
+
normalized = _normalized(query)
|
|
2310
|
+
for entry in entries:
|
|
2311
|
+
if _normalized(entry.get("query")) == normalized:
|
|
2312
|
+
stored = entry.get("topic_vector") or {}
|
|
2313
|
+
return entry, (stored.get("values") if stored.get("model") == cfg.get("embedding_model") else None)
|
|
2314
|
+
if freshness.npm_version_only(query) or freshness.npm_version_security_only(query):
|
|
2315
|
+
package = freshness.npm_package(query)
|
|
2316
|
+
wants_security = freshness.npm_security_intent(query)
|
|
2317
|
+
for entry in entries:
|
|
2318
|
+
classified = entry.get("classification") or {}
|
|
2319
|
+
if (classified.get("npm_package") == package
|
|
2320
|
+
and bool(classified.get("security_intent")) == wants_security):
|
|
2321
|
+
return entry, None
|
|
2322
|
+
if model_client.OFFLINE.get():
|
|
2323
|
+
return None, None
|
|
2324
|
+
embed_model, rerank_model = cfg.get("embedding_model"), cfg.get("rerank_model")
|
|
2325
|
+
match_model = cfg.get("router_model") or cfg.get("research_model")
|
|
2326
|
+
if not embed_model or not rerank_model or not match_model:
|
|
2327
|
+
missing = [name for name, value in (("embedding_model", embed_model), ("rerank_model", rerank_model),
|
|
2328
|
+
("router_model", match_model)) if not value]
|
|
2329
|
+
_web_model_warn("Reuse of similar searches off (only the same question is reused): missing " + ", ".join(missing) + ".")
|
|
2330
|
+
return None, None
|
|
2331
|
+
entries = [entry for entry in entries
|
|
2332
|
+
if (entry.get("topic_vector") or {}).get("model") == embed_model]
|
|
2333
|
+
if not entries:
|
|
2334
|
+
return None, None
|
|
2335
|
+
try:
|
|
2336
|
+
vector = _embed(embed_model, [query], token, deadline=deadline)[0]
|
|
2337
|
+
scored = sorted(((indexer.cosine_similarity(vector, entry["topic_vector"]["values"]), i)
|
|
2338
|
+
for i, entry in enumerate(entries)), reverse=True)
|
|
2339
|
+
candidates = [i for score, i in scored[:10] if score >= 0.45]
|
|
2340
|
+
if not candidates:
|
|
2341
|
+
return None, vector
|
|
2342
|
+
topics = [str(entries[i].get("query") or "") for i in candidates]
|
|
2343
|
+
ranked = _rerank(rerank_model, query, topics, min(3, len(candidates)), token, deadline=deadline)
|
|
2344
|
+
for row in ranked[:3]:
|
|
2345
|
+
index = row.get("index")
|
|
2346
|
+
if not isinstance(index, int) or not 0 <= index < len(candidates):
|
|
2347
|
+
continue
|
|
2348
|
+
if float(row.get("relevance_score") or 0) < 0.12:
|
|
2349
|
+
continue
|
|
2350
|
+
entry = entries[candidates[index]]
|
|
2351
|
+
if freshness.same_fact(query, entry["query"],
|
|
2352
|
+
entry.get("classification", {}).get("subject", ""), match_model,
|
|
2353
|
+
timeout=min(12, _research_time_left(deadline))):
|
|
2354
|
+
return entry, vector
|
|
2355
|
+
except Exception as exc:
|
|
2356
|
+
_log(f"semantic cache match failed: {type(exc).__name__}: {exc}")
|
|
2357
|
+
_web_model_warn(f"Reuse of similar searches failed on this call: {type(exc).__name__}: {exc}"[:300])
|
|
2358
|
+
return None, None
|
|
2359
|
+
return None, vector
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
def _bilingual_search(query, query_en, num, scope, deadline):
|
|
2363
|
+
if _normalized(query_en) == _normalized(query):
|
|
2364
|
+
return _search_web_once(query, num, scope, deadline=deadline, assess_semantic=True, allow_recall=False,
|
|
2365
|
+
single_browser=True)
|
|
2366
|
+
domains = tuple(dict.fromkeys(_site_domains_from_query(query) + _site_domains_from_query(query_en))) or None
|
|
2367
|
+
options = {"deadline": deadline, "assess_semantic": True, "allow_recall": False, "single_browser": True,
|
|
2368
|
+
"site_domains": domains, "memory_before": _memory_snapshot()}
|
|
2369
|
+
if domains:
|
|
2370
|
+
query, query_en = _with_site_operator(query, domains), _with_site_operator(query_en, domains)
|
|
2371
|
+
context = contextvars.copy_context()
|
|
2372
|
+
english = WEB_LANG_EXECUTOR.submit(context.run, _search_web_once, query_en, num, scope, **options)
|
|
2373
|
+
original_error = english_error = None
|
|
2374
|
+
try:
|
|
2375
|
+
original = _search_web_once(query, num, scope, **options)
|
|
2376
|
+
except Exception as exc:
|
|
2377
|
+
original, original_error = [], exc
|
|
2378
|
+
try:
|
|
2379
|
+
translated = english.result()
|
|
2380
|
+
except Exception as exc:
|
|
2381
|
+
translated, english_error = [], exc
|
|
2382
|
+
if original_error and english_error:
|
|
2383
|
+
raise RuntimeError(f"search failed in both languages — original: {original_error}; English: {english_error}")
|
|
2384
|
+
if original_error or english_error:
|
|
2385
|
+
failed, error = ("original", original_error) if original_error else ("English", english_error)
|
|
2386
|
+
_log(f"{failed} search failed; results from the other language only: {type(error).__name__}: {error}")
|
|
2387
|
+
_web_warn(f"{failed} search failed; results from the other language only: {type(error).__name__}: {error}"[:300])
|
|
2388
|
+
merged, seen = [], set()
|
|
2389
|
+
for pair in itertools.zip_longest(translated, original):
|
|
2390
|
+
for row in pair:
|
|
2391
|
+
if row and (not row.get("url") or row["url"] not in seen):
|
|
2392
|
+
seen.add(row.get("url"))
|
|
2393
|
+
merged.append(row)
|
|
2394
|
+
return merged[:num]
|
|
2395
|
+
|
|
2396
|
+
|
|
2397
|
+
def _validated_cache_result(depth, query, scope, cfg, token, deadline, requested_num=None):
|
|
2398
|
+
"""Devolve ((entrada, status) ou None, vetor da consulta) para reaproveitar na gravação."""
|
|
2399
|
+
entry, topic_vector = _cache_match(depth, query, scope, cfg, token, deadline)
|
|
2400
|
+
if not entry:
|
|
2401
|
+
return None, topic_vector
|
|
2402
|
+
if (depth == "quick" and requested_num is not None
|
|
2403
|
+
and int(entry.get("result", {}).get("requested_num") or 0) < requested_num):
|
|
2404
|
+
return None, topic_vector
|
|
2405
|
+
pending = (entry.get("classification") or {}).get("volatility") == "pending"
|
|
2406
|
+
if (pending and not model_client.OFFLINE.get() and time.time() - entry["saved_at"] > WEB_CACHE_PENDING_GRACE_S
|
|
2407
|
+
and _claim_completion(entry["id"], entry["saved_at"])):
|
|
2408
|
+
sources = entry["result"].get("rows") if depth == "quick" else entry["result"].get("sources")
|
|
2409
|
+
context = contextvars.copy_context()
|
|
2410
|
+
WEB_CACHE_EXECUTOR.submit(context.run, _complete_research, _cache_namespace(depth, scope), entry["query"],
|
|
2411
|
+
entry["saved_at"], sources or [], entry["classification"], cfg, None)
|
|
2412
|
+
hit = _revalidated(depth, entry, cfg, deadline)
|
|
2413
|
+
if hit and topic_vector is not None and not pending and _normalized(entry["query"]) != _normalized(query):
|
|
2414
|
+
research_cache.put(_cache_namespace(depth, scope), query, entry["result"], entry["classification"],
|
|
2415
|
+
cfg.get("embedding_model"), topic_vector, origin=entry)
|
|
2416
|
+
return hit, topic_vector
|
|
2417
|
+
|
|
2418
|
+
|
|
2419
|
+
def _normalized(text):
|
|
2420
|
+
return " ".join(str(text or "").casefold().split())
|
|
2421
|
+
|
|
2422
|
+
|
|
2423
|
+
def _mark_checked(entry, status):
|
|
2424
|
+
research_cache.mark_validated(entry["id"])
|
|
2425
|
+
entry["validated_at"] = time.time()
|
|
2426
|
+
return entry, status
|
|
2427
|
+
|
|
2428
|
+
|
|
2429
|
+
def _cache_trace(entry, status):
|
|
2430
|
+
stamp = lambda ts: time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
|
|
2431
|
+
return {"status": status, "searched_at": stamp(entry["saved_at"]),
|
|
2432
|
+
"checked_at": stamp(entry["validated_at"]),
|
|
2433
|
+
"cached_query": entry.get("origin_query") or entry["query"]}
|
|
2434
|
+
|
|
2435
|
+
|
|
2436
|
+
def _revalidated(depth, entry, cfg, deadline):
|
|
2437
|
+
classification = entry.get("classification") or {}
|
|
2438
|
+
level = classification.get("volatility", "high")
|
|
2439
|
+
ttl = freshness.TTL_SECONDS.get(level, 0)
|
|
2440
|
+
if ttl and time.time() - entry["validated_at"] < ttl:
|
|
2441
|
+
return entry, "within_ttl"
|
|
2442
|
+
npm_package = classification.get("npm_package")
|
|
2443
|
+
npm_version = classification.get("npm_version")
|
|
2444
|
+
if npm_package and npm_version:
|
|
2445
|
+
current = freshness.npm_registry_evidence(npm_package)
|
|
2446
|
+
if current:
|
|
2447
|
+
if current["npm_version"] != npm_version:
|
|
2448
|
+
research_cache.discard(entry["id"])
|
|
2449
|
+
return None
|
|
2450
|
+
if not classification.get("security_intent"):
|
|
2451
|
+
return _mark_checked(entry, "registry_verified")
|
|
2452
|
+
baseline = classification.get("npm_advisories")
|
|
2453
|
+
if baseline is not None:
|
|
2454
|
+
current_ids = freshness.npm_advisories(npm_package, npm_version)
|
|
2455
|
+
if current_ids is not None:
|
|
2456
|
+
if current_ids == baseline:
|
|
2457
|
+
return _mark_checked(entry, "registry_osv_verified")
|
|
2458
|
+
research_cache.discard(entry["id"])
|
|
2459
|
+
return None
|
|
2460
|
+
if model_client.OFFLINE.get():
|
|
2461
|
+
return None
|
|
2462
|
+
probe_query = str(classification.get("validation_query") or "").strip()
|
|
2463
|
+
if not probe_query:
|
|
2464
|
+
return None
|
|
2465
|
+
domains = _site_domains_from_query(entry["query"])
|
|
2466
|
+
probe_query = _with_site_operator(probe_query, domains)[:WEB_SEARCH_MAX_QUERY_CHARS]
|
|
2467
|
+
try:
|
|
2468
|
+
probe_deadline = min(deadline, time.monotonic() + 45)
|
|
2469
|
+
_web_trace(probe=True)
|
|
2470
|
+
fresh = _search_web_once(
|
|
2471
|
+
probe_query, 5, None, deadline=probe_deadline, assess_semantic=False,
|
|
2472
|
+
site_domains=domains or None, allow_recall=False,
|
|
2473
|
+
)
|
|
2474
|
+
cached_content = entry["result"].get("rows", []) if depth == "quick" else entry["result"]
|
|
2475
|
+
model = cfg.get("router_model") or cfg.get("research_model")
|
|
2476
|
+
verdict, reason = freshness.validate(entry["query"], cached_content,
|
|
2477
|
+
classification, fresh, model,
|
|
2478
|
+
timeout=min(12, _research_time_left(deadline)))
|
|
2479
|
+
except Exception as exc:
|
|
2480
|
+
_log(f"change check failed; searching again: {type(exc).__name__}: {exc}")
|
|
2481
|
+
_web_model_warn(f"Check of the cached research failed; search redone: {type(exc).__name__}: {exc}"[:300])
|
|
2482
|
+
return None
|
|
2483
|
+
_web_trace(verdict=verdict)
|
|
2484
|
+
if verdict == "current":
|
|
2485
|
+
return _mark_checked(entry, "revalidated")
|
|
2486
|
+
_log(f"change check: {verdict}; searching again ({reason})")
|
|
2487
|
+
if verdict == "changed":
|
|
2488
|
+
research_cache.discard(entry["id"])
|
|
2489
|
+
return None
|
|
2490
|
+
|
|
2491
|
+
|
|
2492
|
+
def _store_fresh_research(depth, query, scope, result, sources, cfg, topic_vector=None):
|
|
2493
|
+
"""Grava já com volatilidade pendente; o julgamento e o vetor completam em segundo plano."""
|
|
2494
|
+
if not sources:
|
|
2495
|
+
return None
|
|
2496
|
+
classification = freshness.pending_classification(query)
|
|
2497
|
+
npm_source = next((row for row in sources if row.get("tier") == "npm_registry"), None)
|
|
2498
|
+
if npm_source:
|
|
2499
|
+
classification["npm_package"] = npm_source["npm_package"]
|
|
2500
|
+
classification["npm_version"] = npm_source["npm_version"]
|
|
2501
|
+
classification["security_intent"] = freshness.npm_security_intent(query)
|
|
2502
|
+
if classification["security_intent"]:
|
|
2503
|
+
osv_source = next((row for row in sources if row.get("tier") == "osv_api"), None)
|
|
2504
|
+
classification["npm_advisories"] = (osv_source["osv_advisory_ids"]
|
|
2505
|
+
if osv_source else None)
|
|
2506
|
+
namespace = _cache_namespace(depth, scope)
|
|
2507
|
+
embed_model = cfg.get("embedding_model")
|
|
2508
|
+
saved_at = research_cache.put(namespace, query, result, classification,
|
|
2509
|
+
embed_model if topic_vector else None, topic_vector)
|
|
2510
|
+
if saved_at is not None and not model_client.OFFLINE.get():
|
|
2511
|
+
context = contextvars.copy_context()
|
|
2512
|
+
WEB_CACHE_EXECUTOR.submit(context.run, _complete_research, namespace, query, saved_at,
|
|
2513
|
+
sources, classification, cfg, topic_vector)
|
|
2514
|
+
return classification
|
|
2515
|
+
|
|
2516
|
+
|
|
2517
|
+
_COMPLETING = set()
|
|
2518
|
+
_COMPLETING_LOCK = threading.Lock()
|
|
2519
|
+
|
|
2520
|
+
|
|
2521
|
+
def _claim_completion(entry_id, saved_at):
|
|
2522
|
+
with _COMPLETING_LOCK:
|
|
2523
|
+
if (entry_id, saved_at) in _COMPLETING:
|
|
2524
|
+
return False
|
|
2525
|
+
_COMPLETING.add((entry_id, saved_at))
|
|
2526
|
+
return True
|
|
2527
|
+
|
|
2528
|
+
|
|
2529
|
+
def _complete_research(namespace, query, saved_at, sources, pending, cfg, topic_vector):
|
|
2530
|
+
model = cfg.get("router_model") or cfg.get("research_model")
|
|
2531
|
+
classification = freshness.classify(query, sources, model, timeout=WEB_CACHE_CLASSIFY_TIMEOUT_S)
|
|
2532
|
+
if classification.get("error"):
|
|
2533
|
+
_log(f"volatility not judged; reuse requires a check: {classification['error']}")
|
|
2534
|
+
classification.update({key: value for key, value in pending.items()
|
|
2535
|
+
if key.startswith("npm_") or key == "security_intent"})
|
|
2536
|
+
embed_model = cfg.get("embedding_model")
|
|
2537
|
+
if topic_vector is None and embed_model:
|
|
2538
|
+
try:
|
|
2539
|
+
topic_vector = _embed(embed_model, [query], model_client.get_token())[0]
|
|
2540
|
+
except Exception as exc:
|
|
2541
|
+
_log(f"research vector not generated; reuse by exact text only: {type(exc).__name__}: {exc}")
|
|
2542
|
+
if not research_cache.complete(namespace, query, saved_at, classification,
|
|
2543
|
+
embed_model if topic_vector else None, topic_vector):
|
|
2544
|
+
_log("research classification not saved to the cache (entry replaced or cache unavailable)")
|
|
2545
|
+
|
|
2546
|
+
|
|
2547
|
+
def _with_npm_evidence(query, results, num):
|
|
2548
|
+
package = freshness.npm_package(query)
|
|
2549
|
+
if not package:
|
|
2550
|
+
return results
|
|
2551
|
+
evidence = freshness.npm_registry_evidence(package)
|
|
2552
|
+
if not evidence:
|
|
2553
|
+
return results
|
|
2554
|
+
domains = _site_domains_from_query(query)
|
|
2555
|
+
if domains and not _url_in_site_domains(evidence["url"], domains):
|
|
2556
|
+
return results
|
|
2557
|
+
official = [evidence]
|
|
2558
|
+
if freshness.npm_security_intent(query):
|
|
2559
|
+
osv = freshness.npm_osv_evidence(package, evidence["npm_version"])
|
|
2560
|
+
if osv:
|
|
2561
|
+
official.append(osv)
|
|
2562
|
+
if domains:
|
|
2563
|
+
official = [row for row in official if _url_in_site_domains(row["url"], domains)]
|
|
2564
|
+
seen = {row["url"] for row in official}
|
|
2565
|
+
return official[:num] + [row for row in results if row.get("url") not in seen][:max(0, num - len(official))]
|
|
2566
|
+
|
|
2567
|
+
|
|
2568
|
+
def _annotate_quick_results(rows, classification, cache=None):
|
|
2569
|
+
level = (classification or {}).get("volatility")
|
|
2570
|
+
labels = {item.get("url"): item.get("volatility")
|
|
2571
|
+
for item in (classification or {}).get("items", [])}
|
|
2572
|
+
result = []
|
|
2573
|
+
for row in rows:
|
|
2574
|
+
item = dict(row)
|
|
2575
|
+
if "osv_advisory_ids" in item:
|
|
2576
|
+
ids = item.pop("osv_advisory_ids")
|
|
2577
|
+
item["osv_advisory_count"] = len(ids) if isinstance(ids, list) else 0
|
|
2578
|
+
if level and level != "pending":
|
|
2579
|
+
item["volatility"] = labels.get(item.get("url")) or level
|
|
2580
|
+
if cache:
|
|
2581
|
+
item["source_tier"] = item.get("tier", "")
|
|
2582
|
+
item["tier"] = "memoria_validada"
|
|
2583
|
+
item["cache_status"] = cache["status"]
|
|
2584
|
+
item["searched_at"] = cache["searched_at"]
|
|
2585
|
+
item["checked_at"] = cache["checked_at"]
|
|
2586
|
+
item["cached_query"] = cache["cached_query"]
|
|
2587
|
+
result.append(item)
|
|
2588
|
+
return result
|
|
2589
|
+
|
|
2590
|
+
|
|
2591
|
+
def _record_tier_health(tier_name, success):
|
|
2592
|
+
try:
|
|
2593
|
+
web_search_health.record(tier_name, success)
|
|
2594
|
+
except Exception:
|
|
2595
|
+
pass # telemetria é best-effort — nunca pode derrubar uma busca já resolvida
|
|
2596
|
+
|
|
2597
|
+
|
|
2598
|
+
_is_safe_crawl_target = web_fetch.is_safe_target
|
|
2599
|
+
|
|
2600
|
+
|
|
2601
|
+
def _resolve_seed_url(query):
|
|
2602
|
+
query = query.strip()
|
|
2603
|
+
if _URL_RE.match(query):
|
|
2604
|
+
seed_url = query
|
|
2605
|
+
else:
|
|
2606
|
+
raw_results = _run_search_script(web_search_adapters.NODE_EXECUTABLE, DDG_SEARCH_SCRIPT,
|
|
2607
|
+
[query, "--num", "1"], WEB_SEARCH_TIER1_TIMEOUT_S)
|
|
2608
|
+
if not raw_results:
|
|
2609
|
+
raise RuntimeError("Tier 1 returned no results to resolve the seed URL")
|
|
2610
|
+
seed_url = raw_results[0].get("url")
|
|
2611
|
+
if not seed_url:
|
|
2612
|
+
raise RuntimeError("Tier 1 returned a result without a URL")
|
|
2613
|
+
if not _is_safe_crawl_target(seed_url):
|
|
2614
|
+
raise ValueError("Seed URL rejected: it points to an internal/reserved network destination")
|
|
2615
|
+
return seed_url
|
|
2616
|
+
|
|
2617
|
+
|
|
2618
|
+
_SCOPE_SYNTHESIS_SYSTEM_PROMPT = (
|
|
2619
|
+
"You are given raw Markdown scraped from one or more web pages, plus the user's "
|
|
2620
|
+
"original search query. Synthesize a concise, accurate answer to the query using "
|
|
2621
|
+
"ONLY the provided content — never outside knowledge. If the content doesn't answer "
|
|
2622
|
+
"the query, say so explicitly. When there is more than one source page, attribute "
|
|
2623
|
+
"claims to the specific URL they came from."
|
|
2624
|
+
)
|
|
2625
|
+
|
|
2626
|
+
|
|
2627
|
+
def _synthesize_deep_content(query, pages, scope_model):
|
|
2628
|
+
combined = ""
|
|
2629
|
+
sources = []
|
|
2630
|
+
for page in pages:
|
|
2631
|
+
remaining = DEEP_JOB_SYNTHESIS_MAX_CHARS - len(combined)
|
|
2632
|
+
if remaining <= 0:
|
|
2633
|
+
break
|
|
2634
|
+
chunk = page.get("markdown", "")[:remaining]
|
|
2635
|
+
combined += f"\n\n--- Source: {page.get('url', '')} ---\n{chunk}"
|
|
2636
|
+
# chars = quanto do chunk de fato entrou no prompt, não o tamanho bruto
|
|
2637
|
+
# da página — sources é o que foi realmente considerado na síntese.
|
|
2638
|
+
sources.append({"url": page.get("url", ""), "chars": len(chunk)})
|
|
2639
|
+
if not scope_model:
|
|
2640
|
+
# Sem scope_model configurado: devolve o próprio conteúdo bruto truncado
|
|
2641
|
+
# em vez de falhar o job inteiro — síntese é um refinamento, não um
|
|
2642
|
+
# requisito pra o job ter um resultado utilizável.
|
|
2643
|
+
return combined[:2000], sources
|
|
2644
|
+
token = model_client.get_token()
|
|
2645
|
+
response = model_client.fetch(
|
|
2646
|
+
"/v1/chat/completions", token, method="POST",
|
|
2647
|
+
body={
|
|
2648
|
+
"model": scope_model,
|
|
2649
|
+
"messages": [
|
|
2650
|
+
{"role": "system", "content": _SCOPE_SYNTHESIS_SYSTEM_PROMPT},
|
|
2651
|
+
{"role": "user", "content": json.dumps({"query": query, "content": combined}, ensure_ascii=False)},
|
|
2652
|
+
],
|
|
2653
|
+
"temperature": 0,
|
|
2654
|
+
},
|
|
2655
|
+
# Default de model_client.fetch (15s) é curto pra até 100k chars de
|
|
2656
|
+
# contexto — a etapa de crawl já tem orçamento próprio (DEEP_JOB_TIMEOUT_S),
|
|
2657
|
+
# a síntese precisa do seu.
|
|
2658
|
+
timeout=90,
|
|
2659
|
+
)
|
|
2660
|
+
summary = response["choices"][0]["message"]["content"]
|
|
2661
|
+
return summary, sources
|
|
2662
|
+
|
|
2663
|
+
|
|
2664
|
+
def _run_deep_job(job_id, query, scope):
|
|
2665
|
+
# Roda num worker de background (DEEP_JOB_EXECUTOR) — precisa cobrir QUALQUER
|
|
2666
|
+
# exceção e sempre concluir com _set_job: se o worker morrer sem atualizar o
|
|
2667
|
+
# job, ele fica "pending" pra sempre do ponto de vista de web_search_result,
|
|
2668
|
+
# sem qualquer forma do cliente saber que o worker já não existe mais.
|
|
2669
|
+
try:
|
|
2670
|
+
cfg = config.load_config()
|
|
2671
|
+
scope_model = cfg.get("scope_model") or cfg.get("router_model")
|
|
2672
|
+
seed_url = _resolve_seed_url(query)
|
|
2673
|
+
raw = _run_search_script(
|
|
2674
|
+
CAMOUFOX_VENV_PYTHON, CRAWL4AI_SEARCH_SCRIPT,
|
|
2675
|
+
[seed_url, "--max-pages", str(DEEP_JOB_MAX_PAGES)], DEEP_JOB_TIMEOUT_S,
|
|
2676
|
+
)
|
|
2677
|
+
pages = raw.get("pages") if isinstance(raw, dict) else None
|
|
2678
|
+
if not pages:
|
|
2679
|
+
raise RuntimeError("Tier 4 returned no pages")
|
|
2680
|
+
summary, sources = _synthesize_deep_content(query, pages, scope_model)
|
|
2681
|
+
_set_job(
|
|
2682
|
+
job_id, status="done", error=None,
|
|
2683
|
+
result={"summary": _sanitize_text(summary), "sources": sources, "seed_url": seed_url},
|
|
2684
|
+
created_at=_jobs.get(job_id, {}).get("created_at", time.time()),
|
|
2685
|
+
)
|
|
2686
|
+
except Exception as exc:
|
|
2687
|
+
_set_job(
|
|
2688
|
+
job_id, status="error", result=None,
|
|
2689
|
+
error=_sanitize_text(f"{type(exc).__name__}: {exc}"),
|
|
2690
|
+
created_at=_jobs.get(job_id, {}).get("created_at", time.time()),
|
|
2691
|
+
)
|
|
2692
|
+
|
|
2693
|
+
|
|
2694
|
+
# Schemas/prompts validados em .vscode/scripts/_spike-stop-decision-real-summary-test.py
|
|
2695
|
+
# contra achados reais (não sintéticos) — ver design/planos/
|
|
2696
|
+
# smart-tool-agente-pesquisa-item1-integracao.md.
|
|
2697
|
+
_RESEARCH_FINDINGS_TOOL = {
|
|
2698
|
+
"type": "function",
|
|
2699
|
+
"function": {
|
|
2700
|
+
"name": "submit_findings",
|
|
2701
|
+
"description": "Summarize what the research so far has found, structured for a downstream sufficiency check.",
|
|
2702
|
+
"parameters": {
|
|
2703
|
+
"type": "object",
|
|
2704
|
+
"properties": {
|
|
2705
|
+
"findings": {
|
|
2706
|
+
"type": "array",
|
|
2707
|
+
"items": {
|
|
2708
|
+
"type": "object",
|
|
2709
|
+
"properties": {
|
|
2710
|
+
"claim": {"type": "string"},
|
|
2711
|
+
"sources": {"type": "array", "items": {"type": "string"}},
|
|
2712
|
+
"confidence": {"type": "string", "enum": ["alta", "media", "baixa"]},
|
|
2713
|
+
"depth": {"type": "string", "enum": ["snippet", "pagina_completa"]},
|
|
2714
|
+
},
|
|
2715
|
+
"required": ["claim", "sources", "confidence", "depth"],
|
|
2716
|
+
},
|
|
2717
|
+
},
|
|
2718
|
+
"gaps": {"type": "array", "items": {"type": "string"}, "description": "Aspects of the topic not yet covered."},
|
|
2719
|
+
"source_diversity": {
|
|
2720
|
+
"type": "object",
|
|
2721
|
+
"properties": {
|
|
2722
|
+
"independent_domains": {"type": "integer"},
|
|
2723
|
+
"conflicting_claims": {"type": "boolean"},
|
|
2724
|
+
},
|
|
2725
|
+
"required": ["independent_domains", "conflicting_claims"],
|
|
2726
|
+
},
|
|
2727
|
+
},
|
|
2728
|
+
"required": ["findings", "gaps", "source_diversity"],
|
|
2729
|
+
},
|
|
2730
|
+
},
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
_RESEARCH_DECISION_TOOL = {
|
|
2734
|
+
"type": "function",
|
|
2735
|
+
"function": {
|
|
2736
|
+
"name": "submit_decision",
|
|
2737
|
+
"description": "Decide if the research loop should continue or stop.",
|
|
2738
|
+
"parameters": {
|
|
2739
|
+
"type": "object",
|
|
2740
|
+
"properties": {
|
|
2741
|
+
"continue_research": {"type": "boolean"},
|
|
2742
|
+
"reason": {"type": "string", "description": "One sentence justification."},
|
|
2743
|
+
},
|
|
2744
|
+
"required": ["continue_research", "reason"],
|
|
2745
|
+
},
|
|
2746
|
+
},
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
_RESEARCH_PLANNER_TOOL = {
|
|
2750
|
+
"type": "function",
|
|
2751
|
+
"function": {
|
|
2752
|
+
"name": "submit_next_query",
|
|
2753
|
+
"description": "Propose the next search query to fill the most relevant known gap.",
|
|
2754
|
+
"parameters": {
|
|
2755
|
+
"type": "object",
|
|
2756
|
+
"properties": {
|
|
2757
|
+
"next_query": {"type": "string", "description": "A search-engine query, not a restatement of the original topic."},
|
|
2758
|
+
"reason": {"type": "string", "description": "Which gap this targets and why."},
|
|
2759
|
+
},
|
|
2760
|
+
"required": ["next_query", "reason"],
|
|
2761
|
+
},
|
|
2762
|
+
},
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
_RESEARCHER_SYSTEM_PROMPT = (
|
|
2766
|
+
"You are the research step of a web-research loop. You receive raw search-result "
|
|
2767
|
+
"snippets (title/url/snippet) for a topic and must synthesize what they show. Call "
|
|
2768
|
+
"submit_findings with a structured summary: one entry per distinct claim (not per "
|
|
2769
|
+
"source), tagging confidence and whether you only saw a snippet (not the full page). "
|
|
2770
|
+
"List real gaps -- aspects of the topic these snippets do not cover at all."
|
|
2771
|
+
)
|
|
2772
|
+
|
|
2773
|
+
_RESEARCH_CRITIC_SYSTEM_PROMPT = (
|
|
2774
|
+
"You decide whether a web-research loop should keep searching or stop and synthesize "
|
|
2775
|
+
"an answer now. You receive the original topic, the iteration budget used so far, and "
|
|
2776
|
+
"a STRUCTURED summary of findings (claims with confidence/depth, known gaps, source "
|
|
2777
|
+
"diversity).\n\n"
|
|
2778
|
+
"The 'gaps' list is NOT a checklist you must clear before stopping -- the researcher "
|
|
2779
|
+
"lists every direction it could still explore, and for any real topic that list is "
|
|
2780
|
+
"never empty. Judge each gap by whether it would change the answer to what was "
|
|
2781
|
+
"actually asked, not by whether more depth is theoretically available. A gap is worth "
|
|
2782
|
+
"another iteration only if: the topic explicitly asked for that aspect, or the "
|
|
2783
|
+
"high-confidence findings so far are too thin/contradictory to answer at all. "
|
|
2784
|
+
"Exhaustive completeness (primary sources, compliance detail, edge cases nobody asked "
|
|
2785
|
+
"about) is never on its own a reason to continue. Call submit_decision."
|
|
2786
|
+
)
|
|
2787
|
+
|
|
2788
|
+
_RESEARCH_PLANNER_SYSTEM_PROMPT = (
|
|
2789
|
+
"You plan the next search query for a web-research loop that already ran once and "
|
|
2790
|
+
"was told to continue. You receive the original topic, the queries already searched, "
|
|
2791
|
+
"and the structured findings/gaps so far. Propose ONE search-engine query targeting "
|
|
2792
|
+
"the single gap most relevant to the original topic -- never repeat a query already "
|
|
2793
|
+
"searched, and never just restate the original topic verbatim (that would return the "
|
|
2794
|
+
"same pages again). Call submit_next_query."
|
|
2795
|
+
)
|
|
2796
|
+
|
|
2797
|
+
|
|
2798
|
+
def _research_llm_call(model, token, system_prompt, user_content, tool, timeout=30):
|
|
2799
|
+
response = model_client.fetch(
|
|
2800
|
+
"/v1/chat/completions", token, method="POST",
|
|
2801
|
+
body={
|
|
2802
|
+
"model": model,
|
|
2803
|
+
"messages": [
|
|
2804
|
+
{"role": "system", "content": system_prompt},
|
|
2805
|
+
{"role": "user", "content": user_content},
|
|
2806
|
+
],
|
|
2807
|
+
"tools": [tool],
|
|
2808
|
+
"tool_choice": {"type": "function", "function": {"name": tool["function"]["name"]}},
|
|
2809
|
+
"temperature": 0,
|
|
2810
|
+
},
|
|
2811
|
+
timeout=timeout,
|
|
2812
|
+
)
|
|
2813
|
+
tool_call = response["choices"][0]["message"]["tool_calls"][0]
|
|
2814
|
+
return json.loads(tool_call["function"]["arguments"])
|
|
2815
|
+
|
|
2816
|
+
|
|
2817
|
+
def _call_researcher(topic, raw_results, token, model, timeout=30):
|
|
2818
|
+
snippets_text = "\n".join(
|
|
2819
|
+
f"- {r.get('title', '')} ({r.get('url', '')}): {r.get('snippet', '')}" for r in raw_results
|
|
2820
|
+
)
|
|
2821
|
+
user_content = f"Topic: {topic}\n\nRaw search snippets:\n{snippets_text}"
|
|
2822
|
+
summary = _research_llm_call(model, token, _RESEARCHER_SYSTEM_PROMPT, user_content, _RESEARCH_FINDINGS_TOOL, timeout=timeout)
|
|
2823
|
+
diversity = summary.get("source_diversity") if isinstance(summary, dict) else None
|
|
2824
|
+
if (not isinstance(summary, dict) or not isinstance(summary.get("findings"), list)
|
|
2825
|
+
or not isinstance(summary.get("gaps"), list) or not isinstance(diversity, dict)
|
|
2826
|
+
or not isinstance(diversity.get("independent_domains"), int)
|
|
2827
|
+
or not isinstance(diversity.get("conflicting_claims"), bool)):
|
|
2828
|
+
raise ValueError("Researcher returned incomplete structured findings")
|
|
2829
|
+
for item in summary["findings"]:
|
|
2830
|
+
if (not isinstance(item, dict) or not isinstance(item.get("claim"), str)
|
|
2831
|
+
or not isinstance(item.get("sources"), list)
|
|
2832
|
+
or item.get("confidence") not in ("alta", "media", "baixa")
|
|
2833
|
+
or item.get("depth") not in ("snippet", "pagina_completa")):
|
|
2834
|
+
raise ValueError("Researcher returned a finding outside the schema")
|
|
2835
|
+
# O modelo pode citar uma URL real que conhece de fora dos snippets. O agente
|
|
2836
|
+
# chamador recebe só estas fontes, então tal claim pareceria verificável sem ser.
|
|
2837
|
+
# Preserva os achados sustentados e transforma os demais em gap para o planner.
|
|
2838
|
+
observed_urls = {r.get("url") for r in raw_results if isinstance(r, dict) and r.get("url")}
|
|
2839
|
+
backed = []
|
|
2840
|
+
rejected = 0
|
|
2841
|
+
for item in summary["findings"]:
|
|
2842
|
+
cited = item["sources"]
|
|
2843
|
+
if cited and all(isinstance(url, str) and url in observed_urls for url in cited):
|
|
2844
|
+
backed.append(item)
|
|
2845
|
+
else:
|
|
2846
|
+
rejected += 1
|
|
2847
|
+
summary["findings"] = backed
|
|
2848
|
+
if rejected:
|
|
2849
|
+
summary["gaps"].append(
|
|
2850
|
+
f"{rejected} finding(s) cited sources missing from the results; locate those pages before asserting them."
|
|
2851
|
+
)
|
|
2852
|
+
return summary
|
|
2853
|
+
|
|
2854
|
+
|
|
2855
|
+
def _call_critic(topic, budget_used, findings, token, model, timeout=30):
|
|
2856
|
+
user_content = (
|
|
2857
|
+
f"Topic: {topic}\nBudget used: {budget_used}\n"
|
|
2858
|
+
f"Structured findings so far (JSON): {json.dumps(findings, ensure_ascii=False)}"
|
|
2859
|
+
)
|
|
2860
|
+
args = _research_llm_call(model, token, _RESEARCH_CRITIC_SYSTEM_PROMPT, user_content, _RESEARCH_DECISION_TOOL, timeout=timeout)
|
|
2861
|
+
if (not isinstance(args, dict) or not isinstance(args.get("continue_research"), bool)
|
|
2862
|
+
or not isinstance(args.get("reason"), str)):
|
|
2863
|
+
raise ValueError("Critic returned an incomplete decision")
|
|
2864
|
+
return args["continue_research"], args["reason"]
|
|
2865
|
+
|
|
2866
|
+
|
|
2867
|
+
def _call_planner(topic, queries_tried, findings, token, model, timeout=30):
|
|
2868
|
+
user_content = (
|
|
2869
|
+
f"Original topic: {topic}\n"
|
|
2870
|
+
f"Queries already searched: {json.dumps(queries_tried, ensure_ascii=False)}\n"
|
|
2871
|
+
f"Structured findings/gaps so far (JSON): {json.dumps(findings, ensure_ascii=False)}"
|
|
2872
|
+
)
|
|
2873
|
+
args = _research_llm_call(model, token, _RESEARCH_PLANNER_SYSTEM_PROMPT, user_content, _RESEARCH_PLANNER_TOOL, timeout=timeout)
|
|
2874
|
+
next_query = " ".join(str(args.get("next_query") or "").split())
|
|
2875
|
+
tried = {" ".join(query.split()).casefold() for query in queries_tried}
|
|
2876
|
+
if not next_query or next_query.casefold() in tried:
|
|
2877
|
+
raise ValueError("planner did not propose a new sub-search")
|
|
2878
|
+
if len(next_query) > WEB_SEARCH_MAX_QUERY_CHARS:
|
|
2879
|
+
raise ValueError("planner proposed a sub-search that is too long")
|
|
2880
|
+
return next_query, args.get("reason", "")
|
|
2881
|
+
|
|
2882
|
+
|
|
2883
|
+
def _run_research_job(job_id, topic, num, scope=None):
|
|
2884
|
+
# Roda em RESEARCH_JOB_EXECUTOR (pool próprio, separado de DEEP_JOB_EXECUTOR e
|
|
2885
|
+
# WEB_SEARCH_EXECUTOR) — mesmo contrato de _run_deep_job: qualquer exceção sempre
|
|
2886
|
+
# termina em _set_job, nunca deixa o job "pending" pra sempre.
|
|
2887
|
+
try:
|
|
2888
|
+
started = time.monotonic()
|
|
2889
|
+
deadline = started + RESEARCH_JOB_TIMEOUT_S
|
|
2890
|
+
# Uma busca com fallback lento não pode consumir o prazo do pesquisador
|
|
2891
|
+
# e devolver fontes sem findings. O espaço reservado cobre até duas
|
|
2892
|
+
# chamadas LLM (pesquisador + crítico); em testes com prazo curto, usa
|
|
2893
|
+
# no máximo metade do orçamento para preservar a verificação existente.
|
|
2894
|
+
llm_reserve = min(RESEARCH_LLM_RESERVE_S, RESEARCH_JOB_TIMEOUT_S / 2)
|
|
2895
|
+
search_deadline = deadline - llm_reserve
|
|
2896
|
+
cfg = config.load_config()
|
|
2897
|
+
model = cfg.get("research_model") or cfg.get("router_model")
|
|
2898
|
+
if not model:
|
|
2899
|
+
raise RuntimeError("No research_model/router_model configured")
|
|
2900
|
+
max_iterations = config.clamp_research_max_iterations(cfg.get("research_max_iterations"))
|
|
2901
|
+
token = model_client.get_token()
|
|
2902
|
+
try:
|
|
2903
|
+
cached, topic_vector = _validated_cache_result(
|
|
2904
|
+
"research", topic, scope, cfg, token, time.monotonic() + RESEARCH_CACHE_CHECK_S)
|
|
2905
|
+
except Exception as exc:
|
|
2906
|
+
_log(f"research cache unavailable on this call: {type(exc).__name__}: {exc}")
|
|
2907
|
+
cached, topic_vector = None, None
|
|
2908
|
+
if cached:
|
|
2909
|
+
entry, cache_status = cached
|
|
2910
|
+
result = dict(entry["result"])
|
|
2911
|
+
result["volatility"] = entry["classification"]
|
|
2912
|
+
result["cache"] = _cache_trace(entry, cache_status)
|
|
2913
|
+
_set_job(job_id, status="done", error=None, result=result,
|
|
2914
|
+
created_at=_jobs.get(job_id, {}).get("created_at", time.time()))
|
|
2915
|
+
return
|
|
2916
|
+
deadline = time.monotonic() + RESEARCH_JOB_TIMEOUT_S
|
|
2917
|
+
search_deadline = deadline - llm_reserve
|
|
2918
|
+
|
|
2919
|
+
queries_tried = [topic]
|
|
2920
|
+
current_query = topic
|
|
2921
|
+
site_domains = _site_domains_from_query(topic)
|
|
2922
|
+
all_raw = []
|
|
2923
|
+
findings = {}
|
|
2924
|
+
rounds = []
|
|
2925
|
+
# Só pra `research_metrics`: quanto cada rodada acrescenta de fato.
|
|
2926
|
+
urls_seen = set()
|
|
2927
|
+
round_gains = []
|
|
2928
|
+
stopped_by = "budget"
|
|
2929
|
+
|
|
2930
|
+
for i in range(max_iterations):
|
|
2931
|
+
if time.monotonic() >= search_deadline:
|
|
2932
|
+
if all_raw:
|
|
2933
|
+
stopped_by = "time_budget"
|
|
2934
|
+
break
|
|
2935
|
+
raise TimeoutError("search time exhausted before getting sources")
|
|
2936
|
+
try:
|
|
2937
|
+
search_options = {"deadline": search_deadline}
|
|
2938
|
+
if site_domains:
|
|
2939
|
+
search_options["site_domains"] = site_domains
|
|
2940
|
+
round_results = _search_web_once(
|
|
2941
|
+
current_query, num, scope, allow_recall=False, **search_options,
|
|
2942
|
+
)
|
|
2943
|
+
round_results = _with_npm_evidence(current_query, round_results, num)
|
|
2944
|
+
except Exception as exc:
|
|
2945
|
+
# Fontes já reunidas em rodadas anteriores continuam úteis. Na primeira
|
|
2946
|
+
# rodada, sem fonte alguma, o job falha em vez de pedir ao LLM que
|
|
2947
|
+
# invente achados a partir de uma lista vazia.
|
|
2948
|
+
if not all_raw:
|
|
2949
|
+
raise
|
|
2950
|
+
rounds.append({
|
|
2951
|
+
"query": current_query, "results_count": 0,
|
|
2952
|
+
"continue": False, "reason": f"stopped by a search error: {type(exc).__name__}: {exc}",
|
|
2953
|
+
})
|
|
2954
|
+
round_gains.append({"new_urls": 0})
|
|
2955
|
+
stopped_by = "timeout" if isinstance(exc, TimeoutError) else "error"
|
|
2956
|
+
break
|
|
2957
|
+
all_raw.extend(round_results)
|
|
2958
|
+
round_urls = {r.get("url") for r in round_results if r.get("url")}
|
|
2959
|
+
gain = {"new_urls": len(round_urls - urls_seen)}
|
|
2960
|
+
urls_seen |= round_urls
|
|
2961
|
+
round_gains.append(gain)
|
|
2962
|
+
|
|
2963
|
+
# Pesquisador/crítico falhando (timeout, resposta sem tool-call, etc.) não pode
|
|
2964
|
+
# descartar os resultados de busca já acumulados em `all_raw` -- sai do loop com
|
|
2965
|
+
# o que já tem em vez de derrubar o job inteiro (`findings` mantém o valor da
|
|
2966
|
+
# última rodada bem-sucedida, `all_raw` já foi estendido acima).
|
|
2967
|
+
try:
|
|
2968
|
+
findings = _call_researcher(
|
|
2969
|
+
topic, all_raw, token, model,
|
|
2970
|
+
timeout=min(RESEARCH_LLM_TIMEOUT_S, _research_time_left(deadline)),
|
|
2971
|
+
)
|
|
2972
|
+
if isinstance(findings, dict):
|
|
2973
|
+
gain["findings"] = len(findings.get("findings") or [])
|
|
2974
|
+
gain["gaps"] = len(findings.get("gaps") or [])
|
|
2975
|
+
budget_used = f"{i + 1}/{max_iterations} iterations"
|
|
2976
|
+
continue_research, reason = _call_critic(
|
|
2977
|
+
topic, budget_used, findings, token, model,
|
|
2978
|
+
timeout=min(RESEARCH_LLM_TIMEOUT_S, _research_time_left(deadline)),
|
|
2979
|
+
)
|
|
2980
|
+
except Exception as exc:
|
|
2981
|
+
rounds.append({
|
|
2982
|
+
"query": current_query, "results_count": len(round_results),
|
|
2983
|
+
"continue": False, "reason": f"stopped by an error in the round: {type(exc).__name__}: {exc}",
|
|
2984
|
+
})
|
|
2985
|
+
stopped_by = "timeout" if isinstance(exc, TimeoutError) else "error"
|
|
2986
|
+
break
|
|
2987
|
+
|
|
2988
|
+
rounds.append({
|
|
2989
|
+
"query": current_query, "results_count": len(round_results),
|
|
2990
|
+
"continue": continue_research, "reason": reason,
|
|
2991
|
+
})
|
|
2992
|
+
|
|
2993
|
+
if not continue_research:
|
|
2994
|
+
stopped_by = "critic"
|
|
2995
|
+
break
|
|
2996
|
+
if i == max_iterations - 1:
|
|
2997
|
+
break
|
|
2998
|
+
if time.monotonic() >= search_deadline:
|
|
2999
|
+
stopped_by = "time_budget"
|
|
3000
|
+
break
|
|
3001
|
+
|
|
3002
|
+
try:
|
|
3003
|
+
planned_query, _planner_reason = _call_planner(
|
|
3004
|
+
topic, queries_tried, findings, token, model,
|
|
3005
|
+
timeout=min(RESEARCH_LLM_TIMEOUT_S, _research_time_left(deadline)),
|
|
3006
|
+
)
|
|
3007
|
+
current_query = _with_site_operator(planned_query, site_domains)
|
|
3008
|
+
if len(current_query) > WEB_SEARCH_MAX_QUERY_CHARS:
|
|
3009
|
+
raise ValueError("planner proposed a query that is too long after applying the domain")
|
|
3010
|
+
if " ".join(current_query.split()).casefold() in {
|
|
3011
|
+
" ".join(old.split()).casefold() for old in queries_tried
|
|
3012
|
+
}:
|
|
3013
|
+
raise ValueError("planner repeated a query after applying the requested domain")
|
|
3014
|
+
except Exception as exc:
|
|
3015
|
+
stopped_by = "timeout" if isinstance(exc, TimeoutError) else "planner_error"
|
|
3016
|
+
break # sem próxima query -- fica com os findings/fontes já reunidos
|
|
3017
|
+
queries_tried.append(current_query)
|
|
3018
|
+
|
|
3019
|
+
seen_urls = set()
|
|
3020
|
+
sources = []
|
|
3021
|
+
for r in all_raw:
|
|
3022
|
+
url = r.get("url", "")
|
|
3023
|
+
if url and url in seen_urls:
|
|
3024
|
+
continue
|
|
3025
|
+
if url:
|
|
3026
|
+
seen_urls.add(url)
|
|
3027
|
+
sources.append(r)
|
|
3028
|
+
|
|
3029
|
+
research_metrics.log_research_job(
|
|
3030
|
+
topic, max_iterations,
|
|
3031
|
+
[{**r, **g} for r, g in zip(rounds, round_gains)],
|
|
3032
|
+
stopped_by, len(sources), time.monotonic() - started,
|
|
3033
|
+
)
|
|
3034
|
+
result = {
|
|
3035
|
+
"findings": findings, "sources": sources, "rounds": rounds,
|
|
3036
|
+
"partial": stopped_by in ("error", "timeout", "planner_error", "time_budget"),
|
|
3037
|
+
"stop_reason": stopped_by,
|
|
3038
|
+
}
|
|
3039
|
+
if not result["partial"] and findings.get("findings"):
|
|
3040
|
+
_store_fresh_research("research", topic, scope, result, sources, cfg, topic_vector)
|
|
3041
|
+
result["cache"] = {"status": "fresh"}
|
|
3042
|
+
_set_job(
|
|
3043
|
+
job_id, status="done", error=None,
|
|
3044
|
+
result=result,
|
|
3045
|
+
created_at=_jobs.get(job_id, {}).get("created_at", time.time()),
|
|
3046
|
+
)
|
|
3047
|
+
except Exception as exc:
|
|
3048
|
+
_set_job(
|
|
3049
|
+
job_id, status="error", result=None,
|
|
3050
|
+
error=_sanitize_text(f"{type(exc).__name__}: {exc}"),
|
|
3051
|
+
created_at=_jobs.get(job_id, {}).get("created_at", time.time()),
|
|
3052
|
+
)
|
|
3053
|
+
|
|
3054
|
+
|
|
3055
|
+
def _handle_web_search_result(arguments):
|
|
3056
|
+
job_id = arguments.get("job_id")
|
|
3057
|
+
if not job_id:
|
|
3058
|
+
raise ValueError("web_search_result requires 'job_id'")
|
|
3059
|
+
job = _jobs.get(job_id)
|
|
3060
|
+
if job is None:
|
|
3061
|
+
raise ValueError(f"Unknown job_id: {job_id}")
|
|
3062
|
+
payload = {"status": job["status"]}
|
|
3063
|
+
if job["status"] == "done":
|
|
3064
|
+
payload["result"] = job["result"]
|
|
3065
|
+
elif job["status"] == "error":
|
|
3066
|
+
payload["error"] = job["error"]
|
|
3067
|
+
return _to_json_sanitized(payload)
|
|
3068
|
+
|
|
3069
|
+
|
|
3070
|
+
def _tool_result(text, is_error=False):
|
|
3071
|
+
return {"content": [{"type": "text", "text": text}], "isError": is_error}
|
|
3072
|
+
|
|
3073
|
+
|
|
3074
|
+
WEB_FETCH_TIMEOUT_S = 90
|
|
3075
|
+
WEB_FETCH_RENDER_TIMEOUT_S = 60
|
|
3076
|
+
|
|
3077
|
+
|
|
3078
|
+
def _render_page(url, deadline):
|
|
3079
|
+
left = deadline - time.monotonic()
|
|
3080
|
+
if left < 5:
|
|
3081
|
+
return ""
|
|
3082
|
+
raw = _run_search_script(CAMOUFOX_VENV_PYTHON, CRAWL4AI_SEARCH_SCRIPT, [url, "--max-pages", "1"],
|
|
3083
|
+
min(WEB_FETCH_RENDER_TIMEOUT_S, left))
|
|
3084
|
+
pages = raw.get("pages") if isinstance(raw, dict) else None
|
|
3085
|
+
return (pages[0].get("markdown") or "") if pages else ""
|
|
3086
|
+
|
|
3087
|
+
|
|
3088
|
+
def _web_fetch_unavailable():
|
|
3089
|
+
cfg = config.load_config()
|
|
3090
|
+
if not cfg.get("router_model"):
|
|
3091
|
+
return "no router_model configured"
|
|
3092
|
+
return _gateway_problem()
|
|
3093
|
+
|
|
3094
|
+
|
|
3095
|
+
def _handle_web_fetch(arguments):
|
|
3096
|
+
url, prompt = arguments.get("url"), arguments.get("prompt")
|
|
3097
|
+
started = time.monotonic()
|
|
3098
|
+
try:
|
|
3099
|
+
if not isinstance(url, str) or not url.strip() or len(url) > 2000:
|
|
3100
|
+
raise ValueError("web_fetch requires 'url' (at most 2000 characters)")
|
|
3101
|
+
if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 4000:
|
|
3102
|
+
raise ValueError("web_fetch requires 'prompt' (at most 4000 characters)")
|
|
3103
|
+
problem = _web_fetch_unavailable()
|
|
3104
|
+
if problem:
|
|
3105
|
+
raise RuntimeError(f"Gateway model unavailable ({problem}).")
|
|
3106
|
+
deadline = started + WEB_FETCH_TIMEOUT_S
|
|
3107
|
+
page = web_fetch.read_page(url, deadline, _render_page)
|
|
3108
|
+
text = web_fetch.answer(config.load_config()["router_model"], prompt, page, deadline)
|
|
3109
|
+
except Exception as exc:
|
|
3110
|
+
web_fetch.mark_failed(str(url))
|
|
3111
|
+
_metric("web_fetch", error=type(exc).__name__, elapsed_s=round(time.monotonic() - started, 2))
|
|
3112
|
+
raise RuntimeError(f"web_fetch failed: {str(exc).rstrip('.')}. The native WebFetch is allowed for this URL for "
|
|
3113
|
+
f"{web_fetch.FAILED_TTL_S // 60} min.") from None
|
|
3114
|
+
_metric("web_fetch", cached=page["cached"], rendered=page["rendered"], render_error=page.get("render_error"),
|
|
3115
|
+
page_chars=len(page["text"]),
|
|
3116
|
+
answer_chars=len(text), elapsed_s=round(time.monotonic() - started, 2))
|
|
3117
|
+
origin = "from cache" if page["cached"] else "rendered in the browser" if page["rendered"] else "downloaded now"
|
|
3118
|
+
read_at = datetime.datetime.fromtimestamp(page["fetched_at"]).strftime("%Y-%m-%d %H:%M")
|
|
3119
|
+
cut = "; page cut to the first 60,000 characters" if page["truncated"] else ""
|
|
3120
|
+
return _sanitize_text(f"Source: {page['final_url']} (read at {read_at}, {origin}{cut})\n\n{text}")
|
|
3121
|
+
|
|
3122
|
+
|
|
3123
|
+
def _web_route(tool, url):
|
|
3124
|
+
"""Hook decision for native WebSearch/WebFetch: redirect to the Smart Tool only while it can answer."""
|
|
3125
|
+
if tool not in ("WebSearch", "WebFetch"):
|
|
3126
|
+
return {"redirect": False, "reason": "tool not redirected"}
|
|
3127
|
+
if tool == "WebFetch" and web_fetch.recently_failed(url):
|
|
3128
|
+
return {"redirect": False, "reason": "web_fetch failed on this URL recently"}
|
|
3129
|
+
if tool == "WebSearch" and web_fetch.recently_failed(WEB_SEARCH_FAILED_KEY):
|
|
3130
|
+
return {"redirect": False, "reason": "web_search failed or came back empty recently"}
|
|
3131
|
+
problem = _web_fetch_unavailable()
|
|
3132
|
+
if problem:
|
|
3133
|
+
return {"redirect": False, "reason": f"gateway unavailable: {problem}"}
|
|
3134
|
+
if tool == "WebFetch":
|
|
3135
|
+
return {"redirect": True, "reason": "Use the web_fetch tool from the smart-tool MCP (url + prompt): the mini model reads "
|
|
3136
|
+
"the page, the answer comes back short and the page is cached for 24 h."}
|
|
3137
|
+
return {"redirect": True, "reason": "Use the web_search tool from the smart-tool MCP (query + query_en): several sources, "
|
|
3138
|
+
"two languages and a cache shared across sessions."}
|
|
3139
|
+
|
|
3140
|
+
|
|
3141
|
+
HOOK_MAX_BODY_BYTES = 4 * 1024 * 1024
|
|
3142
|
+
|
|
3143
|
+
|
|
3144
|
+
def _pretool_hook(handler):
|
|
3145
|
+
"""PreToolUse decision for the hook (Claude Code `type: "http"`, Codex via the thin command). Always 200 with `{}`
|
|
3146
|
+
or `deny`: a non-2xx answer would show as a hook error on every tool call, and `allow` would skip the user's
|
|
3147
|
+
permission prompt."""
|
|
3148
|
+
client = (urllib.parse.parse_qs(urllib.parse.urlsplit(handler.path).query).get("client") or [""])[0]
|
|
3149
|
+
try:
|
|
3150
|
+
payload = json.loads(handler._read_body(HOOK_MAX_BODY_BYTES) or b"{}")
|
|
3151
|
+
output = hook_decision.decide(payload, client or None, _web_route)
|
|
3152
|
+
except Exception as exc:
|
|
3153
|
+
_log(f"PreToolUse hook without a decision: {type(exc).__name__}: {exc}")
|
|
3154
|
+
return {}
|
|
3155
|
+
specific = output.get("hookSpecificOutput") or {}
|
|
3156
|
+
if specific.get("permissionDecision", "deny") != "deny":
|
|
3157
|
+
return {}
|
|
3158
|
+
return output
|
|
3159
|
+
|
|
3160
|
+
|
|
3161
|
+
_TOOL_HANDLERS = {
|
|
3162
|
+
"web_fetch": _handle_web_fetch,
|
|
3163
|
+
"project_manage": _handle_project_manage,
|
|
3164
|
+
"smart_search": _handle_smart_search,
|
|
3165
|
+
"smart_search_result": _handle_smart_search_result,
|
|
3166
|
+
"web_search": _handle_web_search,
|
|
3167
|
+
"web_search_result": _handle_web_search_result,
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
|
|
3171
|
+
_MCP_SESSION = contextvars.ContextVar("mcp_session", default=None)
|
|
3172
|
+
|
|
3173
|
+
|
|
3174
|
+
def _session_client():
|
|
3175
|
+
session = _sessions.get(_MCP_SESSION.get() or "")
|
|
3176
|
+
return session, (session or {}).get("client") or {}
|
|
3177
|
+
|
|
3178
|
+
|
|
3179
|
+
def _metric(event, **fields):
|
|
3180
|
+
_session, client = _session_client()
|
|
3181
|
+
try:
|
|
3182
|
+
integration_metrics.record(event, client=client_hooks.identify(client) or client.get("name"),
|
|
3183
|
+
client_version=client.get("version"), **fields)
|
|
3184
|
+
except (OSError, ValueError) as exc:
|
|
3185
|
+
_log(f"integration metric not saved: {exc}")
|
|
3186
|
+
|
|
3187
|
+
|
|
3188
|
+
def _dispatch_tool_call(params, session_id=None):
|
|
3189
|
+
if not isinstance(params, dict):
|
|
3190
|
+
return _tool_result("invalid params: expected an object", is_error=True)
|
|
3191
|
+
name = params.get("name")
|
|
3192
|
+
arguments = params.get("arguments") or {}
|
|
3193
|
+
handler = _TOOL_HANDLERS.get(name)
|
|
3194
|
+
if handler is None:
|
|
3195
|
+
return _tool_result(_sanitize_text(f"Unknown tool: {name}"), is_error=True)
|
|
3196
|
+
token = _MCP_SESSION.set(session_id)
|
|
3197
|
+
try:
|
|
3198
|
+
return _tool_result(handler(arguments))
|
|
3199
|
+
except Exception as exc:
|
|
3200
|
+
# Boundary central pra qualquer handler atual/futuro: uma exceção pode
|
|
3201
|
+
# carregar texto de uma fonte não totalmente confiável (ex.: corpo/reason
|
|
3202
|
+
# de erro de um proxy externo) até este ponto sem ter passado por
|
|
3203
|
+
# sanitização própria do handler.
|
|
3204
|
+
return _tool_result(_sanitize_text(str(exc)), is_error=True)
|
|
3205
|
+
finally:
|
|
3206
|
+
_MCP_SESSION.reset(token)
|
|
3207
|
+
|
|
3208
|
+
|
|
3209
|
+
def _dispatch(method, params, session_id):
|
|
3210
|
+
if method == "initialize":
|
|
3211
|
+
client = (params or {}).get("clientInfo") if isinstance((params or {}).get("clientInfo"), dict) else {}
|
|
3212
|
+
_sessions[session_id] = {"initialized": True, "client": client}
|
|
3213
|
+
try:
|
|
3214
|
+
integration_metrics.record("session", client=client_hooks.identify(client) or client.get("name"),
|
|
3215
|
+
client_version=client.get("version"), client_name=client.get("name"))
|
|
3216
|
+
except (OSError, ValueError) as exc:
|
|
3217
|
+
_log(f"integration metric not saved: {exc}")
|
|
3218
|
+
return {
|
|
3219
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
3220
|
+
"capabilities": {"tools": {}},
|
|
3221
|
+
"serverInfo": {"name": "smart-tool", "version": version.VERSION},
|
|
3222
|
+
}
|
|
3223
|
+
if method == "tools/list":
|
|
3224
|
+
return {"tools": TOOLS}
|
|
3225
|
+
if method == "tools/call":
|
|
3226
|
+
return _dispatch_tool_call(params or {}, session_id)
|
|
3227
|
+
if method == "ping":
|
|
3228
|
+
return {}
|
|
3229
|
+
raise KeyError(method)
|
|
3230
|
+
|
|
3231
|
+
|
|
3232
|
+
_LOOPBACK_HOSTNAMES = frozenset({"127.0.0.1", "localhost", "::1"})
|
|
3233
|
+
|
|
3234
|
+
|
|
3235
|
+
def _loopback_host(header_value, require_port=False):
|
|
3236
|
+
"""`Host`/`Origin` apontando de fato para o loopback nesta porta.
|
|
3237
|
+
|
|
3238
|
+
Sem isso o daemon é vulnerável a DNS rebinding: a MESMA origem web (um domínio do
|
|
3239
|
+
atacante que resolve para 127.0.0.1 depois do primeiro acesso) passa a falar com
|
|
3240
|
+
este servidor pelo navegador da vítima, com a política de mesma origem satisfeita —
|
|
3241
|
+
e aqui isso significa rodar `smart_search` sobre qualquer diretório da máquina e ler
|
|
3242
|
+
o `SETUP_TOKEN` de `GET /setup`. A validação exigida pela spec do MCP para
|
|
3243
|
+
transporte local é justamente esta: o navegador não pode falsificar `Host`/`Origin`,
|
|
3244
|
+
então recusar tudo que não seja o nome do loopback fecha o vetor.
|
|
3245
|
+
"""
|
|
3246
|
+
if not header_value:
|
|
3247
|
+
return False
|
|
3248
|
+
netloc = header_value.strip()
|
|
3249
|
+
if "://" in netloc:
|
|
3250
|
+
netloc = urllib.parse.urlsplit(netloc).netloc
|
|
3251
|
+
if netloc.startswith("["):
|
|
3252
|
+
host, sep, rest = netloc[1:].partition("]")
|
|
3253
|
+
if not sep:
|
|
3254
|
+
return False
|
|
3255
|
+
port = rest[1:] if rest.startswith(":") else ""
|
|
3256
|
+
else:
|
|
3257
|
+
host, sep, port = netloc.rpartition(":")
|
|
3258
|
+
if not sep:
|
|
3259
|
+
host, port = netloc, ""
|
|
3260
|
+
if host.lower() not in _LOOPBACK_HOSTNAMES:
|
|
3261
|
+
return False
|
|
3262
|
+
if port == str(_BOUND_PORT):
|
|
3263
|
+
return True
|
|
3264
|
+
# `Host` sem porta é a forma legítima de um cliente que fala na porta padrão; já um
|
|
3265
|
+
# `Origin` sem porta é outra origem (porta 80), e qualquer serviço local ali — app
|
|
3266
|
+
# corporativo com XSS, servidor de dev — não pode ser tratado como esta página.
|
|
3267
|
+
return not port and not require_port
|
|
3268
|
+
|
|
3269
|
+
|
|
3270
|
+
_SETUP_SAVES = {
|
|
3271
|
+
"/setup/models": setup_ui.handle_models_save,
|
|
3272
|
+
"/setup/providers": setup_ui.handle_providers_save,
|
|
3273
|
+
"/setup/local-models": setup_ui.handle_local_models_save,
|
|
3274
|
+
"/setup/integration": setup_ui.handle_integration_save,
|
|
3275
|
+
"/setup/gateway": setup_ui.handle_gateway_save,
|
|
3276
|
+
"/setup/autostart": setup_ui.handle_autostart_action,
|
|
3277
|
+
"/setup/contact": setup_ui.handle_contact_save,
|
|
3278
|
+
}
|
|
3279
|
+
|
|
3280
|
+
|
|
3281
|
+
class MCPHandler(BaseHTTPRequestHandler):
|
|
3282
|
+
server_version = "SmartToolDaemon/0.1"
|
|
3283
|
+
|
|
3284
|
+
def log_message(self, fmt, *args):
|
|
3285
|
+
pass
|
|
3286
|
+
|
|
3287
|
+
def _origin_allowed(self):
|
|
3288
|
+
"""`Origin` ausente é permitido de propósito: cliente MCP nativo (Claude Code,
|
|
3289
|
+
Cursor, VS Code) não manda o header, e nenhum navegador omite `Origin` em
|
|
3290
|
+
requisição cross-origin com efeito colateral."""
|
|
3291
|
+
if not _loopback_host(self.headers.get("Host")):
|
|
3292
|
+
return False
|
|
3293
|
+
origin = self.headers.get("Origin")
|
|
3294
|
+
return origin is None or _loopback_host(origin, require_port=True)
|
|
3295
|
+
|
|
3296
|
+
def _reject_foreign_origin(self):
|
|
3297
|
+
if self._origin_allowed():
|
|
3298
|
+
return False
|
|
3299
|
+
self._write_json(403, {"error": "origin not allowed"})
|
|
3300
|
+
return True
|
|
3301
|
+
|
|
3302
|
+
def do_OPTIONS(self):
|
|
3303
|
+
# Nenhum CORS: o único cliente de navegador legítimo é a própria página /setup,
|
|
3304
|
+
# que é mesma origem. Responder sem `Access-Control-Allow-*` faz o preflight de
|
|
3305
|
+
# qualquer outra origem falhar no navegador.
|
|
3306
|
+
self.send_response(405)
|
|
3307
|
+
self.send_header("Allow", "GET, POST")
|
|
3308
|
+
self.send_header("Content-Length", "0")
|
|
3309
|
+
self.end_headers()
|
|
3310
|
+
|
|
3311
|
+
def _write_json(self, status, payload, extra_headers=None):
|
|
3312
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
3313
|
+
self.send_response(status)
|
|
3314
|
+
self.send_header("Content-Type", "application/json")
|
|
3315
|
+
self.send_header("Content-Length", str(len(body)))
|
|
3316
|
+
self.send_header("Cache-Control", "no-store")
|
|
3317
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
3318
|
+
for key, value in (extra_headers or {}).items():
|
|
3319
|
+
self.send_header(key, value)
|
|
3320
|
+
self.end_headers()
|
|
3321
|
+
try:
|
|
3322
|
+
self.wfile.write(body)
|
|
3323
|
+
except (ConnectionError, BrokenPipeError):
|
|
3324
|
+
pass # O cliente pode fechar a página durante uma operação assíncrona.
|
|
3325
|
+
|
|
3326
|
+
def _write_html(self, status, html):
|
|
3327
|
+
body = html.encode("utf-8")
|
|
3328
|
+
self.send_response(status)
|
|
3329
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
3330
|
+
self.send_header("Content-Length", str(len(body)))
|
|
3331
|
+
# A página carrega o SETUP_TOKEN embutido: não pode ficar em cache de disco nem
|
|
3332
|
+
# ser embutida em iframe de outra origem (clickjacking sobre os botões de
|
|
3333
|
+
# escrita). A CSP fecha o resto — nenhum recurso externo, e `frame-ancestors`
|
|
3334
|
+
# cobre navegadores que já ignoram `X-Frame-Options`; o `unsafe-inline` é
|
|
3335
|
+
# necessário porque o <script>/<style> da página são inline por design (sem
|
|
3336
|
+
# nenhum arquivo estático servido pelo daemon).
|
|
3337
|
+
self.send_header("Cache-Control", "no-store")
|
|
3338
|
+
self.send_header("X-Content-Type-Options", "nosniff")
|
|
3339
|
+
self.send_header("X-Frame-Options", "DENY")
|
|
3340
|
+
self.send_header("Referrer-Policy", "no-referrer")
|
|
3341
|
+
self.send_header(
|
|
3342
|
+
"Content-Security-Policy",
|
|
3343
|
+
"default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
|
|
3344
|
+
"connect-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
3345
|
+
)
|
|
3346
|
+
self.end_headers()
|
|
3347
|
+
try:
|
|
3348
|
+
self.wfile.write(body)
|
|
3349
|
+
except (ConnectionError, BrokenPipeError):
|
|
3350
|
+
pass
|
|
3351
|
+
|
|
3352
|
+
def _read_body(self, max_bytes):
|
|
3353
|
+
try:
|
|
3354
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
3355
|
+
except ValueError:
|
|
3356
|
+
raise ValueError("Invalid Content-Length")
|
|
3357
|
+
if length < 0:
|
|
3358
|
+
raise ValueError("Invalid Content-Length")
|
|
3359
|
+
if length > max_bytes:
|
|
3360
|
+
raise RequestTooLarge(f"Request body over {max_bytes} bytes")
|
|
3361
|
+
return self.rfile.read(length) if length else b""
|
|
3362
|
+
|
|
3363
|
+
def do_GET(self):
|
|
3364
|
+
if self._reject_foreign_origin():
|
|
3365
|
+
return
|
|
3366
|
+
if self.path == "/health":
|
|
3367
|
+
self._write_json(200, {"status": "ok"})
|
|
3368
|
+
return
|
|
3369
|
+
if self.path == "/status":
|
|
3370
|
+
self._write_json(200, {
|
|
3371
|
+
"capabilities": (_capabilities_cache.to_dict() if _capabilities_cache else None),
|
|
3372
|
+
"sessions": len(_sessions),
|
|
3373
|
+
})
|
|
3374
|
+
return
|
|
3375
|
+
if self.path == "/setup":
|
|
3376
|
+
self._write_html(200, setup_ui.render_setup_page())
|
|
3377
|
+
return
|
|
3378
|
+
if self.path == "/setup/status":
|
|
3379
|
+
self._write_json(200, setup_ui.status_payload())
|
|
3380
|
+
return
|
|
3381
|
+
if self.path == "/setup/models":
|
|
3382
|
+
try:
|
|
3383
|
+
self._write_json(200, setup_ui.handle_models_get())
|
|
3384
|
+
except Exception as exc:
|
|
3385
|
+
self._write_json(500, {"error": _sanitize_text(str(exc))})
|
|
3386
|
+
return
|
|
3387
|
+
if self.path == "/projects":
|
|
3388
|
+
import projects_ui
|
|
3389
|
+
self._write_html(200, projects_ui.render_projects_page())
|
|
3390
|
+
return
|
|
3391
|
+
if self.path == "/maps":
|
|
3392
|
+
import projects_ui
|
|
3393
|
+
self._write_html(200, projects_ui.render_map_window())
|
|
3394
|
+
return
|
|
3395
|
+
if self.path == "/setup/projects":
|
|
3396
|
+
try:
|
|
3397
|
+
setup_ui.require_token(self.headers)
|
|
3398
|
+
self._write_json(200, _project_action({"action": "list"}))
|
|
3399
|
+
except setup_ui.SetupError as exc:
|
|
3400
|
+
self._write_json(403, {"error": str(exc)})
|
|
3401
|
+
except Exception as exc:
|
|
3402
|
+
self._write_json(500, {"error": _project_error(exc)})
|
|
3403
|
+
return
|
|
3404
|
+
if self.path == "/setup/integration":
|
|
3405
|
+
try:
|
|
3406
|
+
self._write_json(200, setup_ui.handle_integration_get())
|
|
3407
|
+
except Exception as exc:
|
|
3408
|
+
self._write_json(500, {"error": _sanitize_text(str(exc))})
|
|
3409
|
+
return
|
|
3410
|
+
if self.path == "/setup/local-models":
|
|
3411
|
+
try:
|
|
3412
|
+
self._write_json(200, setup_ui.handle_local_models_get())
|
|
3413
|
+
except Exception as exc:
|
|
3414
|
+
self._write_json(500, {"error": _sanitize_text(str(exc))})
|
|
3415
|
+
return
|
|
3416
|
+
if self.path == "/setup/providers":
|
|
3417
|
+
try:
|
|
3418
|
+
self._write_json(200, setup_ui.handle_providers_get())
|
|
3419
|
+
except Exception as exc:
|
|
3420
|
+
self._write_json(500, {"error": _sanitize_text(str(exc))})
|
|
3421
|
+
return
|
|
3422
|
+
if self.path == "/setup/autostart":
|
|
3423
|
+
try:
|
|
3424
|
+
self._write_json(200, setup_ui.handle_autostart_get())
|
|
3425
|
+
except Exception as exc:
|
|
3426
|
+
self._write_json(500, {"error": _sanitize_text(str(exc))})
|
|
3427
|
+
return
|
|
3428
|
+
self._write_json(404, {"error": "not found"})
|
|
3429
|
+
|
|
3430
|
+
def _post_setup(self, save):
|
|
3431
|
+
try:
|
|
3432
|
+
setup_ui.require_token(self.headers)
|
|
3433
|
+
result = save(json.loads(self._read_body(MCP_MAX_REQUEST_BYTES) or b"{}"))
|
|
3434
|
+
except (setup_ui.SetupError, ValueError, json.JSONDecodeError) as exc:
|
|
3435
|
+
self._write_json(400, {"error": _sanitize_text(str(exc))})
|
|
3436
|
+
return
|
|
3437
|
+
except Exception as exc:
|
|
3438
|
+
self._write_json(500, {"error": _sanitize_text(str(exc))})
|
|
3439
|
+
return
|
|
3440
|
+
self._write_json(200, result)
|
|
3441
|
+
|
|
3442
|
+
def do_POST(self):
|
|
3443
|
+
if self._reject_foreign_origin():
|
|
3444
|
+
return
|
|
3445
|
+
if self.path == "/setup/projects":
|
|
3446
|
+
try:
|
|
3447
|
+
setup_ui.require_token(self.headers)
|
|
3448
|
+
payload = json.loads(self._read_body(128 * 1024) or b"{}")
|
|
3449
|
+
result = _project_action(payload)
|
|
3450
|
+
except setup_ui.SetupError as exc:
|
|
3451
|
+
self._write_json(403, {"error": str(exc)})
|
|
3452
|
+
except (ValueError, TypeError) as exc:
|
|
3453
|
+
self._write_json(400, {"error": _sanitize_text(str(exc))})
|
|
3454
|
+
except Exception as exc:
|
|
3455
|
+
self._write_json(500, {"error": _project_error(exc)})
|
|
3456
|
+
else:
|
|
3457
|
+
self._write_json(200, result)
|
|
3458
|
+
return
|
|
3459
|
+
if urllib.parse.urlsplit(self.path).path == "/hooks/pretool":
|
|
3460
|
+
self._write_json(200, _pretool_hook(self))
|
|
3461
|
+
return
|
|
3462
|
+
save = _SETUP_SAVES.get(self.path)
|
|
3463
|
+
if save:
|
|
3464
|
+
self._post_setup(save)
|
|
3465
|
+
return
|
|
3466
|
+
if self.path != "/mcp":
|
|
3467
|
+
self._write_json(404, {"error": "not found"})
|
|
3468
|
+
return
|
|
3469
|
+
# `<form>` de outra origem só consegue enviar urlencoded/multipart/text-plain e
|
|
3470
|
+
# não é barrado por preflight: exigir JSON deixa esse caminho inutilizável mesmo
|
|
3471
|
+
# que algum navegador entregue a requisição sem `Origin`.
|
|
3472
|
+
if "application/json" not in (self.headers.get("Content-Type") or "").lower():
|
|
3473
|
+
self._write_json(415, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Content-Type must be application/json"}})
|
|
3474
|
+
return
|
|
3475
|
+
try:
|
|
3476
|
+
raw = self._read_body(MCP_MAX_REQUEST_BYTES)
|
|
3477
|
+
except ValueError as exc:
|
|
3478
|
+
status = 413 if isinstance(exc, RequestTooLarge) else 400
|
|
3479
|
+
self._write_json(status, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": str(exc)}})
|
|
3480
|
+
return
|
|
3481
|
+
try:
|
|
3482
|
+
request = json.loads(raw or b"{}")
|
|
3483
|
+
except json.JSONDecodeError:
|
|
3484
|
+
self._write_json(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}})
|
|
3485
|
+
return
|
|
3486
|
+
if not isinstance(request, dict):
|
|
3487
|
+
self._write_json(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "invalid request: expected a JSON object"}})
|
|
3488
|
+
return
|
|
3489
|
+
|
|
3490
|
+
method = request.get("method")
|
|
3491
|
+
req_id = request.get("id")
|
|
3492
|
+
params = request.get("params")
|
|
3493
|
+
session_id = self.headers.get("Mcp-Session-Id") or uuid.uuid4().hex
|
|
3494
|
+
|
|
3495
|
+
if req_id is None:
|
|
3496
|
+
self.send_response(202)
|
|
3497
|
+
self.end_headers()
|
|
3498
|
+
return
|
|
3499
|
+
|
|
3500
|
+
try:
|
|
3501
|
+
result = _dispatch(method, params, session_id)
|
|
3502
|
+
except KeyError:
|
|
3503
|
+
self._write_json(200, {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32601, "message": f"method not supported: {method}"}})
|
|
3504
|
+
return
|
|
3505
|
+
except Exception as exc:
|
|
3506
|
+
self._write_json(200, {"jsonrpc": "2.0", "id": req_id, "error": {"code": -32000, "message": _sanitize_text(str(exc))}})
|
|
3507
|
+
return
|
|
3508
|
+
|
|
3509
|
+
headers = {"Mcp-Session-Id": session_id} if method == "initialize" else {}
|
|
3510
|
+
self._write_json(200, {"jsonrpc": "2.0", "id": req_id, "result": result}, headers)
|
|
3511
|
+
|
|
3512
|
+
|
|
3513
|
+
def _write_registry(port):
|
|
3514
|
+
os.makedirs(RUN_DIR, exist_ok=True)
|
|
3515
|
+
with open(REGISTRY_PATH, "w", encoding="utf-8") as f:
|
|
3516
|
+
json.dump({"url": f"http://127.0.0.1:{port}", "pid": os.getpid(), "port": port}, f)
|
|
3517
|
+
|
|
3518
|
+
|
|
3519
|
+
def _remove_registry():
|
|
3520
|
+
try:
|
|
3521
|
+
os.remove(REGISTRY_PATH)
|
|
3522
|
+
except OSError:
|
|
3523
|
+
pass
|
|
3524
|
+
|
|
3525
|
+
|
|
3526
|
+
class _ExclusiveThreadingHTTPServer(ThreadingHTTPServer):
|
|
3527
|
+
# allow_reuse_address=True (default) deixa um segundo processo dar bind na
|
|
3528
|
+
# mesma porta no Windows mesmo com o primeiro ainda em LISTEN — os dois
|
|
3529
|
+
# respondem requisições de forma não-determinística, sem erro visível.
|
|
3530
|
+
# False força o bind duplicado a falhar alto (OSError) em vez de silencioso.
|
|
3531
|
+
allow_reuse_address = False
|
|
3532
|
+
|
|
3533
|
+
|
|
3534
|
+
def _smart_tool_answers(port):
|
|
3535
|
+
try:
|
|
3536
|
+
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=1) as response:
|
|
3537
|
+
return json.loads(response.read()).get("status") == "ok"
|
|
3538
|
+
except (OSError, ValueError):
|
|
3539
|
+
return False
|
|
3540
|
+
|
|
3541
|
+
|
|
3542
|
+
def _bind_server():
|
|
3543
|
+
"""Default port, then the port clients already know, then any free port (announced in daemon.json and synced to
|
|
3544
|
+
the clients by endpoint_sync). A port held by another live Smart Tool stops this start instead of duplicating it."""
|
|
3545
|
+
for port in dict.fromkeys(p for p in (DEFAULT_PORT, endpoint_sync.known_port(), 0) if p is not None):
|
|
3546
|
+
try:
|
|
3547
|
+
return _ExclusiveThreadingHTTPServer(("127.0.0.1", port), MCPHandler)
|
|
3548
|
+
except OSError:
|
|
3549
|
+
if port and _smart_tool_answers(port):
|
|
3550
|
+
raise SystemExit(f"Smart Tool is already running on port {port}.")
|
|
3551
|
+
raise OSError("No local port available for the daemon.")
|
|
3552
|
+
|
|
3553
|
+
|
|
3554
|
+
def _probe_capabilities():
|
|
3555
|
+
global _capabilities_cache
|
|
3556
|
+
try:
|
|
3557
|
+
_capabilities_cache = capabilities.probe()
|
|
3558
|
+
except Exception as exc:
|
|
3559
|
+
_log(f"capabilities probe failed: {type(exc).__name__}: {exc}")
|
|
3560
|
+
|
|
3561
|
+
|
|
3562
|
+
def main():
|
|
3563
|
+
global _PROJECT_MONITOR
|
|
3564
|
+
# O bind vem antes de qualquer I/O de rede: enquanto a porta não abre, o
|
|
3565
|
+
# health-check de `daemon_launcher.ensure_daemon_running()` falha, e cada tool call
|
|
3566
|
+
# do agente fica esperando esse tempo pra então seguir aberto. `capabilities.probe()`
|
|
3567
|
+
# leva segundos (registry + /v1/models) e só alimenta `GET /status`, que já trata
|
|
3568
|
+
# cache vazio.
|
|
3569
|
+
global _BOUND_PORT
|
|
3570
|
+
server = _bind_server()
|
|
3571
|
+
_BOUND_PORT = server.server_address[1]
|
|
3572
|
+
threading.Thread(target=_probe_capabilities, daemon=True).start()
|
|
3573
|
+
_write_registry(_BOUND_PORT)
|
|
3574
|
+
atexit.register(_remove_registry)
|
|
3575
|
+
try:
|
|
3576
|
+
for path in endpoint_sync.sync(f"http://127.0.0.1:{_BOUND_PORT}"):
|
|
3577
|
+
_log(f"port {_BOUND_PORT} written to {path}")
|
|
3578
|
+
except (OSError, ValueError) as exc:
|
|
3579
|
+
_log(f"clients not pointed to port {_BOUND_PORT}: {type(exc).__name__}: {exc}")
|
|
3580
|
+
atexit.register(BROWSER_SERVICE.close)
|
|
3581
|
+
try:
|
|
3582
|
+
recovered = project_store.recover_interrupted()
|
|
3583
|
+
project_store.import_known_legacy(indexer.INDEX_DIR)
|
|
3584
|
+
_PROJECT_MONITOR = project_monitor.ProjectMonitor(_enqueue_monitored_project)
|
|
3585
|
+
_PROJECT_MONITOR.start()
|
|
3586
|
+
_log(f"project monitor started; {recovered} interrupted job(s) recovered")
|
|
3587
|
+
except Exception as exc:
|
|
3588
|
+
_log(f"project monitor unavailable: {_project_error(exc)}")
|
|
3589
|
+
try:
|
|
3590
|
+
server.serve_forever()
|
|
3591
|
+
finally:
|
|
3592
|
+
if _PROJECT_MONITOR:
|
|
3593
|
+
_PROJECT_MONITOR.stop()
|
|
3594
|
+
with _JOBS_LOCK:
|
|
3595
|
+
pending = [key for key, value in _jobs.items() if value.get("kind") in _PROJECT_KINDS and value.get("status") == "pending"]
|
|
3596
|
+
for job_id in pending:
|
|
3597
|
+
_cancel_project_job(job_id)
|
|
3598
|
+
SMART_SEARCH_EXECUTOR.shutdown(wait=True)
|
|
3599
|
+
server.server_close()
|
|
3600
|
+
_remove_registry()
|
|
3601
|
+
|
|
3602
|
+
|
|
3603
|
+
if __name__ == "__main__":
|
|
3604
|
+
main()
|