watchline-hermes-plugin 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.
- watchline_hermes_plugin-0.1.0/LICENSE +21 -0
- watchline_hermes_plugin-0.1.0/PKG-INFO +126 -0
- watchline_hermes_plugin-0.1.0/README.md +100 -0
- watchline_hermes_plugin-0.1.0/pyproject.toml +48 -0
- watchline_hermes_plugin-0.1.0/setup.cfg +4 -0
- watchline_hermes_plugin-0.1.0/tests/test_config.py +26 -0
- watchline_hermes_plugin-0.1.0/tests/test_delivery.py +29 -0
- watchline_hermes_plugin-0.1.0/watchline_hermes_plugin/__init__.py +1 -0
- watchline_hermes_plugin-0.1.0/watchline_hermes_plugin/adapter.py +281 -0
- watchline_hermes_plugin-0.1.0/watchline_hermes_plugin/client.py +57 -0
- watchline_hermes_plugin-0.1.0/watchline_hermes_plugin/config.py +215 -0
- watchline_hermes_plugin-0.1.0/watchline_hermes_plugin/delivery.py +82 -0
- watchline_hermes_plugin-0.1.0/watchline_hermes_plugin/watchline_cli.py +116 -0
- watchline_hermes_plugin-0.1.0/watchline_hermes_plugin.egg-info/PKG-INFO +126 -0
- watchline_hermes_plugin-0.1.0/watchline_hermes_plugin.egg-info/SOURCES.txt +17 -0
- watchline_hermes_plugin-0.1.0/watchline_hermes_plugin.egg-info/dependency_links.txt +1 -0
- watchline_hermes_plugin-0.1.0/watchline_hermes_plugin.egg-info/entry_points.txt +2 -0
- watchline_hermes_plugin-0.1.0/watchline_hermes_plugin.egg-info/requires.txt +1 -0
- watchline_hermes_plugin-0.1.0/watchline_hermes_plugin.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Watchline
|
|
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,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watchline-hermes-plugin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hermes Agent delivery adapter for Watchline.
|
|
5
|
+
Author-email: Watchline <support@qordinate.ai>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://watch.qordinate.ai
|
|
8
|
+
Project-URL: Documentation, https://watch.qordinate.ai/docs/hermes
|
|
9
|
+
Project-URL: Repository, https://github.com/qordinate-ai/watchline-hermes-plugin
|
|
10
|
+
Project-URL: Issues, https://github.com/qordinate-ai/watchline-hermes-plugin/issues
|
|
11
|
+
Keywords: watchline,hermes-agent,agents,mcp,plugin,pull-delivery
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Communications
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: PyYAML>=6
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# Watchline for Hermes Agent
|
|
28
|
+
|
|
29
|
+
Watchline is an event layer for agents. This Hermes plugin delivers matched
|
|
30
|
+
Watchline events into a local Hermes gateway without exposing your laptop to
|
|
31
|
+
the internet.
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
Watchline uses two integration planes:
|
|
36
|
+
|
|
37
|
+
- **Hosted MCP** gives Hermes the watch tools: `start_watch`,
|
|
38
|
+
`continue_watch`, `list_watches`, `pause_watch`, `resume_watch`, and
|
|
39
|
+
`delete_watch`.
|
|
40
|
+
- **This plugin** registers a Hermes gateway platform named `watchline`. It
|
|
41
|
+
polls a Watchline pull channel and forwards matched events as inbound Hermes
|
|
42
|
+
messages.
|
|
43
|
+
|
|
44
|
+
The plugin intentionally does not duplicate Watchline tools. Hermes discovers
|
|
45
|
+
tools from the hosted MCP server, and this adapter only handles local delivery.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
hermes plugins install qordinate-ai/watchline-hermes-plugin --enable
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
After the first PyPI release, the package can also be installed directly:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -m pip install watchline-hermes-plugin
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For local development:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
hermes plugins install file:///absolute/path/to/watchline-hermes-plugin --enable
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Configure
|
|
66
|
+
|
|
67
|
+
Create a Watchline API key and pull channel at <https://watch.qordinate.ai>,
|
|
68
|
+
then run:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
hermes watchline configure \
|
|
72
|
+
--api-key wl_... \
|
|
73
|
+
--channel-id ch_... \
|
|
74
|
+
--user-id me \
|
|
75
|
+
--delivery-channel main \
|
|
76
|
+
--api-base-url https://api.watch.qordinate.ai
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The command writes:
|
|
80
|
+
|
|
81
|
+
- `gateway.platforms.watchline` for delivery.
|
|
82
|
+
- `mcp_servers.watchline` for hosted watch tools.
|
|
83
|
+
|
|
84
|
+
Restart the Hermes gateway after changing plugin or MCP config:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
hermes gateway restart
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Use
|
|
91
|
+
|
|
92
|
+
Ask Hermes to use Watchline:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
Use the Watchline start_watch tool to watch Gmail for emails from my boss.
|
|
96
|
+
Ask me for the sender email address if needed.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
When a matching event arrives, Watchline queues it on your pull channel. The
|
|
100
|
+
plugin polls that channel, runs Hermes against your main/home channel, and
|
|
101
|
+
acknowledges the delivery only after Hermes accepts it.
|
|
102
|
+
|
|
103
|
+
## Commands
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
hermes watchline status
|
|
107
|
+
hermes watchline install-mcp
|
|
108
|
+
hermes watchline preview-delivery
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Configuration Reference
|
|
112
|
+
|
|
113
|
+
| Field | Required | Description |
|
|
114
|
+
| ----------------------- | -------- | ------------------------------------------------ |
|
|
115
|
+
| `api_key` | Yes | Watchline project API key. |
|
|
116
|
+
| `channel_id` | Yes | Watchline pull channel ID. |
|
|
117
|
+
| `user_id` | No | Stable Watchline user id. Defaults to `me`. |
|
|
118
|
+
| `api_base_url` | No | Watchline API base URL. Defaults to production. |
|
|
119
|
+
| `poll_interval_seconds` | No | Delivery polling interval. Minimum is 5 seconds. |
|
|
120
|
+
| `delivery_channel` | No | Hermes delivery target. Defaults to `main`, the first configured home channel. Use `telegram`, `discord`, or `telegram:<chat_id>[:thread_id]` to override. |
|
|
121
|
+
|
|
122
|
+
## Links
|
|
123
|
+
|
|
124
|
+
- Dashboard: <https://watch.qordinate.ai>
|
|
125
|
+
- API: <https://api.watch.qordinate.ai>
|
|
126
|
+
- Source: <https://github.com/qordinate-ai/watchline-hermes-plugin>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Watchline for Hermes Agent
|
|
2
|
+
|
|
3
|
+
Watchline is an event layer for agents. This Hermes plugin delivers matched
|
|
4
|
+
Watchline events into a local Hermes gateway without exposing your laptop to
|
|
5
|
+
the internet.
|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
9
|
+
Watchline uses two integration planes:
|
|
10
|
+
|
|
11
|
+
- **Hosted MCP** gives Hermes the watch tools: `start_watch`,
|
|
12
|
+
`continue_watch`, `list_watches`, `pause_watch`, `resume_watch`, and
|
|
13
|
+
`delete_watch`.
|
|
14
|
+
- **This plugin** registers a Hermes gateway platform named `watchline`. It
|
|
15
|
+
polls a Watchline pull channel and forwards matched events as inbound Hermes
|
|
16
|
+
messages.
|
|
17
|
+
|
|
18
|
+
The plugin intentionally does not duplicate Watchline tools. Hermes discovers
|
|
19
|
+
tools from the hosted MCP server, and this adapter only handles local delivery.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
hermes plugins install qordinate-ai/watchline-hermes-plugin --enable
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
After the first PyPI release, the package can also be installed directly:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
python -m pip install watchline-hermes-plugin
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For local development:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
hermes plugins install file:///absolute/path/to/watchline-hermes-plugin --enable
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Configure
|
|
40
|
+
|
|
41
|
+
Create a Watchline API key and pull channel at <https://watch.qordinate.ai>,
|
|
42
|
+
then run:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
hermes watchline configure \
|
|
46
|
+
--api-key wl_... \
|
|
47
|
+
--channel-id ch_... \
|
|
48
|
+
--user-id me \
|
|
49
|
+
--delivery-channel main \
|
|
50
|
+
--api-base-url https://api.watch.qordinate.ai
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The command writes:
|
|
54
|
+
|
|
55
|
+
- `gateway.platforms.watchline` for delivery.
|
|
56
|
+
- `mcp_servers.watchline` for hosted watch tools.
|
|
57
|
+
|
|
58
|
+
Restart the Hermes gateway after changing plugin or MCP config:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
hermes gateway restart
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Use
|
|
65
|
+
|
|
66
|
+
Ask Hermes to use Watchline:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
Use the Watchline start_watch tool to watch Gmail for emails from my boss.
|
|
70
|
+
Ask me for the sender email address if needed.
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
When a matching event arrives, Watchline queues it on your pull channel. The
|
|
74
|
+
plugin polls that channel, runs Hermes against your main/home channel, and
|
|
75
|
+
acknowledges the delivery only after Hermes accepts it.
|
|
76
|
+
|
|
77
|
+
## Commands
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
hermes watchline status
|
|
81
|
+
hermes watchline install-mcp
|
|
82
|
+
hermes watchline preview-delivery
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Configuration Reference
|
|
86
|
+
|
|
87
|
+
| Field | Required | Description |
|
|
88
|
+
| ----------------------- | -------- | ------------------------------------------------ |
|
|
89
|
+
| `api_key` | Yes | Watchline project API key. |
|
|
90
|
+
| `channel_id` | Yes | Watchline pull channel ID. |
|
|
91
|
+
| `user_id` | No | Stable Watchline user id. Defaults to `me`. |
|
|
92
|
+
| `api_base_url` | No | Watchline API base URL. Defaults to production. |
|
|
93
|
+
| `poll_interval_seconds` | No | Delivery polling interval. Minimum is 5 seconds. |
|
|
94
|
+
| `delivery_channel` | No | Hermes delivery target. Defaults to `main`, the first configured home channel. Use `telegram`, `discord`, or `telegram:<chat_id>[:thread_id]` to override. |
|
|
95
|
+
|
|
96
|
+
## Links
|
|
97
|
+
|
|
98
|
+
- Dashboard: <https://watch.qordinate.ai>
|
|
99
|
+
- API: <https://api.watch.qordinate.ai>
|
|
100
|
+
- Source: <https://github.com/qordinate-ai/watchline-hermes-plugin>
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "watchline-hermes-plugin"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Hermes Agent delivery adapter for Watchline."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Watchline", email = "support@qordinate.ai" }]
|
|
10
|
+
keywords = ["watchline", "hermes-agent", "agents", "mcp", "plugin", "pull-delivery"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.10",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Topic :: Communications",
|
|
20
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["PyYAML>=6"]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://watch.qordinate.ai"
|
|
26
|
+
Documentation = "https://watch.qordinate.ai/docs/hermes"
|
|
27
|
+
Repository = "https://github.com/qordinate-ai/watchline-hermes-plugin"
|
|
28
|
+
Issues = "https://github.com/qordinate-ai/watchline-hermes-plugin/issues"
|
|
29
|
+
|
|
30
|
+
[project.entry-points."hermes_agent.plugins"]
|
|
31
|
+
watchline = "watchline_hermes_plugin.adapter"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["setuptools>=77"]
|
|
35
|
+
build-backend = "setuptools.build_meta"
|
|
36
|
+
|
|
37
|
+
[tool.setuptools]
|
|
38
|
+
packages = ["watchline_hermes_plugin"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 100
|
|
42
|
+
|
|
43
|
+
[tool.ruff.lint]
|
|
44
|
+
select = ["E", "F", "I", "UP", "B", "SIM"]
|
|
45
|
+
|
|
46
|
+
[tool.pytest.ini_options]
|
|
47
|
+
addopts = ["--import-mode=importlib"]
|
|
48
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import tempfile
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from watchline_hermes_plugin.config import (
|
|
5
|
+
load_hermes_config,
|
|
6
|
+
patch_mcp_config,
|
|
7
|
+
patch_watchline_config,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_configure_writes_platform_and_mcp_config():
|
|
12
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
13
|
+
path = Path(tmp) / "config.yaml"
|
|
14
|
+
config = patch_watchline_config(
|
|
15
|
+
api_key="wl_test",
|
|
16
|
+
channel_id="ch_test",
|
|
17
|
+
user_id="me",
|
|
18
|
+
path=path,
|
|
19
|
+
)
|
|
20
|
+
patch_mcp_config(config, path=path)
|
|
21
|
+
|
|
22
|
+
data = load_hermes_config(path)
|
|
23
|
+
assert "watchline" in data["plugins"]["enabled"]
|
|
24
|
+
assert data["gateway"]["platforms"]["watchline"]["extra"]["channel_id"] == "ch_test"
|
|
25
|
+
assert data["mcp_servers"]["watchline"]["headers"]["x-watchline-channel-id"] == "ch_test"
|
|
26
|
+
assert data["gateway"]["platforms"]["watchline"]["extra"]["delivery_channel"] == "main"
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from watchline_hermes_plugin.delivery import format_delivery, parse_delivery_channel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_match_delivery_text_contains_intent_and_event():
|
|
5
|
+
text = format_delivery(
|
|
6
|
+
{
|
|
7
|
+
"type": "watchline.match",
|
|
8
|
+
"intent": "emails from my boss",
|
|
9
|
+
"event": {"subject": "Launch plan"},
|
|
10
|
+
},
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
assert "Matched event with user intent: emails from my boss" in text
|
|
14
|
+
assert '"subject": "Launch plan"' in text
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_parse_default_delivery_channel():
|
|
18
|
+
channel = parse_delivery_channel("main")
|
|
19
|
+
|
|
20
|
+
assert channel.is_main
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_parse_explicit_delivery_channel():
|
|
24
|
+
channel = parse_delivery_channel("telegram:123:456")
|
|
25
|
+
|
|
26
|
+
assert not channel.is_main
|
|
27
|
+
assert channel.platform == "telegram"
|
|
28
|
+
assert channel.chat_id == "123"
|
|
29
|
+
assert channel.thread_id == "456"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Watchline Hermes plugin package."""
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Hermes platform adapter for Watchline pull delivery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from gateway.config import Platform, PlatformConfig
|
|
13
|
+
from gateway.platforms.base import (
|
|
14
|
+
BasePlatformAdapter,
|
|
15
|
+
MessageEvent,
|
|
16
|
+
MessageType,
|
|
17
|
+
SendResult,
|
|
18
|
+
)
|
|
19
|
+
from gateway.session import SessionSource
|
|
20
|
+
|
|
21
|
+
from watchline_hermes_plugin.client import WatchlineClient
|
|
22
|
+
from watchline_hermes_plugin.config import (
|
|
23
|
+
DEFAULT_API_BASE_URL,
|
|
24
|
+
WATCHLINE_PLATFORM_NAME,
|
|
25
|
+
config_from_platform,
|
|
26
|
+
normalize_config,
|
|
27
|
+
)
|
|
28
|
+
from watchline_hermes_plugin.delivery import delivery_id, format_delivery, parse_delivery_channel
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class WatchlinePlatformAdapter(BasePlatformAdapter):
|
|
34
|
+
"""Pull Watchline deliveries and feed them into Hermes' normal gateway."""
|
|
35
|
+
|
|
36
|
+
def __init__(self, config: PlatformConfig):
|
|
37
|
+
super().__init__(config=config, platform=Platform(WATCHLINE_PLATFORM_NAME))
|
|
38
|
+
self.watchline_config = config_from_platform(config)
|
|
39
|
+
self.client = WatchlineClient(self.watchline_config)
|
|
40
|
+
self._poll_task: asyncio.Task | None = None
|
|
41
|
+
self.gateway_runner: Any | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def name(self) -> str:
|
|
45
|
+
return "Watchline"
|
|
46
|
+
|
|
47
|
+
async def connect(self) -> bool:
|
|
48
|
+
self._running = True
|
|
49
|
+
self._poll_task = asyncio.create_task(self._poll_loop())
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
async def disconnect(self) -> None:
|
|
53
|
+
self._running = False
|
|
54
|
+
if self._poll_task:
|
|
55
|
+
self._poll_task.cancel()
|
|
56
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
57
|
+
await self._poll_task
|
|
58
|
+
|
|
59
|
+
async def send(
|
|
60
|
+
self,
|
|
61
|
+
chat_id: str,
|
|
62
|
+
content: str,
|
|
63
|
+
reply_to: str | None = None,
|
|
64
|
+
metadata: dict[str, Any] | None = None,
|
|
65
|
+
) -> SendResult:
|
|
66
|
+
return SendResult(
|
|
67
|
+
success=False,
|
|
68
|
+
error=(
|
|
69
|
+
"Watchline is an inbound delivery adapter and cannot send "
|
|
70
|
+
"messages back to Watchline."
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
async def _poll_loop(self) -> None:
|
|
75
|
+
while self._running:
|
|
76
|
+
try:
|
|
77
|
+
delivered = await self._poll_once()
|
|
78
|
+
if delivered:
|
|
79
|
+
logger.info("Watchline delivered %s event(s) into Hermes", delivered)
|
|
80
|
+
except Exception as error:
|
|
81
|
+
logger.warning("Watchline delivery poll failed: %s", error)
|
|
82
|
+
await asyncio.sleep(self.watchline_config.poll_interval_seconds)
|
|
83
|
+
|
|
84
|
+
async def _poll_once(self) -> int:
|
|
85
|
+
cursor: str | None = None
|
|
86
|
+
ack_ids: list[str] = []
|
|
87
|
+
delivered = 0
|
|
88
|
+
while True:
|
|
89
|
+
response = await asyncio.to_thread(self.client.pending, limit=50, cursor=cursor)
|
|
90
|
+
for item in response.get("data", []):
|
|
91
|
+
if isinstance(item, dict):
|
|
92
|
+
await self._deliver(item)
|
|
93
|
+
delivery_public_id = delivery_id(item)
|
|
94
|
+
if delivery_public_id:
|
|
95
|
+
ack_ids.append(delivery_public_id)
|
|
96
|
+
delivered += 1
|
|
97
|
+
next_cursor = response.get("next_cursor")
|
|
98
|
+
cursor = next_cursor if isinstance(next_cursor, str) and next_cursor else None
|
|
99
|
+
if not cursor:
|
|
100
|
+
break
|
|
101
|
+
await asyncio.to_thread(self.client.ack, ack_ids)
|
|
102
|
+
return delivered
|
|
103
|
+
|
|
104
|
+
async def _deliver(self, delivery: dict[str, Any]) -> None:
|
|
105
|
+
if not self.gateway_runner:
|
|
106
|
+
raise RuntimeError("Hermes gateway runner is not ready.")
|
|
107
|
+
if not self._message_handler:
|
|
108
|
+
raise RuntimeError("Hermes gateway message handler is not ready.")
|
|
109
|
+
source = self._resolve_delivery_source(delivery)
|
|
110
|
+
message_id = delivery_id(delivery) or None
|
|
111
|
+
event = MessageEvent(
|
|
112
|
+
text=format_delivery(delivery),
|
|
113
|
+
message_type=MessageType.TEXT,
|
|
114
|
+
source=source,
|
|
115
|
+
raw_message=delivery,
|
|
116
|
+
message_id=message_id,
|
|
117
|
+
timestamp=datetime.now(),
|
|
118
|
+
internal=True,
|
|
119
|
+
)
|
|
120
|
+
response_text = await self.gateway_runner._handle_message(event)
|
|
121
|
+
if not response_text:
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
adapter = self.gateway_runner.adapters.get(source.platform)
|
|
125
|
+
if not adapter:
|
|
126
|
+
raise RuntimeError(f"Hermes adapter for {source.platform.value} is not active.")
|
|
127
|
+
metadata = {"thread_id": source.thread_id} if source.thread_id else None
|
|
128
|
+
result = await adapter.send(
|
|
129
|
+
chat_id=source.chat_id,
|
|
130
|
+
content=response_text,
|
|
131
|
+
metadata=metadata,
|
|
132
|
+
)
|
|
133
|
+
if not getattr(result, "success", False):
|
|
134
|
+
raise RuntimeError(getattr(result, "error", "Hermes send returned success=False"))
|
|
135
|
+
|
|
136
|
+
def _resolve_delivery_source(self, delivery: dict[str, Any]) -> SessionSource:
|
|
137
|
+
channel = parse_delivery_channel(self.watchline_config.delivery_channel)
|
|
138
|
+
if channel.is_main:
|
|
139
|
+
platform, chat_id, chat_name, thread_id = self._main_home_channel()
|
|
140
|
+
else:
|
|
141
|
+
platform = Platform(channel.platform or "")
|
|
142
|
+
if channel.chat_id:
|
|
143
|
+
chat_id = channel.chat_id
|
|
144
|
+
chat_name = channel.chat_id
|
|
145
|
+
thread_id = channel.thread_id
|
|
146
|
+
else:
|
|
147
|
+
home = self.gateway_runner.config.get_home_channel(platform)
|
|
148
|
+
if not home or not home.chat_id:
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
f"No Hermes home channel configured for {platform.value}; "
|
|
151
|
+
"run /sethome there or set watchline.delivery_channel to "
|
|
152
|
+
f"{platform.value}:<chat_id>."
|
|
153
|
+
)
|
|
154
|
+
chat_id = str(home.chat_id)
|
|
155
|
+
chat_name = home.name
|
|
156
|
+
thread_id = str(home.thread_id) if home.thread_id else None
|
|
157
|
+
|
|
158
|
+
message_id = delivery_id(delivery) or None
|
|
159
|
+
return SessionSource(
|
|
160
|
+
platform=platform,
|
|
161
|
+
chat_id=chat_id,
|
|
162
|
+
chat_name=chat_name,
|
|
163
|
+
chat_type="dm",
|
|
164
|
+
user_id="system:watchline",
|
|
165
|
+
user_name="Watchline",
|
|
166
|
+
thread_id=thread_id,
|
|
167
|
+
message_id=message_id,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _main_home_channel(self) -> tuple[Platform, str, str, str | None]:
|
|
171
|
+
excluded = {
|
|
172
|
+
self.platform,
|
|
173
|
+
Platform.LOCAL,
|
|
174
|
+
Platform.API_SERVER,
|
|
175
|
+
Platform.WEBHOOK,
|
|
176
|
+
Platform.MSGRAPH_WEBHOOK,
|
|
177
|
+
}
|
|
178
|
+
for platform in self.gateway_runner.adapters:
|
|
179
|
+
if platform in excluded:
|
|
180
|
+
continue
|
|
181
|
+
home = self.gateway_runner.config.get_home_channel(platform)
|
|
182
|
+
if home and home.chat_id:
|
|
183
|
+
return (
|
|
184
|
+
platform,
|
|
185
|
+
str(home.chat_id),
|
|
186
|
+
home.name,
|
|
187
|
+
str(home.thread_id) if home.thread_id else None,
|
|
188
|
+
)
|
|
189
|
+
raise RuntimeError(
|
|
190
|
+
"No Hermes main/home channel configured for Watchline delivery. "
|
|
191
|
+
"Run /sethome in the destination chat or set watchline.delivery_channel "
|
|
192
|
+
"to <platform>:<chat_id>."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def check_requirements() -> bool:
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def validate_config(config: Any) -> bool:
|
|
201
|
+
try:
|
|
202
|
+
config_from_platform(config)
|
|
203
|
+
return True
|
|
204
|
+
except Exception:
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def env_enablement() -> dict[str, Any] | None:
|
|
209
|
+
api_key = os.getenv("WATCHLINE_API_KEY", "").strip()
|
|
210
|
+
channel_id = os.getenv("WATCHLINE_CHANNEL_ID", "").strip()
|
|
211
|
+
if not api_key or not channel_id:
|
|
212
|
+
return None
|
|
213
|
+
return {
|
|
214
|
+
"api_key": api_key,
|
|
215
|
+
"channel_id": channel_id,
|
|
216
|
+
"user_id": os.getenv("WATCHLINE_USER_ID", "me").strip() or "me",
|
|
217
|
+
"api_base_url": os.getenv("WATCHLINE_API_BASE_URL", DEFAULT_API_BASE_URL).strip()
|
|
218
|
+
or DEFAULT_API_BASE_URL,
|
|
219
|
+
"delivery_channel": os.getenv("WATCHLINE_DELIVERY_CHANNEL", "main").strip() or "main",
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _apply_yaml_config(
|
|
224
|
+
yaml_cfg: dict[str, Any],
|
|
225
|
+
platform_cfg: dict[str, Any],
|
|
226
|
+
) -> dict[str, Any] | None:
|
|
227
|
+
gateway_platforms = (
|
|
228
|
+
yaml_cfg.get("gateway", {}).get("platforms", {})
|
|
229
|
+
if isinstance(yaml_cfg.get("gateway"), dict)
|
|
230
|
+
else {}
|
|
231
|
+
)
|
|
232
|
+
raw = (
|
|
233
|
+
gateway_platforms.get(WATCHLINE_PLATFORM_NAME, {})
|
|
234
|
+
if isinstance(gateway_platforms, dict)
|
|
235
|
+
else {}
|
|
236
|
+
)
|
|
237
|
+
extra = raw.get("extra", {}) if isinstance(raw, dict) else {}
|
|
238
|
+
try:
|
|
239
|
+
normalized = normalize_config(extra)
|
|
240
|
+
except Exception:
|
|
241
|
+
return None
|
|
242
|
+
return {
|
|
243
|
+
"api_key": normalized.api_key,
|
|
244
|
+
"channel_id": normalized.channel_id,
|
|
245
|
+
"user_id": normalized.user_id,
|
|
246
|
+
"api_base_url": normalized.api_base_url,
|
|
247
|
+
"poll_interval_seconds": normalized.poll_interval_seconds,
|
|
248
|
+
"delivery_channel": normalized.delivery_channel,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def register(ctx: Any) -> None:
|
|
253
|
+
ctx.register_platform(
|
|
254
|
+
name=WATCHLINE_PLATFORM_NAME,
|
|
255
|
+
label="Watchline",
|
|
256
|
+
adapter_factory=lambda cfg: WatchlinePlatformAdapter(cfg),
|
|
257
|
+
check_fn=check_requirements,
|
|
258
|
+
validate_config=validate_config,
|
|
259
|
+
is_connected=validate_config,
|
|
260
|
+
env_enablement_fn=env_enablement,
|
|
261
|
+
apply_yaml_config_fn=_apply_yaml_config,
|
|
262
|
+
allow_all_env="WATCHLINE_ALLOW_ALL_USERS",
|
|
263
|
+
allowed_users_env="WATCHLINE_ALLOWED_USERS",
|
|
264
|
+
platform_hint=(
|
|
265
|
+
"Watchline delivers matched external events. Treat each message as "
|
|
266
|
+
"fresh event context and avoid saying it came from a human chat."
|
|
267
|
+
),
|
|
268
|
+
emoji="🔔",
|
|
269
|
+
)
|
|
270
|
+
_register_cli(ctx)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _register_cli(ctx: Any) -> None:
|
|
274
|
+
from watchline_hermes_plugin.watchline_cli import build_cli
|
|
275
|
+
|
|
276
|
+
ctx.register_cli_command(
|
|
277
|
+
name=WATCHLINE_PLATFORM_NAME,
|
|
278
|
+
help="Configure Watchline delivery and hosted MCP for Hermes.",
|
|
279
|
+
setup_fn=build_cli,
|
|
280
|
+
handler_fn=None,
|
|
281
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Tiny Watchline API client used by the Hermes delivery adapter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
from urllib.error import HTTPError
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
from watchline_hermes_plugin.config import WatchlineConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class WatchlineApiError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WatchlineClient:
|
|
18
|
+
def __init__(self, config: WatchlineConfig):
|
|
19
|
+
self.config = config
|
|
20
|
+
|
|
21
|
+
def pending(self, *, limit: int = 50, cursor: str | None = None) -> dict[str, Any]:
|
|
22
|
+
body: dict[str, Any] = {"channel_id": self.config.channel_id, "limit": limit}
|
|
23
|
+
if cursor:
|
|
24
|
+
body["cursor"] = cursor
|
|
25
|
+
return self._post("/v1/deliveries.pending", body)
|
|
26
|
+
|
|
27
|
+
def ack(self, delivery_ids: list[str]) -> None:
|
|
28
|
+
if not delivery_ids:
|
|
29
|
+
return
|
|
30
|
+
self._post(
|
|
31
|
+
"/v1/deliveries.ack",
|
|
32
|
+
{"channel_id": self.config.channel_id, "delivery_ids": delivery_ids},
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
36
|
+
raw_body = json.dumps(body).encode("utf-8")
|
|
37
|
+
request = Request(
|
|
38
|
+
f"{self.config.api_base_url.rstrip('/')}{path}",
|
|
39
|
+
data=raw_body,
|
|
40
|
+
method="POST",
|
|
41
|
+
headers={
|
|
42
|
+
"Authorization": f"Bearer {self.config.api_key}",
|
|
43
|
+
"Content-Type": "application/json",
|
|
44
|
+
"Accept": "application/json",
|
|
45
|
+
"User-Agent": "watchline-hermes-plugin/0.1.0",
|
|
46
|
+
},
|
|
47
|
+
)
|
|
48
|
+
try:
|
|
49
|
+
with urlopen(request, timeout=30) as response:
|
|
50
|
+
raw = response.read()
|
|
51
|
+
except HTTPError as error:
|
|
52
|
+
detail = error.read().decode("utf-8", errors="replace")
|
|
53
|
+
raise WatchlineApiError(f"Watchline API returned {error.code}: {detail}") from error
|
|
54
|
+
if not raw:
|
|
55
|
+
return {}
|
|
56
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
57
|
+
return parsed if isinstance(parsed, dict) else {}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Configuration helpers for the Watchline Hermes plugin.
|
|
2
|
+
|
|
3
|
+
Hermes platform adapters receive a generic ``PlatformConfig`` object. Keeping
|
|
4
|
+
Watchline-specific validation in this module lets the CLI commands and gateway
|
|
5
|
+
adapter share the same rules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from os import environ
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import yaml
|
|
17
|
+
except ImportError: # pragma: no cover - Hermes normally ships PyYAML.
|
|
18
|
+
yaml = None
|
|
19
|
+
|
|
20
|
+
DEFAULT_API_BASE_URL = "https://api.watch.qordinate.ai"
|
|
21
|
+
DEFAULT_USER_ID = "me"
|
|
22
|
+
DEFAULT_POLL_INTERVAL_SECONDS = 15
|
|
23
|
+
MIN_POLL_INTERVAL_SECONDS = 5
|
|
24
|
+
HERMES_CONFIG_PATH = Path.home() / ".hermes" / "config.yaml"
|
|
25
|
+
WATCHLINE_PLATFORM_NAME = "watchline"
|
|
26
|
+
DEFAULT_DELIVERY_CHANNEL = "main"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class WatchlineConfig:
|
|
31
|
+
api_key: str
|
|
32
|
+
channel_id: str
|
|
33
|
+
user_id: str = DEFAULT_USER_ID
|
|
34
|
+
api_base_url: str = DEFAULT_API_BASE_URL
|
|
35
|
+
poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS
|
|
36
|
+
delivery_channel: str = DEFAULT_DELIVERY_CHANNEL
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def mcp_url(self) -> str:
|
|
40
|
+
return f"{self.api_base_url.rstrip('/')}/v1/mcp"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def normalize_config(value: Any) -> WatchlineConfig:
|
|
44
|
+
"""Normalize config from Hermes ``PlatformConfig.extra`` or raw YAML."""
|
|
45
|
+
|
|
46
|
+
raw = value if isinstance(value, dict) else {}
|
|
47
|
+
api_key = _read_string(raw, "api_key") or environ.get("WATCHLINE_API_KEY", "").strip()
|
|
48
|
+
channel_id = _read_string(raw, "channel_id") or environ.get("WATCHLINE_CHANNEL_ID", "").strip()
|
|
49
|
+
user_id = (
|
|
50
|
+
_read_string(raw, "user_id")
|
|
51
|
+
or environ.get("WATCHLINE_USER_ID", "").strip()
|
|
52
|
+
or DEFAULT_USER_ID
|
|
53
|
+
)
|
|
54
|
+
api_base_url = (
|
|
55
|
+
_read_string(raw, "api_base_url")
|
|
56
|
+
or environ.get("WATCHLINE_API_BASE_URL", "").strip()
|
|
57
|
+
or DEFAULT_API_BASE_URL
|
|
58
|
+
)
|
|
59
|
+
poll_interval_seconds = _read_int(raw, "poll_interval_seconds")
|
|
60
|
+
if poll_interval_seconds is None:
|
|
61
|
+
poll_interval_seconds = DEFAULT_POLL_INTERVAL_SECONDS
|
|
62
|
+
delivery_channel = (
|
|
63
|
+
_read_string(raw, "delivery_channel")
|
|
64
|
+
or environ.get("WATCHLINE_DELIVERY_CHANNEL", "").strip()
|
|
65
|
+
or DEFAULT_DELIVERY_CHANNEL
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
if not api_key:
|
|
69
|
+
raise ValueError("WATCHLINE_API_KEY or watchline.api_key is required.")
|
|
70
|
+
if not channel_id:
|
|
71
|
+
raise ValueError("WATCHLINE_CHANNEL_ID or watchline.channel_id is required.")
|
|
72
|
+
|
|
73
|
+
return WatchlineConfig(
|
|
74
|
+
api_key=api_key,
|
|
75
|
+
channel_id=channel_id,
|
|
76
|
+
user_id=user_id,
|
|
77
|
+
api_base_url=api_base_url.rstrip("/"),
|
|
78
|
+
poll_interval_seconds=max(MIN_POLL_INTERVAL_SECONDS, poll_interval_seconds),
|
|
79
|
+
delivery_channel=delivery_channel,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def config_from_platform(platform_config: Any) -> WatchlineConfig:
|
|
84
|
+
return normalize_config(getattr(platform_config, "extra", {}) or {})
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_hermes_config(path: Path = HERMES_CONFIG_PATH) -> dict[str, Any]:
|
|
88
|
+
if not path.exists():
|
|
89
|
+
return {}
|
|
90
|
+
raw = path.read_text(encoding="utf-8")
|
|
91
|
+
if yaml is None:
|
|
92
|
+
import json
|
|
93
|
+
|
|
94
|
+
loaded = json.loads(raw) if raw.strip() else {}
|
|
95
|
+
else:
|
|
96
|
+
loaded = yaml.safe_load(raw)
|
|
97
|
+
return loaded if isinstance(loaded, dict) else {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def write_hermes_config(data: dict[str, Any], path: Path = HERMES_CONFIG_PATH) -> None:
|
|
101
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
102
|
+
if path.exists():
|
|
103
|
+
backup = path.with_suffix(f"{path.suffix}.watchline.bak")
|
|
104
|
+
backup.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
|
|
105
|
+
backup.chmod(0o600)
|
|
106
|
+
if yaml is None:
|
|
107
|
+
import json
|
|
108
|
+
|
|
109
|
+
rendered = f"{json.dumps(data, indent=2)}\n"
|
|
110
|
+
else:
|
|
111
|
+
rendered = yaml.safe_dump(data, sort_keys=False)
|
|
112
|
+
path.write_text(rendered, encoding="utf-8")
|
|
113
|
+
path.chmod(0o600)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def patch_watchline_config(
|
|
117
|
+
*,
|
|
118
|
+
api_key: str,
|
|
119
|
+
channel_id: str,
|
|
120
|
+
user_id: str = DEFAULT_USER_ID,
|
|
121
|
+
api_base_url: str = DEFAULT_API_BASE_URL,
|
|
122
|
+
poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS,
|
|
123
|
+
delivery_channel: str = DEFAULT_DELIVERY_CHANNEL,
|
|
124
|
+
path: Path = HERMES_CONFIG_PATH,
|
|
125
|
+
) -> WatchlineConfig:
|
|
126
|
+
"""Write both Hermes platform config and top-level Watchline config."""
|
|
127
|
+
|
|
128
|
+
config = normalize_config(
|
|
129
|
+
{
|
|
130
|
+
"api_key": api_key,
|
|
131
|
+
"channel_id": channel_id,
|
|
132
|
+
"user_id": user_id,
|
|
133
|
+
"api_base_url": api_base_url,
|
|
134
|
+
"poll_interval_seconds": poll_interval_seconds,
|
|
135
|
+
"delivery_channel": delivery_channel,
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
data = load_hermes_config(path)
|
|
139
|
+
plugins = _ensure_dict(data, "plugins")
|
|
140
|
+
enabled = plugins.setdefault("enabled", [])
|
|
141
|
+
if not isinstance(enabled, list):
|
|
142
|
+
enabled = []
|
|
143
|
+
plugins["enabled"] = enabled
|
|
144
|
+
if WATCHLINE_PLATFORM_NAME not in enabled:
|
|
145
|
+
enabled.append(WATCHLINE_PLATFORM_NAME)
|
|
146
|
+
|
|
147
|
+
gateway = _ensure_dict(data, "gateway")
|
|
148
|
+
platforms = _ensure_dict(gateway, "platforms")
|
|
149
|
+
platforms[WATCHLINE_PLATFORM_NAME] = {
|
|
150
|
+
"enabled": True,
|
|
151
|
+
"extra": {
|
|
152
|
+
"api_key": config.api_key,
|
|
153
|
+
"channel_id": config.channel_id,
|
|
154
|
+
"user_id": config.user_id,
|
|
155
|
+
"api_base_url": config.api_base_url,
|
|
156
|
+
"poll_interval_seconds": config.poll_interval_seconds,
|
|
157
|
+
"delivery_channel": config.delivery_channel,
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
write_hermes_config(data, path)
|
|
161
|
+
return config
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def patch_mcp_config(config: WatchlineConfig, path: Path = HERMES_CONFIG_PATH) -> None:
|
|
165
|
+
"""Install the hosted Watchline MCP server into Hermes config.yaml."""
|
|
166
|
+
|
|
167
|
+
data = load_hermes_config(path)
|
|
168
|
+
mcp_servers = _ensure_dict(data, "mcp_servers")
|
|
169
|
+
mcp_servers[WATCHLINE_PLATFORM_NAME] = {
|
|
170
|
+
"url": config.mcp_url,
|
|
171
|
+
"headers": {
|
|
172
|
+
"Authorization": f"Bearer {config.api_key}",
|
|
173
|
+
"x-watchline-channel-id": config.channel_id,
|
|
174
|
+
"x-watchline-user-id": config.user_id,
|
|
175
|
+
},
|
|
176
|
+
}
|
|
177
|
+
write_hermes_config(data, path)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def mcp_config_for_display(config: WatchlineConfig) -> dict[str, Any]:
|
|
181
|
+
return {
|
|
182
|
+
"url": config.mcp_url,
|
|
183
|
+
"headers": {
|
|
184
|
+
"Authorization": "Bearer wl_...",
|
|
185
|
+
"x-watchline-channel-id": config.channel_id,
|
|
186
|
+
"x-watchline-user-id": config.user_id,
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _ensure_dict(parent: dict[str, Any], key: str) -> dict[str, Any]:
|
|
192
|
+
value = parent.setdefault(key, {})
|
|
193
|
+
if not isinstance(value, dict):
|
|
194
|
+
value = {}
|
|
195
|
+
parent[key] = value
|
|
196
|
+
return value
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _read_string(raw: dict[str, Any], key: str) -> str | None:
|
|
200
|
+
value = raw.get(key)
|
|
201
|
+
if isinstance(value, str) and value.strip():
|
|
202
|
+
return value.strip()
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _read_int(raw: dict[str, Any], key: str) -> int | None:
|
|
207
|
+
value = raw.get(key)
|
|
208
|
+
if isinstance(value, int):
|
|
209
|
+
return value
|
|
210
|
+
if isinstance(value, str) and value.strip():
|
|
211
|
+
try:
|
|
212
|
+
return int(value)
|
|
213
|
+
except ValueError:
|
|
214
|
+
return None
|
|
215
|
+
return None
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Delivery formatting and polling primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, Protocol
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class DeliveryClient(Protocol):
|
|
10
|
+
def pending(self, *, limit: int = 50, cursor: str | None = None) -> dict[str, Any]: ...
|
|
11
|
+
|
|
12
|
+
def ack(self, delivery_ids: list[str]) -> None: ...
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def format_delivery(delivery: dict[str, Any]) -> str:
|
|
16
|
+
"""Render a Watchline delivery as the user message Hermes should receive."""
|
|
17
|
+
|
|
18
|
+
if delivery.get("type") == "watchline.match":
|
|
19
|
+
return "\n".join(
|
|
20
|
+
[
|
|
21
|
+
f"Matched event with user intent: {delivery.get('intent', '')}",
|
|
22
|
+
"",
|
|
23
|
+
"Use this event as fresh context. Only act if the user intent asks for action.",
|
|
24
|
+
"",
|
|
25
|
+
"Event:",
|
|
26
|
+
_stable_json(delivery.get("event", {})),
|
|
27
|
+
],
|
|
28
|
+
)
|
|
29
|
+
return "\n".join(
|
|
30
|
+
[
|
|
31
|
+
f"Watch needs action: {delivery.get('action', '')}",
|
|
32
|
+
"",
|
|
33
|
+
f"User intent: {delivery.get('intent', '')}",
|
|
34
|
+
"",
|
|
35
|
+
"Connection links:",
|
|
36
|
+
_stable_json(delivery.get("connect_urls", {})),
|
|
37
|
+
],
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class DeliveryChannel:
|
|
43
|
+
value: str
|
|
44
|
+
platform: str | None = None
|
|
45
|
+
chat_id: str | None = None
|
|
46
|
+
thread_id: str | None = None
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def is_main(self) -> bool:
|
|
50
|
+
return self.value == "main"
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def uses_home_channel(self) -> bool:
|
|
54
|
+
return self.platform is not None and self.chat_id is None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_delivery_channel(value: str | None) -> DeliveryChannel:
|
|
58
|
+
raw = (value or "main").strip()
|
|
59
|
+
if not raw or raw.lower() == "main":
|
|
60
|
+
return DeliveryChannel(value="main")
|
|
61
|
+
|
|
62
|
+
parts = raw.split(":", 2)
|
|
63
|
+
platform = parts[0].strip().lower()
|
|
64
|
+
chat_id = parts[1].strip() if len(parts) > 1 and parts[1].strip() else None
|
|
65
|
+
thread_id = parts[2].strip() if len(parts) > 2 and parts[2].strip() else None
|
|
66
|
+
return DeliveryChannel(
|
|
67
|
+
value=raw,
|
|
68
|
+
platform=platform,
|
|
69
|
+
chat_id=chat_id,
|
|
70
|
+
thread_id=thread_id,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def delivery_id(delivery: dict[str, Any]) -> str:
|
|
75
|
+
raw = delivery.get("delivery_id")
|
|
76
|
+
return str(raw) if raw else ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _stable_json(value: Any) -> str:
|
|
80
|
+
import json
|
|
81
|
+
|
|
82
|
+
return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Hermes CLI commands for Watchline setup."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from watchline_hermes_plugin.config import (
|
|
10
|
+
DEFAULT_API_BASE_URL,
|
|
11
|
+
DEFAULT_DELIVERY_CHANNEL,
|
|
12
|
+
DEFAULT_POLL_INTERVAL_SECONDS,
|
|
13
|
+
DEFAULT_USER_ID,
|
|
14
|
+
HERMES_CONFIG_PATH,
|
|
15
|
+
load_hermes_config,
|
|
16
|
+
mcp_config_for_display,
|
|
17
|
+
normalize_config,
|
|
18
|
+
patch_mcp_config,
|
|
19
|
+
patch_watchline_config,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_cli(parser: argparse.ArgumentParser) -> None:
|
|
24
|
+
subcommands = parser.add_subparsers(dest="watchline_command", required=True)
|
|
25
|
+
|
|
26
|
+
configure = subcommands.add_parser("configure", help="Save Watchline Hermes config")
|
|
27
|
+
configure.add_argument("--api-key", required=True)
|
|
28
|
+
configure.add_argument("--channel-id", required=True)
|
|
29
|
+
configure.add_argument("--user-id", default=DEFAULT_USER_ID)
|
|
30
|
+
configure.add_argument("--api-base-url", default=DEFAULT_API_BASE_URL)
|
|
31
|
+
configure.add_argument(
|
|
32
|
+
"--poll-interval-seconds",
|
|
33
|
+
type=int,
|
|
34
|
+
default=DEFAULT_POLL_INTERVAL_SECONDS,
|
|
35
|
+
)
|
|
36
|
+
configure.add_argument("--delivery-channel", default=DEFAULT_DELIVERY_CHANNEL)
|
|
37
|
+
configure.set_defaults(func=_configure)
|
|
38
|
+
|
|
39
|
+
install_mcp = subcommands.add_parser("install-mcp", help="Install Watchline hosted MCP config")
|
|
40
|
+
install_mcp.set_defaults(func=_install_mcp)
|
|
41
|
+
|
|
42
|
+
status = subcommands.add_parser("status", help="Print Watchline config status")
|
|
43
|
+
status.set_defaults(func=_status)
|
|
44
|
+
|
|
45
|
+
preview = subcommands.add_parser(
|
|
46
|
+
"preview-delivery",
|
|
47
|
+
help="Preview a Watchline delivery message",
|
|
48
|
+
)
|
|
49
|
+
preview.set_defaults(func=_preview_delivery)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _configure(args: argparse.Namespace) -> None:
|
|
53
|
+
config = patch_watchline_config(
|
|
54
|
+
api_key=args.api_key,
|
|
55
|
+
channel_id=args.channel_id,
|
|
56
|
+
user_id=args.user_id,
|
|
57
|
+
api_base_url=args.api_base_url,
|
|
58
|
+
poll_interval_seconds=args.poll_interval_seconds,
|
|
59
|
+
delivery_channel=args.delivery_channel,
|
|
60
|
+
)
|
|
61
|
+
patch_mcp_config(config)
|
|
62
|
+
print(f"Saved Watchline platform and MCP config to {HERMES_CONFIG_PATH}.")
|
|
63
|
+
print("Restart Hermes gateway to load the adapter and MCP tools.")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _install_mcp(_: argparse.Namespace) -> None:
|
|
67
|
+
config = _read_saved_config()
|
|
68
|
+
patch_mcp_config(config)
|
|
69
|
+
print(f"Saved Watchline MCP server to {HERMES_CONFIG_PATH}.")
|
|
70
|
+
print("Restart Hermes gateway or start a new Hermes session to load MCP tools.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _status(_: argparse.Namespace) -> None:
|
|
74
|
+
config = _read_saved_config()
|
|
75
|
+
display = mcp_config_for_display(config)
|
|
76
|
+
print("Watchline is configured.")
|
|
77
|
+
print(f" channel_id: {config.channel_id}")
|
|
78
|
+
print(f" user_id: {config.user_id}")
|
|
79
|
+
print(f" api_base: {config.api_base_url}")
|
|
80
|
+
print(f" delivery: {config.delivery_channel}")
|
|
81
|
+
print("")
|
|
82
|
+
print("Hermes MCP entry:")
|
|
83
|
+
print(json.dumps(display, indent=2))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _preview_delivery(_: argparse.Namespace) -> None:
|
|
87
|
+
from watchline_hermes_plugin.delivery import format_delivery
|
|
88
|
+
|
|
89
|
+
print(
|
|
90
|
+
format_delivery(
|
|
91
|
+
{
|
|
92
|
+
"type": "watchline.match",
|
|
93
|
+
"delivery_id": "del_preview",
|
|
94
|
+
"watch_id": "watch_preview",
|
|
95
|
+
"user_id": "me",
|
|
96
|
+
"intent": "urgent billing emails",
|
|
97
|
+
"event": {
|
|
98
|
+
"from": "customer@example.com",
|
|
99
|
+
"subject": "Production invoice failure",
|
|
100
|
+
"snippet": "Our invoice payment is failing before renewal.",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _read_saved_config() -> Any:
|
|
108
|
+
data = load_hermes_config()
|
|
109
|
+
try:
|
|
110
|
+
platform = data["gateway"]["platforms"]["watchline"]
|
|
111
|
+
return normalize_config(platform.get("extra", {}))
|
|
112
|
+
except Exception as error:
|
|
113
|
+
raise SystemExit(
|
|
114
|
+
"Watchline is not configured. Run `hermes watchline configure` "
|
|
115
|
+
f"first. ({error})"
|
|
116
|
+
) from error
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watchline-hermes-plugin
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hermes Agent delivery adapter for Watchline.
|
|
5
|
+
Author-email: Watchline <support@qordinate.ai>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://watch.qordinate.ai
|
|
8
|
+
Project-URL: Documentation, https://watch.qordinate.ai/docs/hermes
|
|
9
|
+
Project-URL: Repository, https://github.com/qordinate-ai/watchline-hermes-plugin
|
|
10
|
+
Project-URL: Issues, https://github.com/qordinate-ai/watchline-hermes-plugin/issues
|
|
11
|
+
Keywords: watchline,hermes-agent,agents,mcp,plugin,pull-delivery
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Communications
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
License-File: LICENSE
|
|
24
|
+
Requires-Dist: PyYAML>=6
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
# Watchline for Hermes Agent
|
|
28
|
+
|
|
29
|
+
Watchline is an event layer for agents. This Hermes plugin delivers matched
|
|
30
|
+
Watchline events into a local Hermes gateway without exposing your laptop to
|
|
31
|
+
the internet.
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
Watchline uses two integration planes:
|
|
36
|
+
|
|
37
|
+
- **Hosted MCP** gives Hermes the watch tools: `start_watch`,
|
|
38
|
+
`continue_watch`, `list_watches`, `pause_watch`, `resume_watch`, and
|
|
39
|
+
`delete_watch`.
|
|
40
|
+
- **This plugin** registers a Hermes gateway platform named `watchline`. It
|
|
41
|
+
polls a Watchline pull channel and forwards matched events as inbound Hermes
|
|
42
|
+
messages.
|
|
43
|
+
|
|
44
|
+
The plugin intentionally does not duplicate Watchline tools. Hermes discovers
|
|
45
|
+
tools from the hosted MCP server, and this adapter only handles local delivery.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
hermes plugins install qordinate-ai/watchline-hermes-plugin --enable
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
After the first PyPI release, the package can also be installed directly:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
python -m pip install watchline-hermes-plugin
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For local development:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
hermes plugins install file:///absolute/path/to/watchline-hermes-plugin --enable
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Configure
|
|
66
|
+
|
|
67
|
+
Create a Watchline API key and pull channel at <https://watch.qordinate.ai>,
|
|
68
|
+
then run:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
hermes watchline configure \
|
|
72
|
+
--api-key wl_... \
|
|
73
|
+
--channel-id ch_... \
|
|
74
|
+
--user-id me \
|
|
75
|
+
--delivery-channel main \
|
|
76
|
+
--api-base-url https://api.watch.qordinate.ai
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The command writes:
|
|
80
|
+
|
|
81
|
+
- `gateway.platforms.watchline` for delivery.
|
|
82
|
+
- `mcp_servers.watchline` for hosted watch tools.
|
|
83
|
+
|
|
84
|
+
Restart the Hermes gateway after changing plugin or MCP config:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
hermes gateway restart
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Use
|
|
91
|
+
|
|
92
|
+
Ask Hermes to use Watchline:
|
|
93
|
+
|
|
94
|
+
```text
|
|
95
|
+
Use the Watchline start_watch tool to watch Gmail for emails from my boss.
|
|
96
|
+
Ask me for the sender email address if needed.
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
When a matching event arrives, Watchline queues it on your pull channel. The
|
|
100
|
+
plugin polls that channel, runs Hermes against your main/home channel, and
|
|
101
|
+
acknowledges the delivery only after Hermes accepts it.
|
|
102
|
+
|
|
103
|
+
## Commands
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
hermes watchline status
|
|
107
|
+
hermes watchline install-mcp
|
|
108
|
+
hermes watchline preview-delivery
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Configuration Reference
|
|
112
|
+
|
|
113
|
+
| Field | Required | Description |
|
|
114
|
+
| ----------------------- | -------- | ------------------------------------------------ |
|
|
115
|
+
| `api_key` | Yes | Watchline project API key. |
|
|
116
|
+
| `channel_id` | Yes | Watchline pull channel ID. |
|
|
117
|
+
| `user_id` | No | Stable Watchline user id. Defaults to `me`. |
|
|
118
|
+
| `api_base_url` | No | Watchline API base URL. Defaults to production. |
|
|
119
|
+
| `poll_interval_seconds` | No | Delivery polling interval. Minimum is 5 seconds. |
|
|
120
|
+
| `delivery_channel` | No | Hermes delivery target. Defaults to `main`, the first configured home channel. Use `telegram`, `discord`, or `telegram:<chat_id>[:thread_id]` to override. |
|
|
121
|
+
|
|
122
|
+
## Links
|
|
123
|
+
|
|
124
|
+
- Dashboard: <https://watch.qordinate.ai>
|
|
125
|
+
- API: <https://api.watch.qordinate.ai>
|
|
126
|
+
- Source: <https://github.com/qordinate-ai/watchline-hermes-plugin>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
tests/test_config.py
|
|
5
|
+
tests/test_delivery.py
|
|
6
|
+
watchline_hermes_plugin/__init__.py
|
|
7
|
+
watchline_hermes_plugin/adapter.py
|
|
8
|
+
watchline_hermes_plugin/client.py
|
|
9
|
+
watchline_hermes_plugin/config.py
|
|
10
|
+
watchline_hermes_plugin/delivery.py
|
|
11
|
+
watchline_hermes_plugin/watchline_cli.py
|
|
12
|
+
watchline_hermes_plugin.egg-info/PKG-INFO
|
|
13
|
+
watchline_hermes_plugin.egg-info/SOURCES.txt
|
|
14
|
+
watchline_hermes_plugin.egg-info/dependency_links.txt
|
|
15
|
+
watchline_hermes_plugin.egg-info/entry_points.txt
|
|
16
|
+
watchline_hermes_plugin.egg-info/requires.txt
|
|
17
|
+
watchline_hermes_plugin.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
PyYAML>=6
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
watchline_hermes_plugin
|