@artil-ai/hermes 0.0.0-stage → 0.1.0
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.
- package/README.md +40 -2
- package/artil/__init__.py +152 -0
- package/artil/plugin.yaml +11 -0
- package/package.json +24 -4
package/README.md
CHANGED
|
@@ -1,3 +1,41 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @artil-ai/hermes
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Artil platform for the [Hermes](https://hermes-agent.nousresearch.com)
|
|
4
|
+
gateway. New email and texts to the agent account, and wake-ups from its team,
|
|
5
|
+
reach the gateway as notices that `artil internal hermes-channel` prints, from
|
|
6
|
+
where it left off; the agent answers them with the `artil` CLI.
|
|
7
|
+
|
|
8
|
+
## Usage
|
|
9
|
+
|
|
10
|
+
The `artil` CLI carries the plugin from this package. `artil plugin install
|
|
11
|
+
hermes` writes it to `$HERMES_HOME/plugins/artil` and turns it on in Hermes'
|
|
12
|
+
`config.yaml`, with the absolute path of `artil`, since the gateway's service
|
|
13
|
+
does not see your shell's PATH:
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
plugins:
|
|
17
|
+
enabled: [artil]
|
|
18
|
+
platforms:
|
|
19
|
+
artil:
|
|
20
|
+
enabled: true
|
|
21
|
+
extra:
|
|
22
|
+
command: /Users/you/.local/bin/artil
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Entry point | What it is |
|
|
26
|
+
| ------------------- | ------------------------------------------------------- |
|
|
27
|
+
| `artil/plugin.yaml` | The plugin manifest; Hermes 0.21.5 or later loads it |
|
|
28
|
+
| `artil/__init__.py` | The `artil` gateway platform and its system-prompt hint |
|
|
29
|
+
|
|
30
|
+
## Development
|
|
31
|
+
|
|
32
|
+
From the repository root, lint and format the plugin with
|
|
33
|
+
[ruff](https://docs.astral.sh/ruff), then validate and test it inside the Hermes
|
|
34
|
+
image:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
uvx ruff check && uvx ruff format --check
|
|
38
|
+
docker run --rm -v "$PWD/hermes:/plugin" -w /plugin --entrypoint hermes nousresearch/hermes-agent:latest plugins validate artil
|
|
39
|
+
docker run --rm -v "$PWD/hermes:/plugin" -w /plugin --entrypoint sh nousresearch/hermes-agent:latest -c \
|
|
40
|
+
'uv pip install -q --python /opt/hermes/.venv/bin/python pytest && PYTHONPATH=/opt/hermes /opt/hermes/.venv/bin/python -m pytest -p no:cacheprovider tests'
|
|
41
|
+
```
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Artil for Hermes: notices about an agent account's new email and texts reach the gateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import dataclasses
|
|
7
|
+
import json
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from gateway.config import Platform, PlatformConfig
|
|
11
|
+
from gateway.platform_registry import PlatformEntry
|
|
12
|
+
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
|
|
13
|
+
|
|
14
|
+
PLATFORM = "artil"
|
|
15
|
+
|
|
16
|
+
HINT = (
|
|
17
|
+
"Notices about new email and texts to this Artil agent account, and wake-ups from its team, "
|
|
18
|
+
"arrive here. Read each message with the artil command its notice names, and answer it with "
|
|
19
|
+
"`artil email send --reply-to` or `artil sms send --reply-to`, as the artil skill describes. "
|
|
20
|
+
"Nobody outside reads what you write in this conversation."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ArtilAdapter(BasePlatformAdapter):
|
|
25
|
+
"""Runs `artil internal hermes-channel` and hands each notice it prints to the gateway."""
|
|
26
|
+
|
|
27
|
+
STOP_GRACE_SECONDS = 2.0
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: PlatformConfig) -> None:
|
|
30
|
+
config.extra = {**(config.extra or {}), "allow_from": [PLATFORM]}
|
|
31
|
+
super().__init__(config, Platform(PLATFORM))
|
|
32
|
+
self._command = str(config.extra.get("command") or "artil")
|
|
33
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
34
|
+
self._listening: asyncio.Task | None = None
|
|
35
|
+
|
|
36
|
+
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
|
37
|
+
try:
|
|
38
|
+
process = await asyncio.create_subprocess_exec(
|
|
39
|
+
self._command,
|
|
40
|
+
"internal",
|
|
41
|
+
"hermes-channel",
|
|
42
|
+
stdin=asyncio.subprocess.PIPE,
|
|
43
|
+
stdout=asyncio.subprocess.PIPE,
|
|
44
|
+
stderr=asyncio.subprocess.PIPE,
|
|
45
|
+
start_new_session=True,
|
|
46
|
+
)
|
|
47
|
+
except OSError as error:
|
|
48
|
+
self._set_fatal_error(
|
|
49
|
+
"artil_missing", f"Could not run {self._command}: {error}", retryable=False
|
|
50
|
+
)
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
self._process = process
|
|
54
|
+
self._listening = asyncio.create_task(self._listen(process))
|
|
55
|
+
self._mark_connected()
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
async def _listen(self, process: asyncio.subprocess.Process) -> None:
|
|
59
|
+
problem = asyncio.create_task(process.stderr.read())
|
|
60
|
+
|
|
61
|
+
async for line in process.stdout:
|
|
62
|
+
await self._deliver(json.loads(line))
|
|
63
|
+
|
|
64
|
+
reason = (await problem).decode().strip()
|
|
65
|
+
code = await process.wait()
|
|
66
|
+
asked_to_stop = code in (0, -2, -15)
|
|
67
|
+
|
|
68
|
+
if asked_to_stop:
|
|
69
|
+
await asyncio.sleep(self.STOP_GRACE_SECONDS)
|
|
70
|
+
|
|
71
|
+
if process is not self._process or getattr(
|
|
72
|
+
self.gateway_runner, "_stop_requested_by_signal", False
|
|
73
|
+
):
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
self._process = None
|
|
77
|
+
self._set_fatal_error(
|
|
78
|
+
"artil_stopped", reason or f"{self._command} stopped", retryable=asked_to_stop
|
|
79
|
+
)
|
|
80
|
+
await self._notify_fatal_error()
|
|
81
|
+
|
|
82
|
+
async def _deliver(self, notice: dict[str, Any]) -> None:
|
|
83
|
+
meta = notice.get("meta") or {}
|
|
84
|
+
source = self.build_source(
|
|
85
|
+
chat_id=PLATFORM,
|
|
86
|
+
chat_name="Artil",
|
|
87
|
+
chat_type="dm",
|
|
88
|
+
user_id=PLATFORM,
|
|
89
|
+
user_name="Artil",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
await self.handle_message(
|
|
93
|
+
MessageEvent(
|
|
94
|
+
text=notice["content"],
|
|
95
|
+
message_type=MessageType.TEXT,
|
|
96
|
+
source=source,
|
|
97
|
+
message_id=meta.get("message_id"),
|
|
98
|
+
allow_gateway_control=False,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async def disconnect(self) -> None:
|
|
103
|
+
process, self._process = self._process, None
|
|
104
|
+
|
|
105
|
+
if process is not None and process.returncode is None:
|
|
106
|
+
process.stdin.close()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
await asyncio.wait_for(process.wait(), 5)
|
|
110
|
+
except TimeoutError:
|
|
111
|
+
process.kill()
|
|
112
|
+
|
|
113
|
+
if self._listening is not None:
|
|
114
|
+
self._listening.cancel()
|
|
115
|
+
|
|
116
|
+
self._mark_disconnected()
|
|
117
|
+
|
|
118
|
+
async def send(
|
|
119
|
+
self,
|
|
120
|
+
chat_id: str,
|
|
121
|
+
content: str,
|
|
122
|
+
reply_to: str | None = None,
|
|
123
|
+
metadata: dict[str, Any] | None = None,
|
|
124
|
+
) -> SendResult:
|
|
125
|
+
"""The agent answers senders with the artil CLI, so its words here stay in the session."""
|
|
126
|
+
return SendResult(success=True)
|
|
127
|
+
|
|
128
|
+
async def get_chat_info(self, chat_id: str) -> dict[str, Any]:
|
|
129
|
+
return {"name": "Artil", "type": "dm"}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _needs_nothing() -> bool:
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _service_fields() -> dict[str, Any]:
|
|
137
|
+
known = {field.name for field in dataclasses.fields(PlatformEntry)}
|
|
138
|
+
wanted = {"trusted_inbound": True, "display_tier": "minimal"}
|
|
139
|
+
return {name: value for name, value in wanted.items() if name in known}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def register(ctx: Any) -> None:
|
|
143
|
+
ctx.register_platform(
|
|
144
|
+
name=PLATFORM,
|
|
145
|
+
label="Artil",
|
|
146
|
+
adapter_factory=ArtilAdapter,
|
|
147
|
+
check_fn=_needs_nothing,
|
|
148
|
+
platform_hint=HINT,
|
|
149
|
+
allow_update_command=False,
|
|
150
|
+
emoji="📨",
|
|
151
|
+
**_service_fields(),
|
|
152
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
name: artil
|
|
2
|
+
label: Artil
|
|
3
|
+
kind: platform
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
description: >
|
|
6
|
+
New email and texts to an Artil agent account, and wake-ups from its team,
|
|
7
|
+
reach the Hermes gateway through the artil CLI. The agent answers them with
|
|
8
|
+
the CLI, as the artil skill describes.
|
|
9
|
+
author: Artil
|
|
10
|
+
homepage: https://docs.artil.dev/automatic-messages
|
|
11
|
+
requires_hermes: ">=0.21.5"
|
package/package.json
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@artil-ai/hermes",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Hear about your Artil agent's new messages in the Hermes gateway, and answer them with the artil CLI.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"artil",
|
|
7
|
+
"hermes-agent"
|
|
8
|
+
],
|
|
9
|
+
"homepage": "https://docs.artil.dev/automatic-messages",
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/artilai/plugins.git",
|
|
14
|
+
"directory": "hermes"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"artil"
|
|
18
|
+
],
|
|
19
|
+
"exports": {
|
|
20
|
+
"./plugin.yaml": "./artil/plugin.yaml",
|
|
21
|
+
"./__init__.py": "./artil/__init__.py"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
}
|
|
26
|
+
}
|