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.
- hermes_keenable_web-0.1.0/.gitignore +9 -0
- hermes_keenable_web-0.1.0/LICENSE +21 -0
- hermes_keenable_web-0.1.0/PKG-INFO +83 -0
- hermes_keenable_web-0.1.0/README.md +67 -0
- hermes_keenable_web-0.1.0/hermes_keenable_web/__init__.py +17 -0
- hermes_keenable_web-0.1.0/hermes_keenable_web/plugin.yaml +7 -0
- hermes_keenable_web-0.1.0/hermes_keenable_web/provider.py +270 -0
- hermes_keenable_web-0.1.0/pyproject.toml +47 -0
|
@@ -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"
|