bookmark-research 0.5.0-beta.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/.agents/plugins/marketplace.json +20 -0
- package/.codex-plugin/plugin.json +46 -0
- package/LICENSE +674 -0
- package/README.md +128 -0
- package/README.zh.md +126 -0
- package/bin/bookmark-research.js +30 -0
- package/config/providers.json +98 -0
- package/docs/bookmark-research-architecture.md +124 -0
- package/docs/cli-launcher-plan.md +58 -0
- package/docs/details.en.md +272 -0
- package/docs/details.md +93 -0
- package/docs/harness-compatibility.md +304 -0
- package/docs/harness-sources.json +342 -0
- package/docs/host-validation.json +298 -0
- package/docs/install-design-0.2.0.md +57 -0
- package/docs/installation.en.md +197 -0
- package/docs/installation.md +316 -0
- package/docs/instructions.en.md +42 -0
- package/docs/instructions.md +42 -0
- package/docs/mcp-aggregation-0.2.0.md +92 -0
- package/docs/multi-host-installation-plan.md +163 -0
- package/docs/prompt-reference.en.md +104 -0
- package/docs/prompt-reference.md +104 -0
- package/docs/provider-smoke-0.2.0.json +235 -0
- package/docs/release-notes-0.5.0-beta.1.md +33 -0
- package/docs/research-0.2.0.md +140 -0
- package/docs/research-sources-0.2.0.json +9360 -0
- package/docs/user-guide.en.md +74 -0
- package/docs/user-guide.md +74 -0
- package/docs/validation-0.2.0.md +126 -0
- package/docs/validation-0.4.0.md +154 -0
- package/docs/wiki-quality.md +105 -0
- package/docs/wiki-quality.zh.md +105 -0
- package/hosts/claude/runtime.js +13 -0
- package/hosts/codex/delegate.md +14 -0
- package/hosts/codex/prepare.py +61 -0
- package/hosts/dsh/plugin.js +36 -0
- package/hosts/dsh/runtime.js +14 -0
- package/hosts/dsh/workflow-call.py +45 -0
- package/hosts/pi/register-workflow.py +72 -0
- package/hosts/pi/runtime.js +18 -0
- package/hosts/shared/research-call.py +76 -0
- package/hosts/shared/research-flow.js +208 -0
- package/hosts/shared/workflow.json +15 -0
- package/install.sh +310 -0
- package/package.json +12 -0
- package/scripts/build_zip.py +143 -0
- package/scripts/export_bundle.py +496 -0
- package/scripts/host_assets.py +70 -0
- package/scripts/host_clients.py +196 -0
- package/scripts/host_install.py +277 -0
- package/scripts/install.py +593 -0
- package/scripts/launcher.py +186 -0
- package/scripts/verify_fixture.py +220 -0
- package/scripts/verify_live.py +62 -0
- package/scripts/verify_quality.py +87 -0
- package/skills/bookmark-research/SKILL.md +80 -0
- package/skills/bookmark-research/agents/openai.yaml +3 -0
- package/skills/bookmark-research/references/cli.md +150 -0
- package/skills/bookmark-research/references/deep-research.md +164 -0
- package/skills/bookmark-research/references/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/host-workflows.md +101 -0
- package/skills/bookmark-research/references/package-semantics.md +78 -0
- package/skills/bookmark-research/references/research-methods.md +51 -0
- package/skills/bookmark-research/references/research-services.md +72 -0
- package/skills/bookmark-research/references/research-workflow.md +57 -0
- package/skills/bookmark-research/references/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
- package/skills/bookmark-research/references/zh/cli.md +150 -0
- package/skills/bookmark-research/references/zh/deep-research.md +164 -0
- package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
- package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
- package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
- package/skills/bookmark-research/references/zh/research-methods.md +51 -0
- package/skills/bookmark-research/references/zh/research-services.md +70 -0
- package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
- package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
- package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
- package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
- package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
- package/src/archive.py +302 -0
- package/src/bookmark_index.py +913 -0
- package/src/cli.py +435 -0
- package/src/credentials.py +82 -0
- package/src/evaluation.py +337 -0
- package/src/jina.py +96 -0
- package/src/mcp_server.py +641 -0
- package/src/onboarding.py +352 -0
- package/src/provider_adapters.py +152 -0
- package/src/readiness.py +283 -0
- package/src/remote_mcp.py +295 -0
- package/src/research.py +1334 -0
- package/src/research_coverage.py +160 -0
- package/src/research_services.py +684 -0
- package/src/routing.py +202 -0
- package/src/search_results.py +244 -0
- package/src/service_http.py +139 -0
- package/src/settings.py +247 -0
- package/src/source_inputs.py +201 -0
- package/src/source_manager.py +348 -0
- package/src/source_watcher.py +68 -0
- package/src/textio.py +9 -0
- package/src/tui.py +586 -0
- package/src/web_search.py +603 -0
- package/src/wiki.py +383 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
"""Query selected existing search MCPs and retain provider/target provenance."""
|
|
2
|
+
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import copy
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from urllib.parse import urlsplit
|
|
13
|
+
|
|
14
|
+
from remote_mcp import McpError, McpHttpClient
|
|
15
|
+
from provider_adapters import ProviderAdapter
|
|
16
|
+
from search_results import _canonical_url, fuse_results
|
|
17
|
+
from settings import Settings
|
|
18
|
+
from credentials import Credentials
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _ProviderState:
|
|
22
|
+
def __init__(self):
|
|
23
|
+
self.lock = threading.RLock()
|
|
24
|
+
self.client, self.identity = None, None
|
|
25
|
+
self.catalog, self.catalog_client, self.catalog_version = None, None, None
|
|
26
|
+
self.catalog_expires, self.observed_at = 0, None
|
|
27
|
+
self.cooldown_error, self.cooldown_until = None, 0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class SearchProviders:
|
|
31
|
+
"""Aggregate retrieval providers; no model, crawler, or OAuth implementation."""
|
|
32
|
+
|
|
33
|
+
SCHEMA_CACHE_SECONDS = 300
|
|
34
|
+
MAX_CATALOG_BYTES = 1_000_000
|
|
35
|
+
MAX_CATALOG_TOOLS = 256
|
|
36
|
+
USAGE_FIELDS = ("tool_calls", "http_requests", "initialize_requests", "list_requests", "session_recoveries", "cache_hits")
|
|
37
|
+
|
|
38
|
+
def __init__(self, timeout=None, settings=None):
|
|
39
|
+
if timeout is not None and (isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not 1 <= timeout <= 60):
|
|
40
|
+
raise ValueError("timeout must be between 1 and 60 seconds")
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
self.settings = settings if settings is not None else Settings()
|
|
43
|
+
self.credentials = Credentials(self.settings)
|
|
44
|
+
self.registry = json.loads(
|
|
45
|
+
(Path(__file__).resolve().parent.parent / "config/providers.json").read_text(encoding="utf-8"))
|
|
46
|
+
self._states = {name: _ProviderState() for name in self.registry["providers"]}
|
|
47
|
+
self.session_id = uuid.uuid4().hex
|
|
48
|
+
|
|
49
|
+
def _selected(self, providers):
|
|
50
|
+
selected = self.settings.load()["search"]["providers"] if providers is None else providers
|
|
51
|
+
if not isinstance(selected, list) or not selected or not all(isinstance(x, str) for x in selected):
|
|
52
|
+
raise ValueError("providers must be a nonempty list of names")
|
|
53
|
+
selected = list(dict.fromkeys(selected))
|
|
54
|
+
unknown = set(selected) - set(self.registry["providers"])
|
|
55
|
+
if unknown:
|
|
56
|
+
raise ValueError("Unknown providers: " + ", ".join(sorted(unknown)))
|
|
57
|
+
return selected
|
|
58
|
+
|
|
59
|
+
def describe(self, providers=None):
|
|
60
|
+
names = self._selected(providers) if providers is not None else list(self.registry["providers"])
|
|
61
|
+
preferences = self.settings.load()
|
|
62
|
+
return {"probe_performed": False, "default_providers": preferences["search"]["providers"],
|
|
63
|
+
"search_fallback_providers": preferences["search"]["fallback_providers"],
|
|
64
|
+
"default_fetch_providers": Settings.fetch_providers(preferences),
|
|
65
|
+
"providers": [{"provider": name, **self.registry["providers"][name],
|
|
66
|
+
"availability": "not_probed", "authentication_state": self._authentication(name),
|
|
67
|
+
"operations": {purpose: {"credential_required": bool(variable),
|
|
68
|
+
"credential_configured": bool(self.credentials.get(variable)) if variable else None,
|
|
69
|
+
"default_role": ("primary" if name in preferences["search"]["providers"] else
|
|
70
|
+
"fallback" if name in preferences["search"]["fallback_providers"] else "not_selected") if purpose == "search" else
|
|
71
|
+
("primary" if name == preferences["fetch"]["provider"] else
|
|
72
|
+
"fallback" if name in preferences["fetch"]["providers"] else "not_selected"),
|
|
73
|
+
"missing": [variable] if variable and not self.credentials.get(variable) else []}
|
|
74
|
+
for purpose in ("search", "fetch")
|
|
75
|
+
for variable in [self.registry["providers"][name].get("required_env", {}).get(purpose)]}}
|
|
76
|
+
for name in names],
|
|
77
|
+
"schema_cache_seconds": self.SCHEMA_CACHE_SECONDS,
|
|
78
|
+
"note": "Configuration is not proof of connection, authentication, or service availability. No OAuth flow is implemented."}
|
|
79
|
+
|
|
80
|
+
def _authentication(self, provider):
|
|
81
|
+
info = self.registry["providers"][provider]
|
|
82
|
+
variables = list(info.get("optional_header_env", {}).values()) + [info.get("optional_bearer_env")]
|
|
83
|
+
configured = any(self.credentials.get(variable) for variable in variables if variable)
|
|
84
|
+
return {"mode": "api_key" if configured else ("keyless" if info.get("keyless_headers") else "anonymous"),
|
|
85
|
+
"credential_configured": configured, "credential_validity_verified": False,
|
|
86
|
+
"oauth_implemented": False}
|
|
87
|
+
|
|
88
|
+
def _client(self, provider):
|
|
89
|
+
info = self.registry["providers"][provider]
|
|
90
|
+
if not info["direct_client"]:
|
|
91
|
+
raise McpError("This provider requires a host-managed MCP client", "unsupported_capability")
|
|
92
|
+
headers = {}
|
|
93
|
+
for header, variable in info.get("optional_header_env", {}).items():
|
|
94
|
+
if self.credentials.get(variable):
|
|
95
|
+
headers[header] = self.credentials.get(variable)
|
|
96
|
+
variable = info.get("optional_bearer_env")
|
|
97
|
+
if variable and self.credentials.get(variable):
|
|
98
|
+
headers["Authorization"] = "Bearer " + self.credentials.get(variable)
|
|
99
|
+
if not headers:
|
|
100
|
+
headers.update(info.get("keyless_headers", {}))
|
|
101
|
+
timeout = self.timeout if self.timeout is not None else self.settings.load()["timeout_seconds"]
|
|
102
|
+
# Identity stays in memory. Neither headers nor credential fingerprints
|
|
103
|
+
# are included in metadata, usage reports, logs or persisted settings.
|
|
104
|
+
identity = (info["url"], tuple(sorted(headers.items())), timeout)
|
|
105
|
+
state = self._states[provider]
|
|
106
|
+
with state.lock:
|
|
107
|
+
if state.client is None or state.identity != identity:
|
|
108
|
+
if info.get("transport") == "http":
|
|
109
|
+
from jina import JinaClient
|
|
110
|
+
state.client = JinaClient(headers=headers, timeout=timeout)
|
|
111
|
+
else:
|
|
112
|
+
state.client = McpHttpClient(info["url"], headers=headers, timeout=timeout)
|
|
113
|
+
state.identity = identity
|
|
114
|
+
state.catalog = None
|
|
115
|
+
state.cooldown_error, state.cooldown_until = None, 0
|
|
116
|
+
return state.client
|
|
117
|
+
|
|
118
|
+
def _adapter(self, provider):
|
|
119
|
+
return ProviderAdapter(provider, self.registry["providers"][provider])
|
|
120
|
+
|
|
121
|
+
def _catalog(self, provider, client, usage, refresh=False):
|
|
122
|
+
state = self._states[provider]
|
|
123
|
+
version = getattr(client, "catalog_version", None)
|
|
124
|
+
version = version if type(version) is int else None
|
|
125
|
+
if (not refresh and state.catalog is not None and state.catalog_client is client
|
|
126
|
+
and state.catalog_version == version and state.catalog_expires > time.monotonic()):
|
|
127
|
+
usage["cache_hits"] += 1
|
|
128
|
+
return state.catalog
|
|
129
|
+
usage["list_requests"] += 1
|
|
130
|
+
tools = client.list_tools()
|
|
131
|
+
if not isinstance(tools, list) or len(tools) > self.MAX_CATALOG_TOOLS:
|
|
132
|
+
raise McpError("Provider tool catalog is invalid", "protocol_error")
|
|
133
|
+
if len(json.dumps(tools, ensure_ascii=False).encode("utf-8")) > self.MAX_CATALOG_BYTES:
|
|
134
|
+
raise McpError("Provider tool catalog exceeds cache size limit", "response_too_large")
|
|
135
|
+
state.catalog, state.catalog_client = copy.deepcopy(tools), client
|
|
136
|
+
version = getattr(client, "catalog_version", None)
|
|
137
|
+
state.catalog_version = version if type(version) is int else None
|
|
138
|
+
state.catalog_expires = time.monotonic() + self.SCHEMA_CACHE_SECONDS
|
|
139
|
+
state.observed_at = datetime.now(timezone.utc).isoformat()
|
|
140
|
+
return state.catalog
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def _empty_usage(cls):
|
|
144
|
+
return dict.fromkeys(cls.USAGE_FIELDS, 0)
|
|
145
|
+
|
|
146
|
+
@staticmethod
|
|
147
|
+
def _snapshot(client):
|
|
148
|
+
snapshot = getattr(client, "snapshot_usage", None)
|
|
149
|
+
result = snapshot() if callable(snapshot) else None
|
|
150
|
+
return result if isinstance(result, dict) else None
|
|
151
|
+
|
|
152
|
+
def _usage(self, client, before, fallback):
|
|
153
|
+
after = self._snapshot(client) if client is not None and before is not None else None
|
|
154
|
+
if after is None:
|
|
155
|
+
return fallback
|
|
156
|
+
return {key: max(0, after.get(key, 0) - before.get(key, 0)) if key != "cache_hits" else fallback[key]
|
|
157
|
+
for key in self.USAGE_FIELDS}
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _failure(error):
|
|
161
|
+
if isinstance(error, McpError):
|
|
162
|
+
return error.details()
|
|
163
|
+
# Never return arbitrary exception messages: a transport, fake adapter,
|
|
164
|
+
# or service may reflect an endpoint containing credentials.
|
|
165
|
+
return {"error": "Provider operation failed (%s)" % type(error).__name__,
|
|
166
|
+
"error_kind": "provider_error", "retryable": False}
|
|
167
|
+
|
|
168
|
+
def _probe_one(self, provider):
|
|
169
|
+
state = self._states[provider]
|
|
170
|
+
with state.lock:
|
|
171
|
+
client, before, usage = None, None, self._empty_usage()
|
|
172
|
+
try:
|
|
173
|
+
if self.registry["providers"][provider].get("transport") == "http":
|
|
174
|
+
return {"provider": provider, "status": "not_probed", "tools": [],
|
|
175
|
+
"network_probe_performed": False, "catalog_source": "documented_http_api",
|
|
176
|
+
"capabilities": {purpose: {"status": "supported", "execution_verified": False}
|
|
177
|
+
for purpose in ("search", "fetch")},
|
|
178
|
+
"authentication_state": self._authentication(provider), "usage": usage}
|
|
179
|
+
client = self._client(provider)
|
|
180
|
+
before = self._snapshot(client)
|
|
181
|
+
tools = self._catalog(provider, client, usage, refresh=True)
|
|
182
|
+
native = self.registry["providers"][provider].get("native_research", {})
|
|
183
|
+
return {"provider": provider, "status": "ok", "tools": copy.deepcopy(tools),
|
|
184
|
+
"capabilities": self._adapter(provider).capabilities(tools, self.session_id),
|
|
185
|
+
"native_research": {**native, "advertised_tools": [tool["name"] for tool in tools
|
|
186
|
+
if tool.get("name") in native.get("tools", [])],
|
|
187
|
+
"execution_verified": False, "invoked_by_aggregator": False},
|
|
188
|
+
"catalog_observed_at": state.observed_at,
|
|
189
|
+
"authentication_state": self._authentication(provider),
|
|
190
|
+
"usage": self._usage(client, before, usage)}
|
|
191
|
+
except (RuntimeError, ValueError, OSError) as error:
|
|
192
|
+
state.catalog = None
|
|
193
|
+
return {"provider": provider, "status": "error", **self._failure(error),
|
|
194
|
+
"usage": self._usage(client, before, usage)}
|
|
195
|
+
|
|
196
|
+
def probe(self, providers=None):
|
|
197
|
+
names = self._selected(providers)
|
|
198
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(names)) as pool:
|
|
199
|
+
results = list(pool.map(self._probe_one, names))
|
|
200
|
+
return {"probe_performed": True, "providers": results,
|
|
201
|
+
"successful_provider_count": sum(row["status"] == "ok" for row in results),
|
|
202
|
+
"note": "MCP probes perform initialize/tools/list; HTTP providers report their local API mapping without a request. Neither proves successful retrieval."}
|
|
203
|
+
|
|
204
|
+
def _tool(self, provider, purpose, tools):
|
|
205
|
+
return self._adapter(provider).select(purpose, tools)
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _arguments(tool, purpose, query=None, urls=None, limit=5, max_characters=12000):
|
|
209
|
+
# Preserve the old helper for callers/tests; runtime routing always
|
|
210
|
+
# selects the provider explicitly, never by overlapping field names.
|
|
211
|
+
name = tool.get("name", "")
|
|
212
|
+
provider = "tavily" if name.startswith("tavily") else ("parallel" if name in ("web_search", "web_fetch") else "exa")
|
|
213
|
+
return ProviderAdapter(provider, {}).arguments(tool, purpose, query, urls, limit, max_characters)
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _records(value):
|
|
217
|
+
if isinstance(value, list):
|
|
218
|
+
if all(isinstance(x, dict) and "url" in x for x in value):
|
|
219
|
+
return value
|
|
220
|
+
if isinstance(value, dict):
|
|
221
|
+
for key in ("results", "search_results", "items"):
|
|
222
|
+
if isinstance(value.get(key), list):
|
|
223
|
+
return value[key]
|
|
224
|
+
for key in ("data", "result"):
|
|
225
|
+
if isinstance(value.get(key), (dict, list)):
|
|
226
|
+
rows = SearchProviders._records(value[key])
|
|
227
|
+
if rows is not None:
|
|
228
|
+
return rows
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
@staticmethod
|
|
232
|
+
def _result_error(result):
|
|
233
|
+
"""Recognize provider error envelopes even when MCP isError is false.
|
|
234
|
+
|
|
235
|
+
A keyless endpoint can return a successful HTTP/MCP response containing
|
|
236
|
+
a quota error. Treat that as a failed retrieval, not an empty result or
|
|
237
|
+
an unknown search format. Never interpret next_actions as instructions.
|
|
238
|
+
"""
|
|
239
|
+
candidates = [result.get("structuredContent")]
|
|
240
|
+
content = result.get("content", [])
|
|
241
|
+
if isinstance(content, list):
|
|
242
|
+
for block in content:
|
|
243
|
+
if not isinstance(block, dict) or block.get("type") != "text":
|
|
244
|
+
continue
|
|
245
|
+
try:
|
|
246
|
+
candidates.append(json.loads(block.get("text", "")))
|
|
247
|
+
except (ValueError, TypeError):
|
|
248
|
+
pass
|
|
249
|
+
codes = {
|
|
250
|
+
"monthly_cap_reached_bonus_eligible": "quota_exhausted",
|
|
251
|
+
"monthly_cap_reached": "quota_exhausted",
|
|
252
|
+
"usage_limit_exceeded": "quota_exhausted",
|
|
253
|
+
"insufficient_quota": "quota_exhausted",
|
|
254
|
+
"rate_limit_exceeded": "rate_limited",
|
|
255
|
+
"rate_limited": "rate_limited",
|
|
256
|
+
"invalid_api_key": "authentication_required",
|
|
257
|
+
"missing_api_key": "authentication_required",
|
|
258
|
+
"unauthorized": "authentication_required",
|
|
259
|
+
}
|
|
260
|
+
for value in candidates:
|
|
261
|
+
if not isinstance(value, dict):
|
|
262
|
+
continue
|
|
263
|
+
detail = value.get("error") if isinstance(value.get("error"), dict) else value
|
|
264
|
+
code = detail.get("code")
|
|
265
|
+
kind = codes.get(code) if isinstance(code, str) else None
|
|
266
|
+
if kind is not None:
|
|
267
|
+
delay = detail.get("retry_after_seconds")
|
|
268
|
+
delay = delay if type(delay) is int and 0 <= delay <= 86400 else None
|
|
269
|
+
messages = {"quota_exhausted": "Search provider quota exhausted",
|
|
270
|
+
"rate_limited": "Search provider rate limited this request",
|
|
271
|
+
"authentication_required": "Search provider requires valid authentication"}
|
|
272
|
+
return McpError(messages[kind], kind, kind == "rate_limited", retry_after=delay)
|
|
273
|
+
if value.get("error") and SearchProviders._records(value) is None:
|
|
274
|
+
return McpError("Search provider returned an error envelope", "tool_error")
|
|
275
|
+
if result.get("isError") is True:
|
|
276
|
+
return McpError("Search provider tool reported an error", "tool_error")
|
|
277
|
+
return None
|
|
278
|
+
|
|
279
|
+
@staticmethod
|
|
280
|
+
def _normalize(result):
|
|
281
|
+
failure = SearchProviders._result_error(result)
|
|
282
|
+
if failure is not None:
|
|
283
|
+
raise failure
|
|
284
|
+
rows = SearchProviders._records(result.get("structuredContent"))
|
|
285
|
+
content = result.get("content", [])
|
|
286
|
+
if not isinstance(content, list):
|
|
287
|
+
raise McpError("Provider tool returned an invalid content array", "result_format")
|
|
288
|
+
texts = [block["text"] for block in content
|
|
289
|
+
if isinstance(block, dict) and block.get("type") == "text" and isinstance(block.get("text"), str)]
|
|
290
|
+
if rows is None:
|
|
291
|
+
for text in texts:
|
|
292
|
+
try:
|
|
293
|
+
rows = SearchProviders._records(json.loads(text))
|
|
294
|
+
except ValueError:
|
|
295
|
+
continue
|
|
296
|
+
if rows is not None:
|
|
297
|
+
break
|
|
298
|
+
if rows is None:
|
|
299
|
+
# Exa's documented text output uses record headers, not every URL in prose.
|
|
300
|
+
records = []
|
|
301
|
+
for text in texts:
|
|
302
|
+
for block in re.split(r"(?m)(?=^Title:\s)", text):
|
|
303
|
+
match = re.search(r"(?m)^URL:\s*(https?://\S+)\s*$", block)
|
|
304
|
+
title = re.search(r"(?m)^Title:\s*(.+)$", block)
|
|
305
|
+
if match and title:
|
|
306
|
+
records.append({"url": match.group(1), "title": title.group(1),
|
|
307
|
+
"snippet": block[match.end():].strip()})
|
|
308
|
+
rows = records or None
|
|
309
|
+
if rows is None:
|
|
310
|
+
raise McpError("Unrecognized provider result format; use native MCP output with merge-results", "result_format")
|
|
311
|
+
normalized = []
|
|
312
|
+
for row in rows:
|
|
313
|
+
if not isinstance(row, dict) or not isinstance(row.get("url"), str):
|
|
314
|
+
continue
|
|
315
|
+
try:
|
|
316
|
+
_canonical_url(row["url"])
|
|
317
|
+
except ValueError:
|
|
318
|
+
continue
|
|
319
|
+
snippet = row.get("snippet") or row.get("text") or row.get("content") or row.get("excerpts") or ""
|
|
320
|
+
if isinstance(snippet, list):
|
|
321
|
+
snippet = "\n".join(x for x in snippet if isinstance(x, str))
|
|
322
|
+
if not isinstance(snippet, str):
|
|
323
|
+
snippet = ""
|
|
324
|
+
title = row.get("title") if isinstance(row.get("title"), str) else row["url"]
|
|
325
|
+
normalized.append({"url": row["url"], "title": title, "snippet": snippet,
|
|
326
|
+
"rank": len(normalized) + 1})
|
|
327
|
+
if rows and not normalized:
|
|
328
|
+
raise McpError("Provider returned no parseable search results", "result_format")
|
|
329
|
+
return normalized
|
|
330
|
+
|
|
331
|
+
def _execute(self, provider, purpose, query=None, urls=None, limit=5, max_characters=12000):
|
|
332
|
+
state = self._states[provider]
|
|
333
|
+
with state.lock:
|
|
334
|
+
client, before, tool, arguments = None, None, None, {}
|
|
335
|
+
usage, skipped = self._empty_usage(), False
|
|
336
|
+
try:
|
|
337
|
+
client = self._client(provider)
|
|
338
|
+
before = self._snapshot(client)
|
|
339
|
+
credential = self.registry["providers"][provider].get("required_env", {}).get(purpose)
|
|
340
|
+
if credential and not self.credentials.get(credential):
|
|
341
|
+
raise McpError("This operation requires " + credential, "authentication_required")
|
|
342
|
+
if state.cooldown_error is not None and time.monotonic() < state.cooldown_until:
|
|
343
|
+
skipped = True
|
|
344
|
+
raise state.cooldown_error
|
|
345
|
+
if self.registry["providers"][provider].get("transport") == "http":
|
|
346
|
+
tool = {"name": self.registry["providers"][provider][purpose + "_tools"][0]}
|
|
347
|
+
arguments = {"query": query} if purpose == "search" else {"urls": urls}
|
|
348
|
+
result = client.call(purpose, **arguments)
|
|
349
|
+
else:
|
|
350
|
+
catalog = self._catalog(provider, client, usage)
|
|
351
|
+
adapter = self._adapter(provider)
|
|
352
|
+
tool = adapter.select(purpose, catalog)
|
|
353
|
+
arguments = adapter.arguments(tool, purpose, query, urls, limit, max_characters, self.session_id)
|
|
354
|
+
usage["tool_calls"] += 1
|
|
355
|
+
result = client.call_tool(tool["name"], arguments)
|
|
356
|
+
if not isinstance(result, dict):
|
|
357
|
+
raise McpError("Provider tool returned an invalid result", "result_format")
|
|
358
|
+
failure = self._result_error(result)
|
|
359
|
+
if failure is not None:
|
|
360
|
+
raise failure
|
|
361
|
+
return {"status": "ok", "result": result, "tool": tool["name"], "request_arguments": arguments,
|
|
362
|
+
"usage": self._usage(client, before, usage)}
|
|
363
|
+
except (RuntimeError, ValueError, OSError) as error:
|
|
364
|
+
if isinstance(error, McpError):
|
|
365
|
+
if error.kind in ("session_expired", "schema_mismatch", "tool_unavailable", "protocol_error"):
|
|
366
|
+
state.catalog = None
|
|
367
|
+
if error.kind in ("rate_limited", "quota_exhausted") and not skipped:
|
|
368
|
+
state.cooldown_error = error
|
|
369
|
+
delay = 300 if error.kind == "quota_exhausted" else 5
|
|
370
|
+
state.cooldown_until = time.monotonic() + (error.retry_after if error.retry_after is not None else delay)
|
|
371
|
+
return {"status": "error", **self._failure(error), "skipped_due_to_cooldown": skipped,
|
|
372
|
+
"tool": tool["name"] if tool else None, "request_arguments": arguments,
|
|
373
|
+
"usage": self._usage(client, before, usage)}
|
|
374
|
+
|
|
375
|
+
def _search_one(self, work):
|
|
376
|
+
provider, target, query, limit = work
|
|
377
|
+
base = {"provider": provider, "target": target, "query": query,
|
|
378
|
+
"retrieved_at": datetime.now(timezone.utc).isoformat()}
|
|
379
|
+
outcome = self._execute(provider, "search", query=query, limit=limit)
|
|
380
|
+
if outcome["status"] == "error":
|
|
381
|
+
return {**base, **outcome, "results": [], "result_state": "failed"}
|
|
382
|
+
try:
|
|
383
|
+
result = outcome.pop("result")
|
|
384
|
+
rows = self._normalize(result)[:limit]
|
|
385
|
+
return {**base, **outcome, "results": rows, "result_state": "nonempty" if rows else "empty"}
|
|
386
|
+
except (RuntimeError, ValueError, OSError) as error:
|
|
387
|
+
return {**base, **outcome, "status": "error", "results": [], "result_state": "failed", **self._failure(error)}
|
|
388
|
+
|
|
389
|
+
def _search_provider(self, work):
|
|
390
|
+
provider, queries, limit = work
|
|
391
|
+
return [self._search_one((provider, target, query, limit)) for target, query in queries]
|
|
392
|
+
|
|
393
|
+
def _search_batches(self, jobs, limit):
|
|
394
|
+
grouped = {}
|
|
395
|
+
for provider, target, query in jobs:
|
|
396
|
+
grouped.setdefault(provider, []).append((target, query))
|
|
397
|
+
# One worker per provider avoids starving independent providers with
|
|
398
|
+
# multiple queued calls waiting for the same provider's session lock.
|
|
399
|
+
work = [(provider, queries, limit) for provider, queries in grouped.items()]
|
|
400
|
+
if not work:
|
|
401
|
+
return []
|
|
402
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(work)) as pool:
|
|
403
|
+
return [batch for results in pool.map(self._search_provider, work) for batch in results]
|
|
404
|
+
|
|
405
|
+
@staticmethod
|
|
406
|
+
def _unresolved_queries(queries, batches):
|
|
407
|
+
found = set()
|
|
408
|
+
for batch in batches:
|
|
409
|
+
if batch.get("status") != "ok":
|
|
410
|
+
continue
|
|
411
|
+
for row in batch.get("results", []):
|
|
412
|
+
try:
|
|
413
|
+
_canonical_url(row.get("url"))
|
|
414
|
+
except (ValueError, AttributeError):
|
|
415
|
+
continue
|
|
416
|
+
found.add((batch["target"], batch["query"]))
|
|
417
|
+
break
|
|
418
|
+
return [pair for pair in queries if pair not in found]
|
|
419
|
+
|
|
420
|
+
def search(self, targets, providers=None, limit_per_target=None, *, fallback_providers=None,
|
|
421
|
+
reserve_fallback=None):
|
|
422
|
+
preferences = self.settings.load()
|
|
423
|
+
if limit_per_target is None:
|
|
424
|
+
limit_per_target = preferences["search"]["limit_per_target"]
|
|
425
|
+
if not isinstance(targets, list) or not 1 <= len(targets) <= 12:
|
|
426
|
+
raise ValueError("targets must contain 1 to 12 target/query objects")
|
|
427
|
+
if type(limit_per_target) is not int or not 1 <= limit_per_target <= 20:
|
|
428
|
+
raise ValueError("limit_per_target must be between 1 and 20")
|
|
429
|
+
queries = []
|
|
430
|
+
for row in targets:
|
|
431
|
+
if not isinstance(row, dict) or set(row) - {"target", "query"}:
|
|
432
|
+
raise ValueError("Each target must contain only target and query")
|
|
433
|
+
if not all(isinstance(row.get(key), str) and row[key].strip() for key in ("target", "query")):
|
|
434
|
+
raise ValueError("Each target needs nonempty target and query strings")
|
|
435
|
+
if len(row["query"]) > 2000 or len(row["target"]) > 200:
|
|
436
|
+
raise ValueError("Target or query is too long")
|
|
437
|
+
queries.append((row["target"].strip(), row["query"].strip()))
|
|
438
|
+
queries = list(dict.fromkeys(queries))
|
|
439
|
+
names = self._selected(preferences["search"]["providers"] if providers is None else providers)
|
|
440
|
+
if fallback_providers is None:
|
|
441
|
+
fallback_providers = preferences["search"]["fallback_providers"] if providers is None else []
|
|
442
|
+
if not isinstance(fallback_providers, list):
|
|
443
|
+
raise ValueError("fallback_providers must be a list of names")
|
|
444
|
+
fallbacks = [name for name in self._selected(fallback_providers) if name not in names] if fallback_providers else []
|
|
445
|
+
batches = self._search_batches([(name, target, query) for name in names for target, query in queries], limit_per_target)
|
|
446
|
+
pending = self._unresolved_queries(queries, batches)
|
|
447
|
+
skipped, jobs = [], []
|
|
448
|
+
if pending:
|
|
449
|
+
for name in fallbacks:
|
|
450
|
+
credential = self.registry["providers"][name].get("required_env", {}).get("search")
|
|
451
|
+
if credential and not self.credentials.get(credential):
|
|
452
|
+
skipped.append({"provider": name, "reason": "authentication_required", "missing": [credential]})
|
|
453
|
+
else:
|
|
454
|
+
jobs.extend((name, target, query) for target, query in pending)
|
|
455
|
+
selected = reserve_fallback(jobs) if jobs and reserve_fallback is not None else jobs
|
|
456
|
+
unattempted = [job for job in jobs if job not in selected]
|
|
457
|
+
batches.extend(self._search_batches(selected, limit_per_target))
|
|
458
|
+
unresolved = self._unresolved_queries(queries, batches)
|
|
459
|
+
fused = fuse_results({"batches": batches, "targets": list(dict.fromkeys(t for t, _ in queries)),
|
|
460
|
+
"limit_per_target": limit_per_target})
|
|
461
|
+
return {**fused, "batches": batches,
|
|
462
|
+
"routing": {"mode": "parallel_then_fallback" if fallbacks else "parallel",
|
|
463
|
+
"primary_providers": names, "fallback_providers": fallbacks,
|
|
464
|
+
"fallback_triggered": bool(pending and fallbacks), "skipped_providers": skipped},
|
|
465
|
+
"unresolved_queries": [{"target": target, "query": query} for target, query in unresolved],
|
|
466
|
+
"remaining_attempts": [{"provider": name, "target": target, "query": query, "reason": "budget"}
|
|
467
|
+
for name, target, query in unattempted if (target, query) in unresolved],
|
|
468
|
+
"usage": {key: sum(batch["usage"][key] for batch in batches) for key in self.USAGE_FIELDS},
|
|
469
|
+
"retry_policy": "Try configured fallback providers only for queries with no usable URL from the primary group. Each provider/target/query is attempted once; failures remain visible and cooldown may skip calls.",
|
|
470
|
+
"freshness": "retrieved_at is retrieval time; origin freshness is not established",
|
|
471
|
+
"evidence_note": "Provider agreement is not independent fact verification; read source pages."}
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def _fetch_coverage(result, urls):
|
|
475
|
+
# Share the archive's conservative URL/body association rules even when
|
|
476
|
+
# archival is disabled; a redirected or unidentified body is unverified.
|
|
477
|
+
from archive import SourceArchive
|
|
478
|
+
by_url = {}
|
|
479
|
+
for row in SourceArchive._extract(result, urls):
|
|
480
|
+
try:
|
|
481
|
+
key = SourceArchive._key(row["url"])
|
|
482
|
+
except (ValueError, TypeError, KeyError):
|
|
483
|
+
continue
|
|
484
|
+
by_url.setdefault(key, []).append(row)
|
|
485
|
+
coverage = []
|
|
486
|
+
for url in urls:
|
|
487
|
+
_, body, extraction, kind = SourceArchive._select(by_url.get(SourceArchive._key(url)))
|
|
488
|
+
status = "ok" if body is not None and kind != "provider_excerpts" else (
|
|
489
|
+
"error" if extraction == "provider_error" else "unverified")
|
|
490
|
+
coverage.append({"url": url, "status": status, "extraction_status": extraction,
|
|
491
|
+
"content_kind": kind, "characters": len(body) if body is not None else 0,
|
|
492
|
+
"complete_page_verified": False})
|
|
493
|
+
successful = sum(row["status"] == "ok" for row in coverage)
|
|
494
|
+
status = "ok" if successful == len(urls) else ("partial" if successful else
|
|
495
|
+
("error" if all(row["status"] == "error" for row in coverage) else "unverified"))
|
|
496
|
+
return {"status": status, "per_url": coverage, "successful_url_count": successful}
|
|
497
|
+
|
|
498
|
+
@classmethod
|
|
499
|
+
def _fetch_waterfall(cls, urls, providers, fetch_one, reserve_fallback=None):
|
|
500
|
+
"""Try the preferred provider, then the selected alternatives concurrently."""
|
|
501
|
+
attempts, coverage = [], {}
|
|
502
|
+
|
|
503
|
+
def quality(row):
|
|
504
|
+
return (row["status"] == "ok", row.get("characters", 0) > 0,
|
|
505
|
+
row["extraction_status"] not in ("request_failed", "unrecognized_or_not_returned"))
|
|
506
|
+
|
|
507
|
+
for stage, names in enumerate((providers[:1], providers[1:])):
|
|
508
|
+
pending = [url for url in urls if coverage.get(url, {}).get("status") != "ok"]
|
|
509
|
+
if not pending or not names:
|
|
510
|
+
break
|
|
511
|
+
if stage and reserve_fallback is not None:
|
|
512
|
+
names = reserve_fallback(names, pending)
|
|
513
|
+
if not names:
|
|
514
|
+
break
|
|
515
|
+
|
|
516
|
+
def run(name):
|
|
517
|
+
try:
|
|
518
|
+
fetched = fetch_one(name, pending)
|
|
519
|
+
except (RuntimeError, OSError, ValueError) as error:
|
|
520
|
+
fetched = {"status": "error", **cls._failure(error), "usage": cls._empty_usage()}
|
|
521
|
+
fetched = {**fetched, "provider": name, "urls": pending}
|
|
522
|
+
if "result" in fetched:
|
|
523
|
+
fetched.update(cls._fetch_coverage(fetched["result"], pending))
|
|
524
|
+
else:
|
|
525
|
+
fetched["per_url"] = [{"url": url, "status": "unverified",
|
|
526
|
+
"extraction_status": "request_failed", "characters": 0}
|
|
527
|
+
for url in pending]
|
|
528
|
+
return fetched
|
|
529
|
+
|
|
530
|
+
if len(names) == 1:
|
|
531
|
+
batch = [run(names[0])]
|
|
532
|
+
else:
|
|
533
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=len(names)) as pool:
|
|
534
|
+
batch = list(pool.map(run, names))
|
|
535
|
+
attempts.extend(batch)
|
|
536
|
+
for fetched in batch:
|
|
537
|
+
for row in fetched["per_url"]:
|
|
538
|
+
# Provider order breaks ties; a later failure never erases
|
|
539
|
+
# usable text, and successful primary URLs leave the queue.
|
|
540
|
+
if row["url"] not in coverage or quality(row) > quality(coverage[row["url"]]):
|
|
541
|
+
coverage[row["url"]] = {**row, "provider": fetched["provider"]}
|
|
542
|
+
unresolved = [url for url in urls if coverage[url]["status"] != "ok"]
|
|
543
|
+
successful = len(urls) - len(unresolved)
|
|
544
|
+
return {"mode": "waterfall", "urls": urls, "providers": providers, "attempts": attempts,
|
|
545
|
+
"status": "ok" if not unresolved else "partial" if successful else "error",
|
|
546
|
+
"per_url": [coverage[url] for url in urls], "successful_url_count": successful,
|
|
547
|
+
"unresolved_urls": unresolved,
|
|
548
|
+
"remaining_providers": [name for name in providers if name not in {a["provider"] for a in attempts}]
|
|
549
|
+
if unresolved else [],
|
|
550
|
+
"usage": {key: sum(a.get("usage", {}).get(key, 0) for a in attempts) for key in cls.USAGE_FIELDS}}
|
|
551
|
+
|
|
552
|
+
def fetch(self, urls, provider=None, archive=None, max_characters=None):
|
|
553
|
+
preferences = self.settings.load()
|
|
554
|
+
automatic = provider is None
|
|
555
|
+
provider = preferences["fetch"]["provider"] if provider is None else provider
|
|
556
|
+
archive = preferences["archive"]["enabled"] if archive is None else archive
|
|
557
|
+
max_characters = preferences["fetch"]["max_characters"] if max_characters is None else max_characters
|
|
558
|
+
if type(archive) is not bool:
|
|
559
|
+
raise ValueError("archive must be a boolean")
|
|
560
|
+
if type(max_characters) is not int or not 100 <= max_characters <= 100000:
|
|
561
|
+
raise ValueError("max_characters must be between 100 and 100000")
|
|
562
|
+
self._selected([provider])
|
|
563
|
+
if not isinstance(urls, list) or not 1 <= len(urls) <= 8:
|
|
564
|
+
raise ValueError("urls must contain 1 to 8 HTTP(S) URLs")
|
|
565
|
+
for url in urls:
|
|
566
|
+
if not isinstance(url, str):
|
|
567
|
+
raise ValueError("Every URL must be a string")
|
|
568
|
+
if any(character.isspace() or ord(character) < 32 or ord(character) == 127 for character in url) or "\\" in url:
|
|
569
|
+
raise ValueError("URLs must not contain whitespace, control characters or backslashes")
|
|
570
|
+
parsed = urlsplit(url)
|
|
571
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username is not None or parsed.password is not None:
|
|
572
|
+
raise ValueError("Every URL must be HTTP(S) without embedded credentials")
|
|
573
|
+
urls = list(dict.fromkeys(urls))
|
|
574
|
+
if automatic:
|
|
575
|
+
names = self._selected(Settings.fetch_providers(preferences))
|
|
576
|
+
fetched = self._fetch_waterfall(urls, names, lambda name, pending:
|
|
577
|
+
self.fetch(pending, provider=name, archive=archive, max_characters=max_characters))
|
|
578
|
+
return fetched["attempts"][0] if len(fetched["attempts"]) == 1 else fetched
|
|
579
|
+
from archive import SourceArchive
|
|
580
|
+
store = SourceArchive(preferences["archive"]["directory"]) if archive else None
|
|
581
|
+
outcome = self._execute(provider, "fetch", urls=urls, max_characters=max_characters)
|
|
582
|
+
arguments = outcome["request_arguments"]
|
|
583
|
+
fetched = {"provider": provider, "urls": urls, **outcome,
|
|
584
|
+
"requested_max_characters": max_characters,
|
|
585
|
+
"character_limit_applied": any(key in arguments for key in ("maxCharacters", "max_chars")),
|
|
586
|
+
"retrieved_at": datetime.now(timezone.utc).isoformat(),
|
|
587
|
+
"freshness": "Origin freshness is not established; inspect per-URL provider statuses."}
|
|
588
|
+
fetched["archive"] = {"status": "disabled"}
|
|
589
|
+
if outcome["status"] == "error":
|
|
590
|
+
fetched["per_url"] = [{"url": url, "status": "unverified", "extraction_status": "request_failed"} for url in urls]
|
|
591
|
+
fetched["successful_url_count"] = 0
|
|
592
|
+
if store is not None:
|
|
593
|
+
fetched["archive"] = {"status": "not_saved", "reason": "No provider response was obtained"}
|
|
594
|
+
return fetched
|
|
595
|
+
fetched.update(self._fetch_coverage(outcome["result"], urls))
|
|
596
|
+
if store is not None:
|
|
597
|
+
try:
|
|
598
|
+
fetched["archive"] = store.save(fetched)
|
|
599
|
+
except (OSError, ValueError) as error:
|
|
600
|
+
# The response is still usable; never hide a fetch or repeat a paid call
|
|
601
|
+
# just because local persistence failed.
|
|
602
|
+
fetched["archive"] = {"status": "error", "error": str(error)}
|
|
603
|
+
return fetched
|