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.
Files changed (106) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/.codex-plugin/plugin.json +46 -0
  3. package/LICENSE +674 -0
  4. package/README.md +128 -0
  5. package/README.zh.md +126 -0
  6. package/bin/bookmark-research.js +30 -0
  7. package/config/providers.json +98 -0
  8. package/docs/bookmark-research-architecture.md +124 -0
  9. package/docs/cli-launcher-plan.md +58 -0
  10. package/docs/details.en.md +272 -0
  11. package/docs/details.md +93 -0
  12. package/docs/harness-compatibility.md +304 -0
  13. package/docs/harness-sources.json +342 -0
  14. package/docs/host-validation.json +298 -0
  15. package/docs/install-design-0.2.0.md +57 -0
  16. package/docs/installation.en.md +197 -0
  17. package/docs/installation.md +316 -0
  18. package/docs/instructions.en.md +42 -0
  19. package/docs/instructions.md +42 -0
  20. package/docs/mcp-aggregation-0.2.0.md +92 -0
  21. package/docs/multi-host-installation-plan.md +163 -0
  22. package/docs/prompt-reference.en.md +104 -0
  23. package/docs/prompt-reference.md +104 -0
  24. package/docs/provider-smoke-0.2.0.json +235 -0
  25. package/docs/release-notes-0.5.0-beta.1.md +33 -0
  26. package/docs/research-0.2.0.md +140 -0
  27. package/docs/research-sources-0.2.0.json +9360 -0
  28. package/docs/user-guide.en.md +74 -0
  29. package/docs/user-guide.md +74 -0
  30. package/docs/validation-0.2.0.md +126 -0
  31. package/docs/validation-0.4.0.md +154 -0
  32. package/docs/wiki-quality.md +105 -0
  33. package/docs/wiki-quality.zh.md +105 -0
  34. package/hosts/claude/runtime.js +13 -0
  35. package/hosts/codex/delegate.md +14 -0
  36. package/hosts/codex/prepare.py +61 -0
  37. package/hosts/dsh/plugin.js +36 -0
  38. package/hosts/dsh/runtime.js +14 -0
  39. package/hosts/dsh/workflow-call.py +45 -0
  40. package/hosts/pi/register-workflow.py +72 -0
  41. package/hosts/pi/runtime.js +18 -0
  42. package/hosts/shared/research-call.py +76 -0
  43. package/hosts/shared/research-flow.js +208 -0
  44. package/hosts/shared/workflow.json +15 -0
  45. package/install.sh +310 -0
  46. package/package.json +12 -0
  47. package/scripts/build_zip.py +143 -0
  48. package/scripts/export_bundle.py +496 -0
  49. package/scripts/host_assets.py +70 -0
  50. package/scripts/host_clients.py +196 -0
  51. package/scripts/host_install.py +277 -0
  52. package/scripts/install.py +593 -0
  53. package/scripts/launcher.py +186 -0
  54. package/scripts/verify_fixture.py +220 -0
  55. package/scripts/verify_live.py +62 -0
  56. package/scripts/verify_quality.py +87 -0
  57. package/skills/bookmark-research/SKILL.md +80 -0
  58. package/skills/bookmark-research/agents/openai.yaml +3 -0
  59. package/skills/bookmark-research/references/cli.md +150 -0
  60. package/skills/bookmark-research/references/deep-research.md +164 -0
  61. package/skills/bookmark-research/references/github-and-sync.md +30 -0
  62. package/skills/bookmark-research/references/host-workflows.md +101 -0
  63. package/skills/bookmark-research/references/package-semantics.md +78 -0
  64. package/skills/bookmark-research/references/research-methods.md +51 -0
  65. package/skills/bookmark-research/references/research-services.md +72 -0
  66. package/skills/bookmark-research/references/research-workflow.md +57 -0
  67. package/skills/bookmark-research/references/settings-and-archive.md +104 -0
  68. package/skills/bookmark-research/references/source-lifecycle.md +75 -0
  69. package/skills/bookmark-research/references/wiki-and-evaluation.md +63 -0
  70. package/skills/bookmark-research/references/zh/cli.md +150 -0
  71. package/skills/bookmark-research/references/zh/deep-research.md +164 -0
  72. package/skills/bookmark-research/references/zh/github-and-sync.md +30 -0
  73. package/skills/bookmark-research/references/zh/host-workflows.md +101 -0
  74. package/skills/bookmark-research/references/zh/package-semantics.md +78 -0
  75. package/skills/bookmark-research/references/zh/research-methods.md +51 -0
  76. package/skills/bookmark-research/references/zh/research-services.md +70 -0
  77. package/skills/bookmark-research/references/zh/research-workflow.md +57 -0
  78. package/skills/bookmark-research/references/zh/settings-and-archive.md +104 -0
  79. package/skills/bookmark-research/references/zh/skill-guide.md +75 -0
  80. package/skills/bookmark-research/references/zh/source-lifecycle.md +75 -0
  81. package/skills/bookmark-research/references/zh/wiki-and-evaluation.md +63 -0
  82. package/src/archive.py +302 -0
  83. package/src/bookmark_index.py +913 -0
  84. package/src/cli.py +435 -0
  85. package/src/credentials.py +82 -0
  86. package/src/evaluation.py +337 -0
  87. package/src/jina.py +96 -0
  88. package/src/mcp_server.py +641 -0
  89. package/src/onboarding.py +352 -0
  90. package/src/provider_adapters.py +152 -0
  91. package/src/readiness.py +283 -0
  92. package/src/remote_mcp.py +295 -0
  93. package/src/research.py +1334 -0
  94. package/src/research_coverage.py +160 -0
  95. package/src/research_services.py +684 -0
  96. package/src/routing.py +202 -0
  97. package/src/search_results.py +244 -0
  98. package/src/service_http.py +139 -0
  99. package/src/settings.py +247 -0
  100. package/src/source_inputs.py +201 -0
  101. package/src/source_manager.py +348 -0
  102. package/src/source_watcher.py +68 -0
  103. package/src/textio.py +9 -0
  104. package/src/tui.py +586 -0
  105. package/src/web_search.py +603 -0
  106. 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