hermes-plugin-tinyfish 0.1.3__py3-none-any.whl
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_plugin_tinyfish/__init__.py +35 -0
- hermes_plugin_tinyfish/normalize.py +175 -0
- hermes_plugin_tinyfish/provider.py +271 -0
- hermes_plugin_tinyfish/rest_client.py +64 -0
- hermes_plugin_tinyfish/setup_cli.py +266 -0
- hermes_plugin_tinyfish-0.1.3.dist-info/METADATA +165 -0
- hermes_plugin_tinyfish-0.1.3.dist-info/RECORD +10 -0
- hermes_plugin_tinyfish-0.1.3.dist-info/WHEEL +4 -0
- hermes_plugin_tinyfish-0.1.3.dist-info/entry_points.txt +2 -0
- hermes_plugin_tinyfish-0.1.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""TinyFish provider plugin for Hermes Agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__version__ = "0.1.0"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register(ctx: Any) -> None:
|
|
11
|
+
"""Register the TinyFish web provider and CLI setup commands."""
|
|
12
|
+
|
|
13
|
+
from .provider import TinyFishWebSearchProvider
|
|
14
|
+
from .setup_cli import dispatch_tinyfish_cli, setup_tinyfish_cli
|
|
15
|
+
|
|
16
|
+
provider = TinyFishWebSearchProvider(dispatch_tool=getattr(ctx, "dispatch_tool", None))
|
|
17
|
+
ctx.register_web_search_provider(provider)
|
|
18
|
+
ctx.register_cli_command(
|
|
19
|
+
name="tinyfish",
|
|
20
|
+
help="Configure and diagnose the TinyFish web provider",
|
|
21
|
+
setup_fn=setup_tinyfish_cli,
|
|
22
|
+
handler_fn=dispatch_tinyfish_cli,
|
|
23
|
+
description="Configure TinyFish MCP OAuth, API-key fallback, and Hermes web backend routing.",
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def __getattr__(name: str) -> Any:
|
|
28
|
+
if name == "TinyFishWebSearchProvider":
|
|
29
|
+
from .provider import TinyFishWebSearchProvider
|
|
30
|
+
|
|
31
|
+
return TinyFishWebSearchProvider
|
|
32
|
+
raise AttributeError(name)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = ["TinyFishWebSearchProvider", "register"]
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Normalize TinyFish MCP and REST payloads into Hermes web-provider shapes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, cast
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TinyFishPayloadError(ValueError):
|
|
10
|
+
"""Raised when a TinyFish or MCP payload reports an error."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_jsonish(value: Any) -> Any:
|
|
14
|
+
"""Parse JSON strings when possible and return other values unchanged."""
|
|
15
|
+
|
|
16
|
+
if isinstance(value, str):
|
|
17
|
+
text = value.strip()
|
|
18
|
+
if not text:
|
|
19
|
+
return text
|
|
20
|
+
try:
|
|
21
|
+
return json.loads(text)
|
|
22
|
+
except json.JSONDecodeError:
|
|
23
|
+
return value
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def unwrap_mcp_payload(raw: Any) -> Any:
|
|
28
|
+
"""Convert Hermes' MCP wrapper output to the underlying TinyFish payload."""
|
|
29
|
+
|
|
30
|
+
payload = parse_jsonish(raw)
|
|
31
|
+
if isinstance(payload, dict) and payload.get("error"):
|
|
32
|
+
raise TinyFishPayloadError(str(payload["error"]))
|
|
33
|
+
|
|
34
|
+
if isinstance(payload, dict) and "structuredContent" in payload:
|
|
35
|
+
structured = payload.get("structuredContent")
|
|
36
|
+
if structured is not None:
|
|
37
|
+
return structured
|
|
38
|
+
|
|
39
|
+
if isinstance(payload, dict) and "result" in payload:
|
|
40
|
+
result = parse_jsonish(payload.get("result"))
|
|
41
|
+
if isinstance(result, dict) and result.get("error"):
|
|
42
|
+
raise TinyFishPayloadError(str(result["error"]))
|
|
43
|
+
return result
|
|
44
|
+
|
|
45
|
+
return payload
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize_search_response(payload: Any, limit: int = 5) -> dict[str, Any]:
|
|
49
|
+
"""Return Hermes' standard web-search response envelope."""
|
|
50
|
+
|
|
51
|
+
data = unwrap_mcp_payload(payload)
|
|
52
|
+
if isinstance(data, list):
|
|
53
|
+
results = data
|
|
54
|
+
elif isinstance(data, dict):
|
|
55
|
+
if data.get("error"):
|
|
56
|
+
raise TinyFishPayloadError(str(data["error"]))
|
|
57
|
+
if isinstance(data.get("data"), dict) and isinstance(data["data"].get("web"), list):
|
|
58
|
+
return {
|
|
59
|
+
"success": True,
|
|
60
|
+
"data": {"web": data["data"]["web"][: max(1, int(limit or 5))]},
|
|
61
|
+
}
|
|
62
|
+
results = data.get("results") or data.get("web") or []
|
|
63
|
+
else:
|
|
64
|
+
results = []
|
|
65
|
+
|
|
66
|
+
count = max(1, int(limit or 5))
|
|
67
|
+
web_results: list[dict[str, Any]] = []
|
|
68
|
+
for idx, item in enumerate(list(results)[:count]):
|
|
69
|
+
if not isinstance(item, dict):
|
|
70
|
+
continue
|
|
71
|
+
url = str(item.get("url") or item.get("link") or "")
|
|
72
|
+
web_results.append(
|
|
73
|
+
{
|
|
74
|
+
"title": str(item.get("title") or item.get("site_name") or url),
|
|
75
|
+
"url": url,
|
|
76
|
+
"description": str(
|
|
77
|
+
item.get("snippet")
|
|
78
|
+
or item.get("description")
|
|
79
|
+
or item.get("content")
|
|
80
|
+
or item.get("text")
|
|
81
|
+
or ""
|
|
82
|
+
),
|
|
83
|
+
"position": int(item.get("position") or idx + 1),
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
return {"success": True, "data": {"web": web_results}}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def normalize_fetch_documents(payload: Any, fallback_urls: list[str] | None = None) -> list[dict[str, Any]]:
|
|
90
|
+
"""Return Hermes' standard extract document list."""
|
|
91
|
+
|
|
92
|
+
urls = list(fallback_urls or [])
|
|
93
|
+
data = unwrap_mcp_payload(payload)
|
|
94
|
+
if isinstance(data, dict) and data.get("error"):
|
|
95
|
+
raise TinyFishPayloadError(str(data["error"]))
|
|
96
|
+
|
|
97
|
+
if isinstance(data, dict) and isinstance(data.get("data"), list):
|
|
98
|
+
return cast(list[dict[str, Any]], data["data"])
|
|
99
|
+
|
|
100
|
+
if isinstance(data, dict):
|
|
101
|
+
results = data.get("results") or data.get("documents") or []
|
|
102
|
+
errors = data.get("errors") or data.get("failed_results") or []
|
|
103
|
+
elif isinstance(data, list):
|
|
104
|
+
results = data
|
|
105
|
+
errors = []
|
|
106
|
+
else:
|
|
107
|
+
results = []
|
|
108
|
+
errors = []
|
|
109
|
+
|
|
110
|
+
documents: list[dict[str, Any]] = []
|
|
111
|
+
for idx, item in enumerate(list(results)):
|
|
112
|
+
if isinstance(item, str):
|
|
113
|
+
url = urls[idx] if idx < len(urls) else ""
|
|
114
|
+
documents.append(
|
|
115
|
+
{
|
|
116
|
+
"url": url,
|
|
117
|
+
"title": "",
|
|
118
|
+
"content": item,
|
|
119
|
+
"raw_content": item,
|
|
120
|
+
"metadata": {"sourceURL": url},
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
if not isinstance(item, dict):
|
|
125
|
+
continue
|
|
126
|
+
url = str(item.get("url") or item.get("final_url") or (urls[idx] if idx < len(urls) else ""))
|
|
127
|
+
raw = str(
|
|
128
|
+
item.get("text") or item.get("markdown") or item.get("raw_content") or item.get("content") or ""
|
|
129
|
+
)
|
|
130
|
+
documents.append(
|
|
131
|
+
{
|
|
132
|
+
"url": url,
|
|
133
|
+
"title": str(item.get("title") or ""),
|
|
134
|
+
"content": raw,
|
|
135
|
+
"raw_content": raw,
|
|
136
|
+
"metadata": {
|
|
137
|
+
"sourceURL": url,
|
|
138
|
+
"finalURL": str(item.get("final_url") or url),
|
|
139
|
+
"description": str(item.get("description") or ""),
|
|
140
|
+
"language": str(item.get("language") or ""),
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
for idx, item in enumerate(list(errors)):
|
|
146
|
+
if isinstance(item, dict):
|
|
147
|
+
url = str(item.get("url") or (urls[idx] if idx < len(urls) else ""))
|
|
148
|
+
error = str(item.get("error") or item.get("message") or "fetch failed")
|
|
149
|
+
else:
|
|
150
|
+
url = urls[idx] if idx < len(urls) else ""
|
|
151
|
+
error = str(item)
|
|
152
|
+
documents.append(
|
|
153
|
+
{
|
|
154
|
+
"url": url,
|
|
155
|
+
"title": "",
|
|
156
|
+
"content": "",
|
|
157
|
+
"raw_content": "",
|
|
158
|
+
"error": error,
|
|
159
|
+
"metadata": {"sourceURL": url},
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if not documents and urls:
|
|
164
|
+
return [
|
|
165
|
+
{
|
|
166
|
+
"url": url,
|
|
167
|
+
"title": "",
|
|
168
|
+
"content": "",
|
|
169
|
+
"raw_content": "",
|
|
170
|
+
"error": "TinyFish returned no content",
|
|
171
|
+
"metadata": {"sourceURL": url},
|
|
172
|
+
}
|
|
173
|
+
for url in urls
|
|
174
|
+
]
|
|
175
|
+
return documents
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Hermes WebSearchProvider implementation for TinyFish."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from agent.web_search_provider import WebSearchProvider as _HermesWebSearchProvider
|
|
12
|
+
except Exception: # pragma: no cover - lets package import outside Hermes
|
|
13
|
+
|
|
14
|
+
class _HermesWebSearchProvider: # type: ignore[no-redef]
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from . import rest_client
|
|
19
|
+
from .normalize import TinyFishPayloadError, normalize_fetch_documents, normalize_search_response
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
MCP_SERVER_NAME = "tinyfish"
|
|
24
|
+
MCP_SEARCH_TOOLS = (
|
|
25
|
+
"mcp__tinyfish__search",
|
|
26
|
+
"mcp_tinyfish_search",
|
|
27
|
+
)
|
|
28
|
+
MCP_FETCH_TOOLS = (
|
|
29
|
+
"mcp__tinyfish__fetch_content",
|
|
30
|
+
"mcp__tinyfish__fetch",
|
|
31
|
+
"mcp_tinyfish_fetch_content",
|
|
32
|
+
"mcp_tinyfish_fetch",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _provider_env(name: str) -> str:
|
|
37
|
+
"""Read Hermes config-aware environment values when available."""
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
from agent.web_search_provider import get_provider_env
|
|
41
|
+
|
|
42
|
+
return str(get_provider_env(name) or "").strip()
|
|
43
|
+
except Exception:
|
|
44
|
+
return str(os.getenv(name, "") or "").strip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _registry() -> Any | None:
|
|
48
|
+
try:
|
|
49
|
+
from tools.registry import registry
|
|
50
|
+
|
|
51
|
+
return registry
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _registered_tool_names() -> set[str]:
|
|
57
|
+
registry = _registry()
|
|
58
|
+
if registry is None:
|
|
59
|
+
return set()
|
|
60
|
+
try:
|
|
61
|
+
return set(registry.get_all_tool_names())
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
try:
|
|
65
|
+
return set(getattr(registry, "_tools", {}).keys())
|
|
66
|
+
except Exception:
|
|
67
|
+
return set()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _tinyfish_mcp_configured() -> bool:
|
|
71
|
+
try:
|
|
72
|
+
from hermes_cli.config import load_config
|
|
73
|
+
|
|
74
|
+
config = load_config() or {}
|
|
75
|
+
except Exception:
|
|
76
|
+
return False
|
|
77
|
+
mcp_config = (config.get("mcp_servers") or {}).get(MCP_SERVER_NAME) or {}
|
|
78
|
+
return bool(mcp_config.get("url") and mcp_config.get("auth") == "oauth")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _discover_tinyfish_mcp_tools() -> None:
|
|
82
|
+
"""Register configured MCP tools for this process without prompting for OAuth."""
|
|
83
|
+
|
|
84
|
+
if not _tinyfish_mcp_configured():
|
|
85
|
+
return
|
|
86
|
+
try:
|
|
87
|
+
from tools.mcp_oauth import suppress_interactive_oauth
|
|
88
|
+
except Exception:
|
|
89
|
+
suppress_interactive_oauth = None
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
from tools.mcp_tool import discover_mcp_tools
|
|
93
|
+
except Exception:
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if suppress_interactive_oauth is None:
|
|
97
|
+
discover_mcp_tools()
|
|
98
|
+
return
|
|
99
|
+
with suppress_interactive_oauth():
|
|
100
|
+
discover_mcp_tools()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _first_registered(candidates: tuple[str, ...]) -> str | None:
|
|
104
|
+
names = _registered_tool_names()
|
|
105
|
+
for candidate in candidates:
|
|
106
|
+
if candidate in names:
|
|
107
|
+
return candidate
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class TinyFishWebSearchProvider(_HermesWebSearchProvider): # type: ignore[misc]
|
|
112
|
+
"""TinyFish search and extract provider.
|
|
113
|
+
|
|
114
|
+
The provider prefers TinyFish's hosted MCP server because that path uses
|
|
115
|
+
OAuth and Hermes' built-in MCP token storage. If MCP tools are unavailable,
|
|
116
|
+
it falls back to direct REST calls using ``TINYFISH_API_KEY``.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
def __init__(self, dispatch_tool: Callable[[str, dict[str, Any]], str] | None = None):
|
|
120
|
+
self._dispatch_tool = dispatch_tool
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def name(self) -> str:
|
|
124
|
+
return "tinyfish"
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def display_name(self) -> str:
|
|
128
|
+
return "TinyFish"
|
|
129
|
+
|
|
130
|
+
def is_available(self) -> bool:
|
|
131
|
+
"""Cheap availability check: registered MCP tools or API key only."""
|
|
132
|
+
|
|
133
|
+
return bool(
|
|
134
|
+
_first_registered(MCP_SEARCH_TOOLS)
|
|
135
|
+
or _first_registered(MCP_FETCH_TOOLS)
|
|
136
|
+
or _provider_env("TINYFISH_API_KEY")
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def supports_search(self) -> bool:
|
|
140
|
+
return True
|
|
141
|
+
|
|
142
|
+
def supports_extract(self) -> bool:
|
|
143
|
+
return True
|
|
144
|
+
|
|
145
|
+
def supports_crawl(self) -> bool:
|
|
146
|
+
return False
|
|
147
|
+
|
|
148
|
+
def get_setup_schema(self) -> dict[str, Any]:
|
|
149
|
+
return {
|
|
150
|
+
"name": "TinyFish",
|
|
151
|
+
"badge": "free search/fetch",
|
|
152
|
+
"tag": "OAuth MCP preferred; TINYFISH_API_KEY REST fallback.",
|
|
153
|
+
"env_vars": [
|
|
154
|
+
{
|
|
155
|
+
"key": "TINYFISH_API_KEY",
|
|
156
|
+
"prompt": "TinyFish API key (fallback when MCP OAuth is not configured)",
|
|
157
|
+
"url": "https://agent.tinyfish.ai/api-keys",
|
|
158
|
+
}
|
|
159
|
+
],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
def _dispatch_registered_tool(self, tool_name: str, args: dict[str, Any]) -> str:
|
|
163
|
+
if self._dispatch_tool is not None:
|
|
164
|
+
return self._dispatch_tool(tool_name, args)
|
|
165
|
+
registry = _registry()
|
|
166
|
+
if registry is None:
|
|
167
|
+
raise TinyFishPayloadError("Hermes tool registry is not available")
|
|
168
|
+
return cast(str, registry.dispatch(tool_name, args))
|
|
169
|
+
|
|
170
|
+
def _call_mcp(self, candidates: tuple[str, ...], args: dict[str, Any]) -> Any | None:
|
|
171
|
+
tool_name = _first_registered(candidates)
|
|
172
|
+
if tool_name is None:
|
|
173
|
+
_discover_tinyfish_mcp_tools()
|
|
174
|
+
tool_name = _first_registered(candidates)
|
|
175
|
+
if tool_name is None:
|
|
176
|
+
return None
|
|
177
|
+
logger.debug("TinyFish using MCP tool %s", tool_name)
|
|
178
|
+
return self._dispatch_registered_tool(tool_name, args)
|
|
179
|
+
|
|
180
|
+
def search(self, query: str, limit: int = 5) -> dict[str, Any]:
|
|
181
|
+
try:
|
|
182
|
+
from tools.interrupt import is_interrupted
|
|
183
|
+
|
|
184
|
+
if is_interrupted():
|
|
185
|
+
return {"success": False, "error": "Interrupted"}
|
|
186
|
+
except Exception:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
mcp_error = ""
|
|
190
|
+
try:
|
|
191
|
+
payload = self._call_mcp(MCP_SEARCH_TOOLS, {"query": query})
|
|
192
|
+
if payload is not None:
|
|
193
|
+
return normalize_search_response(payload, limit=limit)
|
|
194
|
+
except Exception as exc: # noqa: BLE001 - REST fallback may still work
|
|
195
|
+
mcp_error = str(exc)
|
|
196
|
+
logger.info("TinyFish MCP search unavailable, trying REST fallback: %s", exc)
|
|
197
|
+
|
|
198
|
+
api_key = _provider_env("TINYFISH_API_KEY")
|
|
199
|
+
if not api_key:
|
|
200
|
+
suffix = f" MCP error: {mcp_error}" if mcp_error else ""
|
|
201
|
+
return {
|
|
202
|
+
"success": False,
|
|
203
|
+
"error": (
|
|
204
|
+
"TinyFish is not configured. Run `hermes tinyfish setup` "
|
|
205
|
+
"to configure MCP OAuth, or set TINYFISH_API_KEY for REST fallback." + suffix
|
|
206
|
+
),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
raw = rest_client.search(query, api_key=api_key)
|
|
211
|
+
return normalize_search_response(raw, limit=limit)
|
|
212
|
+
except Exception as exc: # noqa: BLE001
|
|
213
|
+
logger.warning("TinyFish REST search failed: %s", exc)
|
|
214
|
+
detail = f"; MCP error: {mcp_error}" if mcp_error else ""
|
|
215
|
+
return {"success": False, "error": f"TinyFish search failed: {exc}{detail}"}
|
|
216
|
+
|
|
217
|
+
def extract(self, urls: list[str], **kwargs: Any) -> list[dict[str, Any]]:
|
|
218
|
+
try:
|
|
219
|
+
from tools.interrupt import is_interrupted
|
|
220
|
+
|
|
221
|
+
if is_interrupted():
|
|
222
|
+
return [{"url": url, "title": "", "content": "", "error": "Interrupted"} for url in urls]
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
|
|
226
|
+
output_format = str(kwargs.get("format") or kwargs.get("output_format") or "markdown")
|
|
227
|
+
mcp_error = ""
|
|
228
|
+
try:
|
|
229
|
+
payload = self._call_mcp(
|
|
230
|
+
MCP_FETCH_TOOLS,
|
|
231
|
+
{"urls": urls, "format": output_format},
|
|
232
|
+
)
|
|
233
|
+
if payload is not None:
|
|
234
|
+
return normalize_fetch_documents(payload, fallback_urls=urls)
|
|
235
|
+
except Exception as exc: # noqa: BLE001 - REST fallback may still work
|
|
236
|
+
mcp_error = str(exc)
|
|
237
|
+
logger.info("TinyFish MCP fetch unavailable, trying REST fallback: %s", exc)
|
|
238
|
+
|
|
239
|
+
api_key = _provider_env("TINYFISH_API_KEY")
|
|
240
|
+
if not api_key:
|
|
241
|
+
suffix = f" MCP error: {mcp_error}" if mcp_error else ""
|
|
242
|
+
return [
|
|
243
|
+
{
|
|
244
|
+
"url": url,
|
|
245
|
+
"title": "",
|
|
246
|
+
"content": "",
|
|
247
|
+
"raw_content": "",
|
|
248
|
+
"error": (
|
|
249
|
+
"TinyFish is not configured. Run `hermes tinyfish setup` "
|
|
250
|
+
"to configure MCP OAuth, or set TINYFISH_API_KEY for REST fallback." + suffix
|
|
251
|
+
),
|
|
252
|
+
}
|
|
253
|
+
for url in urls
|
|
254
|
+
]
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
raw = rest_client.fetch(urls, api_key=api_key, output_format=output_format)
|
|
258
|
+
return normalize_fetch_documents(raw, fallback_urls=urls)
|
|
259
|
+
except Exception as exc: # noqa: BLE001
|
|
260
|
+
logger.warning("TinyFish REST fetch failed: %s", exc)
|
|
261
|
+
detail = f"; MCP error: {mcp_error}" if mcp_error else ""
|
|
262
|
+
return [
|
|
263
|
+
{
|
|
264
|
+
"url": url,
|
|
265
|
+
"title": "",
|
|
266
|
+
"content": "",
|
|
267
|
+
"raw_content": "",
|
|
268
|
+
"error": f"TinyFish fetch failed: {exc}{detail}",
|
|
269
|
+
}
|
|
270
|
+
for url in urls
|
|
271
|
+
]
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""TinyFish REST API client used as the API-key fallback path."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
SEARCH_URL = "https://api.search.tinyfish.ai"
|
|
10
|
+
FETCH_URL = "https://api.fetch.tinyfish.ai"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TinyFishRestError(RuntimeError):
|
|
14
|
+
"""Raised for TinyFish REST transport or HTTP failures."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _headers(api_key: str) -> dict[str, str]:
|
|
18
|
+
return {"X-API-Key": api_key, "Accept": "application/json"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def search(query: str, *, api_key: str, timeout: float = 30.0) -> dict[str, Any]:
|
|
22
|
+
"""Run a TinyFish Search API query."""
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
response = httpx.get(
|
|
26
|
+
SEARCH_URL,
|
|
27
|
+
params={"query": query},
|
|
28
|
+
headers=_headers(api_key),
|
|
29
|
+
timeout=timeout,
|
|
30
|
+
)
|
|
31
|
+
response.raise_for_status()
|
|
32
|
+
return cast(dict[str, Any], response.json())
|
|
33
|
+
except httpx.HTTPStatusError as exc:
|
|
34
|
+
raise TinyFishRestError(f"TinyFish Search returned HTTP {exc.response.status_code}") from exc
|
|
35
|
+
except httpx.RequestError as exc:
|
|
36
|
+
raise TinyFishRestError(f"Could not reach TinyFish Search: {exc}") from exc
|
|
37
|
+
except ValueError as exc:
|
|
38
|
+
raise TinyFishRestError("TinyFish Search returned invalid JSON") from exc
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def fetch(
|
|
42
|
+
urls: list[str],
|
|
43
|
+
*,
|
|
44
|
+
api_key: str,
|
|
45
|
+
output_format: str = "markdown",
|
|
46
|
+
timeout: float = 60.0,
|
|
47
|
+
) -> dict[str, Any]:
|
|
48
|
+
"""Run the TinyFish Fetch API for one or more URLs."""
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
response = httpx.post(
|
|
52
|
+
FETCH_URL,
|
|
53
|
+
json={"urls": urls, "format": output_format},
|
|
54
|
+
headers=_headers(api_key),
|
|
55
|
+
timeout=timeout,
|
|
56
|
+
)
|
|
57
|
+
response.raise_for_status()
|
|
58
|
+
return cast(dict[str, Any], response.json())
|
|
59
|
+
except httpx.HTTPStatusError as exc:
|
|
60
|
+
raise TinyFishRestError(f"TinyFish Fetch returned HTTP {exc.response.status_code}") from exc
|
|
61
|
+
except httpx.RequestError as exc:
|
|
62
|
+
raise TinyFishRestError(f"Could not reach TinyFish Fetch: {exc}") from exc
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
raise TinyFishRestError("TinyFish Fetch returned invalid JSON") from exc
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""CLI helpers for ``hermes tinyfish``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from .provider import (
|
|
14
|
+
MCP_FETCH_TOOLS,
|
|
15
|
+
MCP_SEARCH_TOOLS,
|
|
16
|
+
TinyFishWebSearchProvider,
|
|
17
|
+
_discover_tinyfish_mcp_tools,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
TINYFISH_MCP_URL = "https://agent.tinyfish.ai/mcp"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def setup_tinyfish_cli(parser: argparse.ArgumentParser) -> None:
|
|
24
|
+
sub = parser.add_subparsers(dest="tinyfish_command")
|
|
25
|
+
|
|
26
|
+
setup = sub.add_parser("setup", help="Configure TinyFish for Hermes web search/extract")
|
|
27
|
+
setup.add_argument("--yes", "-y", action="store_true", help="Accept config changes without prompts")
|
|
28
|
+
setup.add_argument("--api-key", help="Save TINYFISH_API_KEY as REST fallback")
|
|
29
|
+
setup.add_argument("--skip-mcp", action="store_true", help="Do not configure TinyFish MCP OAuth")
|
|
30
|
+
setup.add_argument("--skip-login", action="store_true", help="Do not run `hermes mcp login tinyfish`")
|
|
31
|
+
setup.add_argument(
|
|
32
|
+
"--login", action="store_true", help="Run `hermes mcp login tinyfish` after writing config"
|
|
33
|
+
)
|
|
34
|
+
setup.add_argument(
|
|
35
|
+
"--no-web-backend", action="store_true", help="Do not set web.search_backend/extract_backend"
|
|
36
|
+
)
|
|
37
|
+
setup.add_argument("--live", action="store_true", help="Run live doctor checks after setup")
|
|
38
|
+
|
|
39
|
+
doctor = sub.add_parser("doctor", help="Check TinyFish plugin configuration")
|
|
40
|
+
doctor.add_argument("--json", action="store_true", help="Print machine-readable JSON")
|
|
41
|
+
doctor.add_argument("--live", action="store_true", help="Run a live search/fetch check")
|
|
42
|
+
|
|
43
|
+
status = sub.add_parser("status", help="Print non-secret TinyFish configuration status")
|
|
44
|
+
status.add_argument("--json", action="store_true", help="Print machine-readable JSON")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def dispatch_tinyfish_cli(args: argparse.Namespace) -> int:
|
|
48
|
+
command = getattr(args, "tinyfish_command", None) or "status"
|
|
49
|
+
if command == "setup":
|
|
50
|
+
return cmd_setup(args)
|
|
51
|
+
if command == "doctor":
|
|
52
|
+
return cmd_doctor(args)
|
|
53
|
+
if command == "status":
|
|
54
|
+
return cmd_status(args)
|
|
55
|
+
print("Usage: hermes tinyfish {setup,doctor,status}", file=sys.stderr)
|
|
56
|
+
return 2
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_config() -> dict[str, Any]:
|
|
60
|
+
from hermes_cli.config import load_config
|
|
61
|
+
|
|
62
|
+
return dict(load_config() or {})
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _save_config(config: dict[str, Any]) -> None:
|
|
66
|
+
from hermes_cli.config import save_config
|
|
67
|
+
|
|
68
|
+
save_config(config)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _get_env(name: str) -> str:
|
|
72
|
+
try:
|
|
73
|
+
from hermes_cli.config import get_env_value
|
|
74
|
+
|
|
75
|
+
return str(get_env_value(name) or "").strip()
|
|
76
|
+
except Exception:
|
|
77
|
+
return str(os.getenv(name, "") or "").strip()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _save_env(name: str, value: str) -> None:
|
|
81
|
+
from hermes_cli.config import save_env_value
|
|
82
|
+
|
|
83
|
+
save_env_value(name, value)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _hermes_home() -> Path:
|
|
87
|
+
try:
|
|
88
|
+
from hermes_constants import get_hermes_home
|
|
89
|
+
|
|
90
|
+
return Path(get_hermes_home())
|
|
91
|
+
except Exception:
|
|
92
|
+
return Path(os.getenv("HERMES_HOME") or Path.home() / ".hermes")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _confirm(question: str, *, default: bool = True, assume_yes: bool = False) -> bool:
|
|
96
|
+
if assume_yes:
|
|
97
|
+
return True
|
|
98
|
+
if not sys.stdin.isatty():
|
|
99
|
+
return default
|
|
100
|
+
suffix = "Y/n" if default else "y/N"
|
|
101
|
+
try:
|
|
102
|
+
answer = input(f"{question} [{suffix}]: ").strip().lower()
|
|
103
|
+
except (EOFError, KeyboardInterrupt):
|
|
104
|
+
print()
|
|
105
|
+
return default
|
|
106
|
+
if not answer:
|
|
107
|
+
return default
|
|
108
|
+
return answer in {"y", "yes"}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _prompt_secret(question: str) -> str:
|
|
112
|
+
import getpass
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
return getpass.getpass(question).strip()
|
|
116
|
+
except (EOFError, KeyboardInterrupt):
|
|
117
|
+
print()
|
|
118
|
+
return ""
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _apply_mcp_config(config: dict[str, Any]) -> None:
|
|
122
|
+
config.setdefault("mcp_servers", {})["tinyfish"] = {
|
|
123
|
+
"url": TINYFISH_MCP_URL,
|
|
124
|
+
"auth": "oauth",
|
|
125
|
+
"tools": {
|
|
126
|
+
"include": ["search", "fetch_content"],
|
|
127
|
+
"resources": False,
|
|
128
|
+
"prompts": False,
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _apply_web_backend_config(config: dict[str, Any]) -> None:
|
|
134
|
+
web = config.setdefault("web", {})
|
|
135
|
+
web["search_backend"] = "tinyfish"
|
|
136
|
+
web["extract_backend"] = "tinyfish"
|
|
137
|
+
if not web.get("backend"):
|
|
138
|
+
web["backend"] = "tinyfish"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cmd_setup(args: argparse.Namespace) -> int:
|
|
142
|
+
config = _load_config()
|
|
143
|
+
|
|
144
|
+
if not getattr(args, "skip_mcp", False):
|
|
145
|
+
_apply_mcp_config(config)
|
|
146
|
+
print("Configured TinyFish hosted MCP OAuth at https://agent.tinyfish.ai/mcp")
|
|
147
|
+
|
|
148
|
+
if not getattr(args, "no_web_backend", False) and _confirm(
|
|
149
|
+
"Set Hermes web.search_backend and web.extract_backend to tinyfish?",
|
|
150
|
+
default=True,
|
|
151
|
+
assume_yes=bool(getattr(args, "yes", False)),
|
|
152
|
+
):
|
|
153
|
+
_apply_web_backend_config(config)
|
|
154
|
+
print("Configured Hermes web backends to use TinyFish")
|
|
155
|
+
|
|
156
|
+
_save_config(config)
|
|
157
|
+
|
|
158
|
+
api_key = (getattr(args, "api_key", None) or "").strip()
|
|
159
|
+
if (
|
|
160
|
+
not api_key
|
|
161
|
+
and not _get_env("TINYFISH_API_KEY")
|
|
162
|
+
and sys.stdin.isatty()
|
|
163
|
+
and _confirm(
|
|
164
|
+
"Add TINYFISH_API_KEY as REST fallback now?",
|
|
165
|
+
default=False,
|
|
166
|
+
assume_yes=False,
|
|
167
|
+
)
|
|
168
|
+
):
|
|
169
|
+
api_key = _prompt_secret("TinyFish API key: ")
|
|
170
|
+
if api_key:
|
|
171
|
+
_save_env("TINYFISH_API_KEY", api_key)
|
|
172
|
+
print("Saved TINYFISH_API_KEY fallback in Hermes .env")
|
|
173
|
+
|
|
174
|
+
should_login = bool(getattr(args, "login", False))
|
|
175
|
+
if not should_login and not getattr(args, "skip_login", False) and sys.stdin.isatty():
|
|
176
|
+
should_login = _confirm("Run `hermes mcp login tinyfish` now?", default=True)
|
|
177
|
+
if should_login:
|
|
178
|
+
result = subprocess.run(["hermes", "mcp", "login", "tinyfish"], check=False)
|
|
179
|
+
if result.returncode != 0:
|
|
180
|
+
print("TinyFish MCP login did not complete. You can retry with `hermes mcp login tinyfish`.")
|
|
181
|
+
|
|
182
|
+
if getattr(args, "live", False):
|
|
183
|
+
doctor_args = argparse.Namespace(json=False, live=True)
|
|
184
|
+
return cmd_doctor(doctor_args)
|
|
185
|
+
|
|
186
|
+
print("Run `hermes tinyfish doctor --live` to verify the setup.")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _tool_names(*, discover_mcp: bool = False) -> set[str]:
|
|
191
|
+
if discover_mcp:
|
|
192
|
+
_discover_tinyfish_mcp_tools()
|
|
193
|
+
try:
|
|
194
|
+
from tools.registry import registry
|
|
195
|
+
|
|
196
|
+
return set(registry.get_all_tool_names())
|
|
197
|
+
except Exception:
|
|
198
|
+
return set()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def collect_status(*, live: bool = False) -> dict[str, Any]:
|
|
202
|
+
config = _load_config()
|
|
203
|
+
mcp_cfg = (config.get("mcp_servers") or {}).get("tinyfish") or {}
|
|
204
|
+
web_cfg = config.get("web") or {}
|
|
205
|
+
names = _tool_names(discover_mcp=live)
|
|
206
|
+
token_path = _hermes_home() / "mcp-tokens" / "tinyfish.json"
|
|
207
|
+
api_key_configured = bool(_get_env("TINYFISH_API_KEY"))
|
|
208
|
+
provider = TinyFishWebSearchProvider()
|
|
209
|
+
|
|
210
|
+
checks: dict[str, Any] = {
|
|
211
|
+
"plugin_loaded": True,
|
|
212
|
+
"provider_available": provider.is_available(),
|
|
213
|
+
"mcp_configured": bool(mcp_cfg.get("url") == TINYFISH_MCP_URL and mcp_cfg.get("auth") == "oauth"),
|
|
214
|
+
"mcp_search_tool_registered": any(name in names for name in MCP_SEARCH_TOOLS),
|
|
215
|
+
"mcp_fetch_tool_registered": any(name in names for name in MCP_FETCH_TOOLS),
|
|
216
|
+
"mcp_token_cached": token_path.exists(),
|
|
217
|
+
"api_key_fallback_configured": api_key_configured,
|
|
218
|
+
"web_search_backend": web_cfg.get("search_backend") or web_cfg.get("backend") or "",
|
|
219
|
+
"web_extract_backend": web_cfg.get("extract_backend") or web_cfg.get("backend") or "",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
checks["web_backend_configured"] = (
|
|
223
|
+
checks["web_search_backend"] == "tinyfish" and checks["web_extract_backend"] == "tinyfish"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if live:
|
|
227
|
+
search = provider.search("TinyFish web agent", limit=1)
|
|
228
|
+
checks["live_search_ok"] = bool(search.get("success") and search.get("data", {}).get("web"))
|
|
229
|
+
fetch = provider.extract(["https://docs.tinyfish.ai/"], format="markdown")
|
|
230
|
+
checks["live_fetch_ok"] = bool(fetch and not fetch[0].get("error"))
|
|
231
|
+
|
|
232
|
+
checks["ok"] = bool(
|
|
233
|
+
checks["web_backend_configured"]
|
|
234
|
+
and (checks["mcp_configured"] or checks["api_key_fallback_configured"])
|
|
235
|
+
)
|
|
236
|
+
return checks
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _print_status(status: dict[str, Any]) -> None:
|
|
240
|
+
print("TinyFish Hermes plugin status")
|
|
241
|
+
for key in sorted(status):
|
|
242
|
+
value = status[key]
|
|
243
|
+
if isinstance(value, bool):
|
|
244
|
+
value = "yes" if value else "no"
|
|
245
|
+
print(f" {key}: {value}")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
249
|
+
status = collect_status(live=False)
|
|
250
|
+
if getattr(args, "json", False):
|
|
251
|
+
print(json.dumps(status, indent=2, sort_keys=True))
|
|
252
|
+
else:
|
|
253
|
+
_print_status(status)
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def cmd_doctor(args: argparse.Namespace) -> int:
|
|
258
|
+
status = collect_status(live=bool(getattr(args, "live", False)))
|
|
259
|
+
if getattr(args, "json", False):
|
|
260
|
+
print(json.dumps(status, indent=2, sort_keys=True))
|
|
261
|
+
else:
|
|
262
|
+
_print_status(status)
|
|
263
|
+
if not status["ok"]:
|
|
264
|
+
print()
|
|
265
|
+
print("Recommended next step: run `hermes tinyfish setup`.")
|
|
266
|
+
return 0 if status["ok"] else 1
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hermes-plugin-tinyfish
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: TinyFish web search and extraction provider plugin for Hermes Agent
|
|
5
|
+
Project-URL: Homepage, https://github.com/gabeosx/hermes-plugin-tinyfish
|
|
6
|
+
Project-URL: Documentation, https://github.com/gabeosx/hermes-plugin-tinyfish#readme
|
|
7
|
+
Project-URL: Issues, https://github.com/gabeosx/hermes-plugin-tinyfish/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/gabeosx/hermes-plugin-tinyfish/blob/main/CHANGELOG.md
|
|
9
|
+
Author: gabeosx
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: hermes-agent,mcp,tinyfish,web-extraction,web-search
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
25
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.2; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Hermes TinyFish Plugin
|
|
32
|
+
|
|
33
|
+
[](https://github.com/gabeosx/hermes-plugin-tinyfish/actions/workflows/ci.yml)
|
|
34
|
+
[](https://github.com/gabeosx/hermes-plugin-tinyfish/releases)
|
|
35
|
+
[](pyproject.toml)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[](SECURITY.md)
|
|
38
|
+
|
|
39
|
+
TinyFish web search and content extraction for [Hermes Agent](https://hermes-agent.nousresearch.com/docs/).
|
|
40
|
+
|
|
41
|
+
The plugin adds `tinyfish` as a native Hermes `web_search` and `web_extract`
|
|
42
|
+
backend. It prefers TinyFish's hosted OAuth MCP server and falls back to direct
|
|
43
|
+
REST API calls with `TINYFISH_API_KEY` when MCP OAuth is unavailable.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
Hermes Git plugin install:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
hermes plugins install gabeosx/hermes-plugin-tinyfish --enable
|
|
51
|
+
hermes tinyfish setup
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Hermes' Git plugin installer currently clones the repository default branch.
|
|
55
|
+
Stable release artifacts are published on the
|
|
56
|
+
[GitHub Releases page](https://github.com/gabeosx/hermes-plugin-tinyfish/releases).
|
|
57
|
+
|
|
58
|
+
PyPI install is planned after PyPI Trusted Publishing is configured:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install hermes-plugin-tinyfish
|
|
62
|
+
hermes plugins enable web-tinyfish
|
|
63
|
+
hermes tinyfish setup
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## What Setup Does
|
|
67
|
+
|
|
68
|
+
`hermes tinyfish setup` writes ordinary user configuration only:
|
|
69
|
+
|
|
70
|
+
```yaml
|
|
71
|
+
mcp_servers:
|
|
72
|
+
tinyfish:
|
|
73
|
+
url: https://agent.tinyfish.ai/mcp
|
|
74
|
+
auth: oauth
|
|
75
|
+
tools:
|
|
76
|
+
include: [search, fetch_content]
|
|
77
|
+
resources: false
|
|
78
|
+
prompts: false
|
|
79
|
+
|
|
80
|
+
web:
|
|
81
|
+
search_backend: tinyfish
|
|
82
|
+
extract_backend: tinyfish
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
It may also save `TINYFISH_API_KEY` to `~/.hermes/.env` if you choose to add
|
|
86
|
+
an API-key fallback.
|
|
87
|
+
|
|
88
|
+
The plugin does not patch Hermes Agent, update scripts, Dockerfiles, or any
|
|
89
|
+
files inside a Hermes checkout.
|
|
90
|
+
|
|
91
|
+
## Verify
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
hermes tinyfish doctor
|
|
95
|
+
hermes tinyfish doctor --live
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`doctor --live` performs a real TinyFish search and fetch. It requires either
|
|
99
|
+
working MCP OAuth or `TINYFISH_API_KEY`.
|
|
100
|
+
|
|
101
|
+
## Manual Configuration
|
|
102
|
+
|
|
103
|
+
OAuth MCP only:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
hermes mcp add tinyfish --url https://agent.tinyfish.ai/mcp --auth oauth
|
|
107
|
+
hermes mcp login tinyfish
|
|
108
|
+
hermes mcp configure tinyfish
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Then set:
|
|
112
|
+
|
|
113
|
+
```yaml
|
|
114
|
+
web:
|
|
115
|
+
search_backend: tinyfish
|
|
116
|
+
extract_backend: tinyfish
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
REST fallback only:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
export TINYFISH_API_KEY="..."
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Upgrade Safety
|
|
126
|
+
|
|
127
|
+
This plugin uses public Hermes extension points:
|
|
128
|
+
|
|
129
|
+
- `ctx.register_web_search_provider(...)`
|
|
130
|
+
- `ctx.register_cli_command(...)`
|
|
131
|
+
- Hermes MCP config under `mcp_servers`
|
|
132
|
+
- Hermes `.env` config helpers for optional API-key fallback
|
|
133
|
+
|
|
134
|
+
Because it is installed as a user plugin or Python entry point, normal
|
|
135
|
+
`hermes update` operations do not overwrite it.
|
|
136
|
+
|
|
137
|
+
## Development
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
python -m venv .venv
|
|
141
|
+
source .venv/bin/activate
|
|
142
|
+
python -m pip install -U pip
|
|
143
|
+
python -m pip install -e ".[dev]"
|
|
144
|
+
ruff format .
|
|
145
|
+
ruff check .
|
|
146
|
+
mypy hermes_plugin_tinyfish
|
|
147
|
+
pytest
|
|
148
|
+
python -m build
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Live tests are opt-in:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
TINYFISH_LIVE_TESTS=1 TINYFISH_API_KEY=... pytest tests/test_live.py
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## References
|
|
158
|
+
|
|
159
|
+
- [TinyFish MCP Integration](https://docs.tinyfish.ai/mcp-integration)
|
|
160
|
+
- [TinyFish Authentication](https://docs.tinyfish.ai/authentication)
|
|
161
|
+
- [TinyFish Search API](https://docs.tinyfish.ai/search-api)
|
|
162
|
+
- [TinyFish Fetch API](https://docs.tinyfish.ai/fetch-api)
|
|
163
|
+
- [Hermes Web Search Provider Plugins](https://hermes-agent.nousresearch.com/docs/developer-guide/web-search-provider-plugin)
|
|
164
|
+
- [Hermes Plugins](https://hermes-agent.nousresearch.com/docs/user-guide/features/plugins)
|
|
165
|
+
- [Hermes MCP](https://hermes-agent.nousresearch.com/docs/user-guide/features/mcp)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
hermes_plugin_tinyfish/__init__.py,sha256=kYdF_yi9sAGVi-3CitDlIXDaP-z7OF-AOIg5XB2v9Vk,1069
|
|
2
|
+
hermes_plugin_tinyfish/normalize.py,sha256=UR7iwSC57wks88GnvSSWzXWImGOKvWpHVW0imcbFJ8M,5899
|
|
3
|
+
hermes_plugin_tinyfish/provider.py,sha256=5rvkpse7hu2KhCBt54JPmDUZaW3rpprrIltjZcaHQiE,9003
|
|
4
|
+
hermes_plugin_tinyfish/rest_client.py,sha256=bHVpF0CJ-T5kSoFCL1O97P7YAyuyQwSOVxefRbQI7Pk,2091
|
|
5
|
+
hermes_plugin_tinyfish/setup_cli.py,sha256=WbDa61oPQzGQYJMCtyxoXUUJb2YBrL6QtubR-00Tn4U,9031
|
|
6
|
+
hermes_plugin_tinyfish-0.1.3.dist-info/METADATA,sha256=fTZb-8VE79MlGO5k4_tgUeAHClkh5zp3op1STNaJBBk,5121
|
|
7
|
+
hermes_plugin_tinyfish-0.1.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
hermes_plugin_tinyfish-0.1.3.dist-info/entry_points.txt,sha256=qWGzby0SgzF97ICje8dr15LZyA-PBexEeC96FmruWpg,61
|
|
9
|
+
hermes_plugin_tinyfish-0.1.3.dist-info/licenses/LICENSE,sha256=hy8tmDdq46UUrOKt91rKGzBVtHnQs2Qoqt7i-UtL3m8,1064
|
|
10
|
+
hermes_plugin_tinyfish-0.1.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gabeosx
|
|
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.
|