hermes-keenable-web 0.1.0__tar.gz

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.
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ .pytest_cache/
3
+ .ruff_cache/
4
+ __pycache__/
5
+ *.pyc
6
+ dist/
7
+ build/
8
+ *.egg-info/
9
+ uv.lock
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Keenable
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: hermes-keenable-web
3
+ Version: 0.1.0
4
+ Summary: Keenable web search + content fetch provider for Hermes Agent. Keyless by default; a key raises limits.
5
+ Project-URL: Homepage, https://keenable.ai
6
+ Project-URL: Documentation, https://docs.keenable.ai
7
+ Project-URL: Repository, https://github.com/keenableai/hermes-keenable-web
8
+ Author-email: Keenable <hello@keenable.ai>
9
+ Maintainer: keenableai
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: agents,fetch,hermes,hermes-agent,keenable,plugin,web-search
13
+ Requires-Python: <4.0,>=3.10
14
+ Requires-Dist: httpx>=0.24
15
+ Description-Content-Type: text/markdown
16
+
17
+ # hermes-keenable-web
18
+
19
+ [Keenable](https://keenable.ai) as a web search + content-fetch backend for
20
+ [Hermes Agent](https://github.com/NousResearch/hermes-agent).
21
+
22
+ Keenable is low-latency web search and page-to-markdown extraction built for
23
+ agents. This is a **standalone plugin** (per Hermes' third-party-provider
24
+ policy, `AGENTS.md`): it implements the `WebSearchProvider` ABC and registers
25
+ through the normal plugin discovery path, so nothing in Hermes core changes.
26
+
27
+ - **Search** and **extract** in one provider (`web_search` + `web_extract`).
28
+ - **Keyless by default**: the free tier works with no key (rate-limited public
29
+ endpoint). Set `KEENABLE_API_KEY` to raise limits.
30
+ - Every request sends `X-Keenable-Title: Hermes` for traffic attribution.
31
+
32
+ ## Install
33
+
34
+ ### pip (recommended)
35
+
36
+ ```bash
37
+ pip install hermes-keenable-web
38
+ ```
39
+
40
+ Hermes discovers it via the `hermes_agent.plugins` entry point. Enable it in
41
+ `plugins.enabled`, then select it as the web backend.
42
+
43
+ ### User plugin directory
44
+
45
+ Copy the package into your Hermes plugin dir:
46
+
47
+ ```bash
48
+ mkdir -p ~/.hermes/plugins/web/keenable
49
+ cp -r hermes_keenable_web/* ~/.hermes/plugins/web/keenable/
50
+ ```
51
+
52
+ ## Configure
53
+
54
+ In `config.yaml`:
55
+
56
+ ```yaml
57
+ web:
58
+ backend: keenable # both search + extract
59
+ # or per-capability:
60
+ # search_backend: keenable
61
+ # extract_backend: keenable
62
+ ```
63
+
64
+ Environment variables:
65
+
66
+ ```bash
67
+ KEENABLE_API_KEY=... # optional — blank uses the keyless free tier
68
+ KEENABLE_API_URL=... # optional — override https://api.keenable.ai
69
+ ```
70
+
71
+ Keenable stays opt-in: it is never auto-selected in the no-credential fallback.
72
+ It only services calls when you choose it explicitly via `web.backend` /
73
+ `web.*_backend`.
74
+
75
+ ## Develop
76
+
77
+ ```bash
78
+ uv run --with pytest --with httpx --no-project python -m pytest tests/ -q
79
+ ```
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,67 @@
1
+ # hermes-keenable-web
2
+
3
+ [Keenable](https://keenable.ai) as a web search + content-fetch backend for
4
+ [Hermes Agent](https://github.com/NousResearch/hermes-agent).
5
+
6
+ Keenable is low-latency web search and page-to-markdown extraction built for
7
+ agents. This is a **standalone plugin** (per Hermes' third-party-provider
8
+ policy, `AGENTS.md`): it implements the `WebSearchProvider` ABC and registers
9
+ through the normal plugin discovery path, so nothing in Hermes core changes.
10
+
11
+ - **Search** and **extract** in one provider (`web_search` + `web_extract`).
12
+ - **Keyless by default**: the free tier works with no key (rate-limited public
13
+ endpoint). Set `KEENABLE_API_KEY` to raise limits.
14
+ - Every request sends `X-Keenable-Title: Hermes` for traffic attribution.
15
+
16
+ ## Install
17
+
18
+ ### pip (recommended)
19
+
20
+ ```bash
21
+ pip install hermes-keenable-web
22
+ ```
23
+
24
+ Hermes discovers it via the `hermes_agent.plugins` entry point. Enable it in
25
+ `plugins.enabled`, then select it as the web backend.
26
+
27
+ ### User plugin directory
28
+
29
+ Copy the package into your Hermes plugin dir:
30
+
31
+ ```bash
32
+ mkdir -p ~/.hermes/plugins/web/keenable
33
+ cp -r hermes_keenable_web/* ~/.hermes/plugins/web/keenable/
34
+ ```
35
+
36
+ ## Configure
37
+
38
+ In `config.yaml`:
39
+
40
+ ```yaml
41
+ web:
42
+ backend: keenable # both search + extract
43
+ # or per-capability:
44
+ # search_backend: keenable
45
+ # extract_backend: keenable
46
+ ```
47
+
48
+ Environment variables:
49
+
50
+ ```bash
51
+ KEENABLE_API_KEY=... # optional — blank uses the keyless free tier
52
+ KEENABLE_API_URL=... # optional — override https://api.keenable.ai
53
+ ```
54
+
55
+ Keenable stays opt-in: it is never auto-selected in the no-credential fallback.
56
+ It only services calls when you choose it explicitly via `web.backend` /
57
+ `web.*_backend`.
58
+
59
+ ## Develop
60
+
61
+ ```bash
62
+ uv run --with pytest --with httpx --no-project python -m pytest tests/ -q
63
+ ```
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1,17 @@
1
+ """Keenable web search + extract — standalone Hermes plugin.
2
+
3
+ Installed via pip (entry point ``hermes_agent.plugins``) or copied into
4
+ ``~/.hermes/plugins/web/keenable/``. Registers the Keenable provider through
5
+ the standard plugin context; nothing in Hermes core needs to change.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from hermes_keenable_web.provider import KeenableWebSearchProvider
11
+
12
+ __version__ = "0.1.0"
13
+
14
+
15
+ def register(ctx) -> None:
16
+ """Register the Keenable provider with the plugin context."""
17
+ ctx.register_web_search_provider(KeenableWebSearchProvider())
@@ -0,0 +1,7 @@
1
+ name: web-keenable
2
+ version: 0.1.0
3
+ description: "Keenable web search + content fetch — low-latency search and page-to-markdown extraction built for agents. Free tier works keyless; set KEENABLE_API_KEY (https://keenable.ai/signup) to raise limits."
4
+ author: Keenable
5
+ kind: backend
6
+ provides_web_providers:
7
+ - keenable
@@ -0,0 +1,270 @@
1
+ """Keenable web search + content extraction — standalone Hermes plugin.
2
+
3
+ Subclasses :class:`agent.web_search_provider.WebSearchProvider`. Two
4
+ capabilities, both sync (the underlying call is ``httpx``):
5
+
6
+ - ``supports_search()`` -> True (Keenable ``GET /v1/search``)
7
+ - ``supports_extract()`` -> True (Keenable ``GET /v1/fetch``, one URL per call)
8
+
9
+ Config keys this provider responds to::
10
+
11
+ web:
12
+ search_backend: "keenable" # explicit per-capability
13
+ extract_backend: "keenable" # explicit per-capability
14
+ backend: "keenable" # shared fallback for both
15
+
16
+ Env vars::
17
+
18
+ KEENABLE_API_KEY=... # https://keenable.ai/signup (optional)
19
+ KEENABLE_API_URL=... # optional override of https://api.keenable.ai
20
+
21
+ Keyless: Keenable's free tier works without a key. When ``KEENABLE_API_KEY``
22
+ is unset the provider calls the ``/public`` endpoint variants (rate-limited)
23
+ and omits the ``X-API-Key`` header — mirroring Keenable's own MCP client.
24
+
25
+ ``is_available()`` returns True even without a key so keyless selection works
26
+ for both ``web.backend`` and the per-capability ``web.search_backend`` /
27
+ ``web.extract_backend`` keys (Hermes gates the latter on ``is_available()``).
28
+ It remains opt-in in practice: the plugin is installed deliberately, and only
29
+ surfaces as a no-config last-resort fallback when nothing else is configured.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ from typing import Any, Dict, List
36
+ from urllib.parse import urlsplit
37
+
38
+ from agent.web_search_provider import WebSearchProvider
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # Identifies Hermes to Keenable for traffic attribution. Sent on every call.
43
+ _CLIENT_TITLE = "Hermes"
44
+
45
+ _DEFAULT_BASE_URL = "https://api.keenable.ai"
46
+
47
+
48
+ def _provider_env(name: str) -> str:
49
+ """Read an env var through Hermes' managed-scope-aware helper when available.
50
+
51
+ Falls back to ``os.getenv`` on older cores that predate
52
+ ``get_provider_env`` so the plugin loads across a range of Hermes versions.
53
+ """
54
+ try:
55
+ from agent.web_search_provider import get_provider_env
56
+
57
+ return get_provider_env(name) or ""
58
+ except ImportError:
59
+ import os
60
+
61
+ return os.getenv(name, "") or ""
62
+
63
+
64
+ def _keenable_base_url() -> str:
65
+ """Resolve the API base URL from ``KEENABLE_API_URL`` and enforce HTTPS.
66
+
67
+ An arbitrary base URL is a credential-forwarding / SSRF foothold — the
68
+ ``X-API-Key`` header would follow the request to whatever host is set — so
69
+ only ``https://`` (or ``http://`` against a loopback host, for local dev) is
70
+ accepted. Mirrors the other Keenable clients in this repo.
71
+ """
72
+ base = (_provider_env("KEENABLE_API_URL") or _DEFAULT_BASE_URL).rstrip("/")
73
+ parsed = urlsplit(base)
74
+ if parsed.hostname and not parsed.username and not parsed.password:
75
+ if parsed.scheme == "https":
76
+ return base
77
+ # Permit plain http only for local development against a loopback host.
78
+ if parsed.scheme == "http" and parsed.hostname in {"localhost", "127.0.0.1", "::1"}:
79
+ return base
80
+ raise ValueError(f"KEENABLE_API_URL must be an https:// URL with a host, got {base!r}")
81
+
82
+
83
+ def _api_key() -> str:
84
+ return _provider_env("KEENABLE_API_KEY").strip()
85
+
86
+
87
+ def _keenable_headers(api_key: str) -> Dict[str, str]:
88
+ """Request headers; ``X-API-Key`` only when a key is present (keyless otherwise)."""
89
+ headers = {"X-Keenable-Title": _CLIENT_TITLE, "Content-Type": "application/json"}
90
+ if api_key:
91
+ headers["X-API-Key"] = api_key
92
+ return headers
93
+
94
+
95
+ def _endpoint(base_url: str, path: str, api_key: str) -> str:
96
+ """Keyless calls hit the ``/public`` variant (no auth, rate-limited)."""
97
+ return f"{base_url}{path}" if api_key else f"{base_url}{path}/public"
98
+
99
+
100
+ def _normalize_search_results(response: Dict[str, Any]) -> Dict[str, Any]:
101
+ """Map Keenable ``/v1/search`` response to ``{success, data: {web: [...]}}``.
102
+
103
+ Each result has ``title``, ``url``, ``description``.
104
+ """
105
+ web_results = []
106
+ for i, result in enumerate(response.get("results", []) or []):
107
+ web_results.append(
108
+ {
109
+ "title": result.get("title", ""),
110
+ "url": result.get("url", ""),
111
+ "description": result.get("description", ""),
112
+ "position": i + 1,
113
+ }
114
+ )
115
+ return {"success": True, "data": {"web": web_results}}
116
+
117
+
118
+ class KeenableWebSearchProvider(WebSearchProvider):
119
+ """Keenable search + extract provider."""
120
+
121
+ @property
122
+ def name(self) -> str:
123
+ return "keenable"
124
+
125
+ @property
126
+ def display_name(self) -> str:
127
+ return "Keenable"
128
+
129
+ def is_available(self) -> bool:
130
+ """Always True — Keenable works keyless against the public endpoints.
131
+
132
+ Hermes gates per-capability backend selection (``web.search_backend`` /
133
+ ``web.extract_backend``) on ``is_available()``, so key-gating it here
134
+ would break keyless-by-default for those config keys (the selection
135
+ silently falls back to another provider). A key only raises rate
136
+ limits, never a prerequisite. As a deliberately-installed opt-in
137
+ plugin, keenable surfacing as a last-resort fallback when nothing else
138
+ is configured is acceptable.
139
+ """
140
+ return True
141
+
142
+ def supports_search(self) -> bool:
143
+ return True
144
+
145
+ def supports_extract(self) -> bool:
146
+ return True
147
+
148
+ def search(self, query: str, limit: int = 5) -> Dict[str, Any]:
149
+ """Execute a Keenable search (``GET /v1/search?query=``).
150
+
151
+ The API takes no result-count param (query/mode/site/date filters
152
+ only), so ``limit`` is applied client-side to the ranked results.
153
+ """
154
+ try:
155
+ try:
156
+ from tools.interrupt import is_interrupted
157
+
158
+ if is_interrupted():
159
+ return {"success": False, "error": "Interrupted"}
160
+ except ImportError:
161
+ pass
162
+
163
+ import httpx
164
+
165
+ api_key = _api_key()
166
+ # DEBUG, not INFO: queries can carry PII/secrets — keep them out of
167
+ # default-level logs.
168
+ logger.debug("Keenable search: '%s' (limit=%d)", query, limit)
169
+ response = httpx.get(
170
+ _endpoint(_keenable_base_url(), "/v1/search", api_key),
171
+ headers=_keenable_headers(api_key),
172
+ params={"query": query},
173
+ timeout=60,
174
+ )
175
+ response.raise_for_status()
176
+ normalized = _normalize_search_results(response.json())
177
+ # Guard against negative limits: a raw [:limit] slice would drop
178
+ # from the tail instead of returning nothing.
179
+ safe_limit = max(limit, 0)
180
+ normalized["data"]["web"] = normalized["data"]["web"][:safe_limit]
181
+ return normalized
182
+ except Exception as exc: # noqa: BLE001 — including httpx errors
183
+ logger.warning("Keenable search error: %s", exc)
184
+ return {"success": False, "error": f"Keenable search failed: {exc}"}
185
+
186
+ def extract(self, urls: List[str], **kwargs: Any) -> List[Dict[str, Any]]:
187
+ """Extract content from URLs via Keenable (``GET /v1/fetch``, one per URL).
188
+
189
+ Sync — the underlying call is ``httpx.get(...)``. Returns the legacy
190
+ list-of-results shape; per-URL failures become items with ``error``.
191
+ """
192
+ try:
193
+ from tools.interrupt import is_interrupted
194
+ except ImportError: # interrupt support is optional
195
+ def is_interrupted() -> bool: # noqa: D401
196
+ return False
197
+
198
+ import httpx
199
+
200
+ base_url = _keenable_base_url()
201
+ api_key = _api_key()
202
+ headers = _keenable_headers(api_key)
203
+ fetch_url = _endpoint(base_url, "/v1/fetch", api_key)
204
+ documents: List[Dict[str, Any]] = []
205
+
206
+ # /v1/fetch takes a single ``url`` query param (no batch, no max_chars).
207
+ for url in urls:
208
+ if is_interrupted():
209
+ documents.append(
210
+ {
211
+ "url": url,
212
+ "title": "",
213
+ "content": "",
214
+ "raw_content": "",
215
+ "error": "Interrupted",
216
+ "metadata": {"sourceURL": url},
217
+ }
218
+ )
219
+ continue
220
+ try:
221
+ logger.debug("Keenable fetch: %s", url)
222
+ response = httpx.get(
223
+ fetch_url,
224
+ headers=headers,
225
+ params={"url": url},
226
+ timeout=60,
227
+ )
228
+ response.raise_for_status()
229
+ payload = response.json()
230
+ title = payload.get("title", "")
231
+ content = payload.get("content", "")
232
+ metadata = {"sourceURL": url, "title": title}
233
+ if isinstance(payload.get("metadata"), dict):
234
+ metadata.update(payload["metadata"])
235
+ documents.append(
236
+ {
237
+ "url": payload.get("url", url),
238
+ "title": title,
239
+ "content": content,
240
+ "raw_content": content,
241
+ "metadata": metadata,
242
+ }
243
+ )
244
+ except Exception as exc: # noqa: BLE001
245
+ logger.warning("Keenable fetch error for %s: %s", url, exc)
246
+ documents.append(
247
+ {
248
+ "url": url,
249
+ "title": "",
250
+ "content": "",
251
+ "raw_content": "",
252
+ "error": f"Keenable fetch failed: {exc}",
253
+ "metadata": {"sourceURL": url},
254
+ }
255
+ )
256
+ return documents
257
+
258
+ def get_setup_schema(self) -> Dict[str, Any]:
259
+ return {
260
+ "name": "Keenable",
261
+ "badge": "free",
262
+ "tag": "Search + page fetch; free tier works keyless, key raises limits.",
263
+ "env_vars": [
264
+ {
265
+ "key": "KEENABLE_API_KEY",
266
+ "prompt": "Keenable API key (optional — blank uses the keyless free tier)",
267
+ "url": "https://keenable.ai/signup",
268
+ },
269
+ ],
270
+ }
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "hermes-keenable-web"
7
+ version = "0.1.0"
8
+ description = "Keenable web search + content fetch provider for Hermes Agent. Keyless by default; a key raises limits."
9
+ authors = [{ name = "Keenable", email = "hello@keenable.ai" }]
10
+ maintainers = [{ name = "keenableai" }]
11
+ requires-python = ">=3.10,<4.0"
12
+ readme = "README.md"
13
+ license = "MIT"
14
+ keywords = ["hermes", "hermes-agent", "web-search", "fetch", "keenable", "plugin", "agents"]
15
+ dependencies = [
16
+ "httpx>=0.24",
17
+ ]
18
+
19
+ [project.urls]
20
+ Homepage = "https://keenable.ai"
21
+ Documentation = "https://docs.keenable.ai"
22
+ Repository = "https://github.com/keenableai/hermes-keenable-web"
23
+
24
+ # Discovered by Hermes' PluginManager via importlib.metadata (group
25
+ # "hermes_agent.plugins"). The value points at the module exposing register(ctx).
26
+ [project.entry-points."hermes_agent.plugins"]
27
+ keenable = "hermes_keenable_web"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=8.0",
32
+ "ruff>=0.11",
33
+ "mypy>=1.10",
34
+ ]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ include = ["hermes_keenable_web/", "LICENSE", "README.md"]
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ include = ["hermes_keenable_web/"]
41
+
42
+ [tool.hatch.build.targets.wheel.force-include]
43
+ "hermes_keenable_web/plugin.yaml" = "hermes_keenable_web/plugin.yaml"
44
+
45
+ [tool.mypy]
46
+ ignore_missing_imports = true
47
+ python_version = "3.10"