@cotal-ai/connector-hermes 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.
Files changed (43) hide show
  1. package/LICENSE +202 -0
  2. package/bin/install.mjs +99 -0
  3. package/dist/bridge.d.ts +7 -0
  4. package/dist/bridge.d.ts.map +1 -0
  5. package/dist/bridge.js +199 -0
  6. package/dist/bridge.js.map +1 -0
  7. package/dist/extension.d.ts +13 -0
  8. package/dist/extension.d.ts.map +1 -0
  9. package/dist/extension.js +49 -0
  10. package/dist/extension.js.map +1 -0
  11. package/dist/hermes-hooks.d.ts +15 -0
  12. package/dist/hermes-hooks.d.ts.map +1 -0
  13. package/dist/hermes-hooks.js +51 -0
  14. package/dist/hermes-hooks.js.map +1 -0
  15. package/dist/index.d.ts +2 -0
  16. package/dist/index.d.ts.map +1 -0
  17. package/dist/index.js +2 -0
  18. package/dist/index.js.map +1 -0
  19. package/dist/launch.d.ts +2 -0
  20. package/dist/launch.d.ts.map +1 -0
  21. package/dist/launch.js +149 -0
  22. package/dist/launch.js.map +1 -0
  23. package/dist/sidecar.d.ts +10 -0
  24. package/dist/sidecar.d.ts.map +1 -0
  25. package/dist/sidecar.js +60 -0
  26. package/dist/sidecar.js.map +1 -0
  27. package/dist/standalone.d.ts +2 -0
  28. package/dist/standalone.d.ts.map +1 -0
  29. package/dist/standalone.js +31 -0
  30. package/dist/standalone.js.map +1 -0
  31. package/dist/tool-schema.d.ts +10 -0
  32. package/dist/tool-schema.d.ts.map +1 -0
  33. package/dist/tool-schema.js +34 -0
  34. package/dist/tool-schema.js.map +1 -0
  35. package/package.json +51 -0
  36. package/plugin/cotal/__init__.py +148 -0
  37. package/plugin/cotal/_sidecar/standalone.cjs +33188 -0
  38. package/plugin/cotal/adapter.py +114 -0
  39. package/plugin/cotal/bridge_client.py +153 -0
  40. package/plugin/cotal/hooks.py +74 -0
  41. package/plugin/cotal/plugin.yaml +10 -0
  42. package/plugin/cotal/tools.py +62 -0
  43. package/pyproject.toml +19 -0
@@ -0,0 +1,114 @@
1
+ """Cotal gateway platform adapter.
2
+
3
+ Inbound: the sidecar pushes mesh messages over the bridge; the adapter builds a ``MessageEvent``
4
+ and calls ``handle_message`` — which wakes an idle session or **queues + interrupts a running one**
5
+ (the gateway's own busy handling), so a peer can DRIVE a live turn, not just leave a message.
6
+ Outbound: the gateway hands a turn's reply to ``send()``, which the adapter routes back to that
7
+ message's mesh origin (the channel it came in on, or a DM to the sender).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import uuid
13
+ from typing import Any, Optional
14
+
15
+ from gateway.platforms.base import (
16
+ BasePlatformAdapter,
17
+ MessageEvent,
18
+ MessageType,
19
+ SendResult,
20
+ )
21
+ from gateway.config import Platform, PlatformConfig
22
+
23
+ from . import hooks
24
+ from .bridge_client import get_client
25
+
26
+
27
+ def _target_for(chat_id: str) -> dict:
28
+ """Reverse the chat_id minted on inbound back into a mesh reply target."""
29
+ if chat_id.startswith("channel:"):
30
+ return {"channel": chat_id[len("channel:"):]}
31
+ if chat_id.startswith("dm:"):
32
+ return {"peerId": chat_id[len("dm:"):]}
33
+ return {}
34
+
35
+
36
+ class CotalAdapter(BasePlatformAdapter):
37
+ def __init__(self, config: PlatformConfig) -> None:
38
+ super().__init__(config, Platform("cotal"))
39
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
40
+ self._client = get_client()
41
+
42
+ async def connect(self) -> bool:
43
+ self._loop = asyncio.get_running_loop()
44
+ self._client.start(self._on_incoming) # reader thread → _on_incoming
45
+ self._mark_connected()
46
+ hooks.relay("gateway_startup") # present + free
47
+ return True
48
+
49
+ async def disconnect(self) -> None:
50
+ hooks.relay("gateway_shutdown")
51
+ self._client.close()
52
+ self._mark_disconnected()
53
+
54
+ async def send(
55
+ self, chat_id: str, content: str, reply_to: Any = None, metadata: Any = None
56
+ ) -> SendResult:
57
+ # The gateway delivers a turn's reply here → route it back to the message's mesh origin.
58
+ self._client.reply(_target_for(chat_id), content)
59
+ return SendResult(success=True, message_id=uuid.uuid4().hex)
60
+
61
+ async def get_chat_info(self, chat_id: str) -> dict:
62
+ if chat_id.startswith("channel:"):
63
+ return {"name": "#" + chat_id[len("channel:"):], "type": "group"}
64
+ return {"name": chat_id, "type": "dm"}
65
+
66
+ # ---- inbound (bridge reader thread → gateway loop) -----------------------
67
+
68
+ def _on_incoming(self, msg: dict) -> None:
69
+ """Called off-loop by the bridge reader; hop onto the gateway loop to inject the turn."""
70
+ loop = self._loop
71
+ if loop is None:
72
+ return
73
+ fut = asyncio.run_coroutine_threadsafe(self._inject(msg), loop)
74
+ fut.add_done_callback(lambda f: self._maybe_ack(msg, f))
75
+
76
+ def _maybe_ack(self, msg: dict, fut: Any) -> None:
77
+ """Ack a message on the mesh stream once it has been surfaced into a turn.
78
+
79
+ VALIDATION GATE (open confirmation #4): ``handle_message`` returning means the event was
80
+ *queued*, not necessarily *consumed into a turn*. On the pinned Hermes line, confirm the
81
+ completion point and, if needed, move this ack to a real processing-complete hook/wrapper —
82
+ do NOT relax it to ack-on-queue (a mesh message that never reaches the model must redeliver
83
+ after a crash). Until proven, this acks on the inject coroutine completing without error.
84
+ """
85
+ mid = msg.get("id")
86
+ if mid and not fut.cancelled() and fut.exception() is None:
87
+ self._client.delivered(mid)
88
+
89
+ async def _inject(self, msg: dict) -> None:
90
+ kind = msg.get("kind")
91
+ sender = msg.get("fromName") or "peer"
92
+ role = msg.get("fromRole")
93
+ tag = f"[{kind} from {sender}{f' / {role}' if role else ''}] "
94
+
95
+ if kind == "channel":
96
+ ch = msg.get("channel") or "general"
97
+ chat_id, chat_type, chat_name = f"channel:{ch}", "group", f"#{ch}"
98
+ else: # dm / anycast → a turn whose reply goes straight back to the sender
99
+ chat_id, chat_type, chat_name = f"dm:{msg.get('fromId')}", "dm", sender
100
+
101
+ source = self.build_source(
102
+ chat_id=chat_id,
103
+ chat_name=chat_name,
104
+ chat_type=chat_type,
105
+ user_id=msg.get("fromId"),
106
+ user_name=sender,
107
+ )
108
+ event = MessageEvent(
109
+ text=tag + (msg.get("text") or ""),
110
+ message_type=MessageType.TEXT,
111
+ source=source,
112
+ message_id=msg.get("id"),
113
+ )
114
+ await self.handle_message(event)
@@ -0,0 +1,153 @@
1
+ """Persistent client to the Cotal sidecar's bridge socket.
2
+
3
+ The sidecar (``src/sidecar.ts``) owns the mesh endpoint and exposes a unix-socket bridge; this is
4
+ the in-gateway half. One background thread owns a blocking ``AF_UNIX`` connection and dispatches
5
+ frames — inbound mesh messages go to the adapter's callback, tool results resolve pending calls.
6
+ Writes are newline-delimited JSON under a lock; it reconnects with backoff so a sidecar restart
7
+ self-heals (re-subscribing on every (re)connect). Wire format mirrors ``src/bridge.ts``.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import socket
14
+ import threading
15
+ import time
16
+ import uuid
17
+ from typing import Any, Callable, Optional
18
+
19
+ _BACKOFF_S = 2.0
20
+
21
+
22
+ class BridgeClient:
23
+ def __init__(self, socket_path: str) -> None:
24
+ self._path = socket_path
25
+ self._sock: Optional[socket.socket] = None
26
+ self._lock = threading.Lock()
27
+ self._pending: dict[str, tuple[threading.Event, dict]] = {}
28
+ self._on_incoming: Optional[Callable[[dict], None]] = None
29
+ self._stop = threading.Event()
30
+ self._reader: Optional[threading.Thread] = None
31
+
32
+ def start(self, on_incoming: Callable[[dict], None]) -> None:
33
+ """Begin the reader thread. ``on_incoming`` is called (off-loop) for each mesh message."""
34
+ self._on_incoming = on_incoming
35
+ if self._reader is None:
36
+ self._reader = threading.Thread(target=self._run, name="cotal-bridge", daemon=True)
37
+ self._reader.start()
38
+
39
+ # ---- reader thread -------------------------------------------------------
40
+
41
+ def _run(self) -> None:
42
+ buf = b""
43
+ while not self._stop.is_set():
44
+ if self._sock is None:
45
+ self._connect()
46
+ if self._sock is None:
47
+ continue
48
+ self._send({"t": "subscribe"}) # (re)subscribe after every (re)connect
49
+ try:
50
+ data = self._sock.recv(65536)
51
+ except OSError:
52
+ data = b""
53
+ if not data:
54
+ with self._lock:
55
+ self._sock = None
56
+ continue
57
+ buf += data
58
+ while b"\n" in buf:
59
+ line, buf = buf.split(b"\n", 1)
60
+ if line.strip():
61
+ self._dispatch(line)
62
+
63
+ def _connect(self) -> None:
64
+ while not self._stop.is_set():
65
+ try:
66
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
67
+ s.connect(self._path)
68
+ with self._lock:
69
+ self._sock = s
70
+ return
71
+ except OSError:
72
+ time.sleep(_BACKOFF_S)
73
+
74
+ def _dispatch(self, line: bytes) -> None:
75
+ try:
76
+ frame = json.loads(line)
77
+ except ValueError:
78
+ return
79
+ t = frame.get("t")
80
+ if t == "incoming":
81
+ cb = self._on_incoming
82
+ if cb:
83
+ cb(frame.get("msg") or {})
84
+ elif t == "tool_result":
85
+ entry = self._pending.get(frame.get("id"))
86
+ if entry:
87
+ entry[1].update(frame)
88
+ entry[0].set()
89
+
90
+ # ---- writes --------------------------------------------------------------
91
+
92
+ def _send(self, frame: dict) -> None:
93
+ data = (json.dumps(frame) + "\n").encode()
94
+ with self._lock:
95
+ if self._sock is None:
96
+ return
97
+ try:
98
+ self._sock.sendall(data)
99
+ except OSError:
100
+ self._sock = None
101
+
102
+ def delivered(self, msg_id: str) -> None:
103
+ """Ack a message on the stream — call only once it has been surfaced into a turn."""
104
+ self._send({"t": "delivered", "id": msg_id})
105
+
106
+ def reply(self, target: dict, text: str) -> None:
107
+ """Route a turn's reply back to its mesh origin (channel broadcast or DM to the sender)."""
108
+ self._send({"t": "reply", "target": target, "text": text})
109
+
110
+ def call_tool(self, name: str, args: dict, timeout: float = 30.0) -> str:
111
+ """Invoke a cotal_* tool on the sidecar and block for its text result (raises on transport
112
+ error/timeout). The sidecar runs the shared spec, so the text is already model-ready; an
113
+ in-tool logical error comes back flagged and is prefixed for the model."""
114
+ rid = uuid.uuid4().hex
115
+ ev = threading.Event()
116
+ box: dict = {}
117
+ self._pending[rid] = (ev, box)
118
+ try:
119
+ self._send({"t": "tool", "id": rid, "name": name, "args": args})
120
+ if not ev.wait(timeout):
121
+ raise TimeoutError(f"cotal tool '{name}' timed out")
122
+ if not box.get("ok"):
123
+ raise RuntimeError(box.get("error") or "tool failed")
124
+ text = box.get("text") or ""
125
+ return f"⚠ {text}" if box.get("isError") else text
126
+ finally:
127
+ self._pending.pop(rid, None)
128
+
129
+ def close(self) -> None:
130
+ self._stop.set()
131
+ with self._lock:
132
+ if self._sock is not None:
133
+ try:
134
+ self._sock.close()
135
+ except OSError:
136
+ pass
137
+ self._sock = None
138
+
139
+
140
+ _client: Optional[BridgeClient] = None
141
+
142
+
143
+ def get_client() -> BridgeClient:
144
+ """Process-wide singleton, bound to ``COTAL_BRIDGE_SOCKET`` (set by the launcher/bootstrap)."""
145
+ global _client
146
+ if _client is None:
147
+ path = os.environ.get("COTAL_BRIDGE_SOCKET")
148
+ if not path:
149
+ raise RuntimeError(
150
+ "COTAL_BRIDGE_SOCKET not set — the Cotal launcher or standalone bootstrap must set it"
151
+ )
152
+ _client = BridgeClient(path)
153
+ return _client
@@ -0,0 +1,74 @@
1
+ """Hermes lifecycle hooks → Cotal presence (the relay.ts pattern, in Python).
2
+
3
+ Each hook makes a one-shot connection to connector-core's control socket
4
+ (``COTAL_CONTROL_SOCKET``), sends ``{"hook_event_name": ...}``, and ignores the reply — the TS
5
+ ``hermesHookHandle`` turns it into a presence change. Hooks must never block the gateway, so the
6
+ connection has a short timeout and every error is swallowed.
7
+
8
+ Hermes hook callback signatures vary by version; these take ``*args, **kwargs`` and best-effort
9
+ extract what they need, so a signature change degrades to "no detail" rather than an exception.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import socket
16
+ from typing import Any
17
+
18
+ _TIMEOUT_S = 2.0
19
+
20
+
21
+ def relay(event_name: str, **fields: Any) -> None:
22
+ """Forward one lifecycle event to the connector's control socket; fire-and-forget."""
23
+ path = os.environ.get("COTAL_CONTROL_SOCKET")
24
+ if not path:
25
+ return
26
+ payload = {"hook_event_name": event_name, **fields}
27
+ try:
28
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
29
+ s.settimeout(_TIMEOUT_S)
30
+ s.connect(path)
31
+ s.sendall((json.dumps(payload) + "\n").encode())
32
+ try:
33
+ s.recv(65536) # read + discard the reply
34
+ except OSError:
35
+ pass
36
+ s.close()
37
+ except OSError:
38
+ pass
39
+
40
+
41
+ def _extract_tool(args: tuple, kwargs: dict) -> tuple[str, Any]:
42
+ """Best-effort tool name + input from whatever Hermes passes the pre_tool_call hook."""
43
+ ctx: dict = {}
44
+ for a in args:
45
+ if isinstance(a, dict):
46
+ ctx = a
47
+ break
48
+ ctx = {**ctx, **kwargs}
49
+ name = ctx.get("tool_name") or ctx.get("name") or ctx.get("tool") or ""
50
+ inp = ctx.get("tool_input") or ctx.get("arguments") or ctx.get("input") or ctx.get("args")
51
+ return str(name), inp
52
+
53
+
54
+ # ---- hook callbacks (registered in __init__.register) -----------------------
55
+
56
+ def on_session_start(*args: Any, **kwargs: Any) -> None:
57
+ relay("on_session_start")
58
+
59
+
60
+ def pre_llm_call(*args: Any, **kwargs: Any) -> None:
61
+ relay("pre_llm_call")
62
+
63
+
64
+ def pre_tool_call(*args: Any, **kwargs: Any) -> None:
65
+ name, inp = _extract_tool(args, kwargs)
66
+ relay("pre_tool_call", tool_name=name, tool_input=inp)
67
+
68
+
69
+ def post_llm_call(*args: Any, **kwargs: Any) -> None:
70
+ relay("post_llm_call")
71
+
72
+
73
+ def on_session_end(*args: Any, **kwargs: Any) -> None:
74
+ relay("on_session_end")
@@ -0,0 +1,10 @@
1
+ name: cotal
2
+ label: Cotal
3
+ kind: platform
4
+ version: 0.1.0
5
+ description: Join the Cotal mesh — coordinate with other AI agents as lateral peers over NATS.
6
+ author: Cotal
7
+ # COTAL_BRIDGE_SOCKET is intentionally NOT declared as a required/prompted env var: in managed
8
+ # mode the Cotal launcher always presets it, and in standalone mode (a user's own `hermes`) the
9
+ # plugin spawns the bundled sidecar itself and derives it — see __init__.register(). The platform's
10
+ # check_fn gates enablement instead, so an unrelated gateway never gets prompted for it.
@@ -0,0 +1,62 @@
1
+ """cotal_* tools — the deliberate, proactive mesh actions, exposed to the Hermes agent.
2
+
3
+ A turn's *reply* is delivered automatically (the adapter routes it back to whoever messaged), so
4
+ these tools are for reaching OTHER peers/channels, seeing who's around, reporting status, and
5
+ growing the team. We do NOT hand-write the list: the TS sidecar renders it once from the shared
6
+ ``cotalToolSpecs`` and writes the descriptors to ``COTAL_TOOLS_FILE``; this reads that file and
7
+ registers each as a Hermes plugin tool whose handler forwards the call (by name) over the bridge
8
+ and returns the sidecar's already-formatted text result.
9
+
10
+ The exact ``ctx.register_tool`` schema/handler contract is the Hermes 0.16 plugin API; adjust the
11
+ ``_spec`` shape if a pinned version differs.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from typing import Any, Callable
18
+
19
+ from .bridge_client import get_client
20
+
21
+
22
+ def _spec(descriptor: dict) -> dict:
23
+ """A Hermes tool spec from a sidecar descriptor ({name, description, parameters})."""
24
+ params = descriptor.get("parameters") or {"type": "object", "properties": {}, "required": []}
25
+ return {
26
+ "name": descriptor["name"],
27
+ "description": descriptor.get("description", ""),
28
+ "parameters": params,
29
+ }
30
+
31
+
32
+ def _handler(name: str) -> Callable[[dict], str]:
33
+ """Forward a tool call to the sidecar; the sidecar runs the shared spec and returns the text."""
34
+ def run(args: dict) -> str:
35
+ try:
36
+ return get_client().call_tool(name, args or {})
37
+ except Exception as e: # surfaced back to the model as the tool result
38
+ return f"cotal error: {e}"
39
+
40
+ return run
41
+
42
+
43
+ def _load_descriptors() -> list[dict]:
44
+ path = os.environ.get("COTAL_TOOLS_FILE")
45
+ if not path:
46
+ raise RuntimeError("COTAL_TOOLS_FILE not set — the sidecar must publish the tool descriptors")
47
+ with open(path, "r", encoding="utf-8") as f:
48
+ data = json.load(f)
49
+ if not isinstance(data, list):
50
+ raise RuntimeError(f"COTAL_TOOLS_FILE {path} did not contain a tool list")
51
+ return data
52
+
53
+
54
+ def register_tools(ctx: Any) -> None:
55
+ for descriptor in _load_descriptors():
56
+ name = descriptor["name"]
57
+ ctx.register_tool(
58
+ name=name,
59
+ toolset="cotal",
60
+ schema=_spec(descriptor),
61
+ handler=_handler(name),
62
+ )
package/pyproject.toml ADDED
@@ -0,0 +1,19 @@
1
+ # The managed launcher runs the gateway via `uv run --project <this dir> hermes gateway run`,
2
+ # so uv provisions an env that provides the `hermes` CLI. This is NOT a Python package itself
3
+ # (the plugin dir is copied into HERMES_HOME at launch) — `package = false` keeps uv from
4
+ # building it.
5
+ #
6
+ # Pin: the connector is written against the Hermes 0.16 plugin/platform API. The TS sidecar
7
+ # asserts the installed version is on that line at startup and fails loudly on a mismatch
8
+ # (no silent degrade) — keep this pin and the assertion in src/launch.ts in sync.
9
+ [project]
10
+ name = "cotal-connector-hermes"
11
+ version = "0.1.0"
12
+ description = "Run the Hermes (Nous Research) agent as a Cotal mesh peer."
13
+ requires-python = ">=3.11,<3.14"
14
+ dependencies = [
15
+ "hermes-agent>=0.16,<0.17",
16
+ ]
17
+
18
+ [tool.uv]
19
+ package = false