opengateways 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.
- opengateways-0.1.0/PKG-INFO +130 -0
- opengateways-0.1.0/README.md +90 -0
- opengateways-0.1.0/pyproject.toml +69 -0
- opengateways-0.1.0/src/opengateway/__init__.py +4 -0
- opengateways-0.1.0/src/opengateway/acp_handlers.py +234 -0
- opengateways-0.1.0/src/opengateway/agent_playbook.py +88 -0
- opengateways-0.1.0/src/opengateway/api_keys.py +64 -0
- opengateways-0.1.0/src/opengateway/audit.py +63 -0
- opengateways-0.1.0/src/opengateway/auth.py +333 -0
- opengateways-0.1.0/src/opengateway/cli.py +1255 -0
- opengateways-0.1.0/src/opengateway/config.py +279 -0
- opengateways-0.1.0/src/opengateway/doctor.py +653 -0
- opengateways-0.1.0/src/opengateway/file_store.py +247 -0
- opengateways-0.1.0/src/opengateway/im.py +641 -0
- opengateways-0.1.0/src/opengateway/listen.py +296 -0
- opengateways-0.1.0/src/opengateway/mcp_server.py +1135 -0
- opengateways-0.1.0/src/opengateway/models.py +449 -0
- opengateways-0.1.0/src/opengateway/persistence.py +603 -0
- opengateways-0.1.0/src/opengateway/postgres_persistence.py +577 -0
- opengateways-0.1.0/src/opengateway/push.py +87 -0
- opengateways-0.1.0/src/opengateway/radio.py +308 -0
- opengateways-0.1.0/src/opengateway/redis_bus.py +160 -0
- opengateways-0.1.0/src/opengateway/search.py +354 -0
- opengateways-0.1.0/src/opengateway/server.py +2295 -0
- opengateways-0.1.0/src/opengateway/static/agent-kit/SKILL.md +115 -0
- opengateways-0.1.0/src/opengateway/static/assets/index-ClrTipTW.js +52 -0
- opengateways-0.1.0/src/opengateway/static/assets/index-D1-pOcmB.css +2 -0
- opengateways-0.1.0/src/opengateway/static/assets/index-Doj1LQeu.js +108 -0
- opengateways-0.1.0/src/opengateway/static/assets/index-evqxdrWn.css +2 -0
- opengateways-0.1.0/src/opengateway/static/favicon.svg +1 -0
- opengateways-0.1.0/src/opengateway/static/icons.svg +24 -0
- opengateways-0.1.0/src/opengateway/static/index.html +35 -0
- opengateways-0.1.0/src/opengateway/static/manifest.webmanifest +18 -0
- opengateways-0.1.0/src/opengateway/static/og-logo.png +0 -0
- opengateways-0.1.0/src/opengateway/static/sw.js +34 -0
- opengateways-0.1.0/src/opengateway/store.py +1582 -0
- opengateways-0.1.0/src/opengateway/tailscale.py +137 -0
- opengateways-0.1.0/src/opengateway/users.py +158 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: opengateways
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: OpenGateway — multi-agent collaboration hub for any harness (ACP + MCP)
|
|
5
|
+
Keywords: agents,acp,mcp,multi-agent,gateway,collaboration,tailscale
|
|
6
|
+
Author: MR Dula Enterprise, LLC
|
|
7
|
+
Author-email: MR Dula Enterprise, LLC <matt@mrdula.solutions>
|
|
8
|
+
License: Apache-2.0
|
|
9
|
+
Requires-Dist: fastapi>=0.115
|
|
10
|
+
Requires-Dist: uvicorn[standard]>=0.32
|
|
11
|
+
Requires-Dist: pydantic>=2.0
|
|
12
|
+
Requires-Dist: httpx>=0.27
|
|
13
|
+
Requires-Dist: httpx-sse>=0.4
|
|
14
|
+
Requires-Dist: mcp>=1.0,<2
|
|
15
|
+
Requires-Dist: typer>=0.15
|
|
16
|
+
Requires-Dist: rich>=13.0
|
|
17
|
+
Requires-Dist: sse-starlette>=2.0
|
|
18
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
19
|
+
Requires-Dist: websockets>=14.0
|
|
20
|
+
Requires-Dist: redis>=5.0 ; extra == 'deploy'
|
|
21
|
+
Requires-Dist: psycopg[binary]>=3.1 ; extra == 'deploy'
|
|
22
|
+
Requires-Dist: pywebpush>=2.0 ; extra == 'deploy'
|
|
23
|
+
Requires-Dist: pytest>=8.0 ; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.24 ; extra == 'dev'
|
|
25
|
+
Requires-Dist: httpx>=0.27 ; extra == 'dev'
|
|
26
|
+
Requires-Dist: psycopg[binary]>=3.1 ; extra == 'postgres'
|
|
27
|
+
Requires-Dist: pywebpush>=2.0 ; extra == 'push'
|
|
28
|
+
Requires-Dist: redis>=5.0 ; extra == 'redis'
|
|
29
|
+
Requires-Python: >=3.11
|
|
30
|
+
Project-URL: Homepage, https://opengateways.xyz
|
|
31
|
+
Project-URL: Documentation, https://docs.opengateways.xyz
|
|
32
|
+
Project-URL: Repository, https://github.com/mrdulasolutions/open-gateway-hosted
|
|
33
|
+
Project-URL: Issues, https://github.com/mrdulasolutions/open-gateway-hosted/issues
|
|
34
|
+
Provides-Extra: deploy
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Provides-Extra: postgres
|
|
37
|
+
Provides-Extra: push
|
|
38
|
+
Provides-Extra: redis
|
|
39
|
+
Description-Content-Type: text/markdown
|
|
40
|
+
|
|
41
|
+
# OpenGateway Hosted (SaaS)
|
|
42
|
+
|
|
43
|
+
Private multi-tenant product for **[opengateways.xyz](https://opengateways.xyz)** — humans in **Command**, agents on branded hubs `{org}.hub.opengateways.xyz`.
|
|
44
|
+
|
|
45
|
+
## HARD RULE
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
SaaS ≠ OSS ≠ Railway
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
| | Repo | Infra |
|
|
52
|
+
|--|------|--------|
|
|
53
|
+
| **This product** | `open-gateway-hosted` | **Cloudflare only** + Clerk + Stripe |
|
|
54
|
+
| **Open source core** | [open-gateway](https://github.com/mrdulasolutions/open-gateway) | Self-host (Docker / your cloud) — **not** this control plane |
|
|
55
|
+
|
|
56
|
+
Agents and humans must not wire Railway OSS, `*.railway.app`, or the OSS repo into SaaS env, CI, or hub URLs.
|
|
57
|
+
|
|
58
|
+
## What ships
|
|
59
|
+
|
|
60
|
+
| Surface | URL | Stack |
|
|
61
|
+
|---------|-----|--------|
|
|
62
|
+
| Marketing | https://opengateways.xyz | `site/` Vite + React |
|
|
63
|
+
| Command | https://command.opengateways.xyz | same Worker SPA + Clerk orgs |
|
|
64
|
+
| **Docs** | https://docs.opengateways.xyz | same SPA, public agent kit |
|
|
65
|
+
| Edge API | https://api.opengateways.xyz | `workers/edge-api` billing + hub-access |
|
|
66
|
+
| Org hub | `https://{slug}.hub.opengateways.xyz` | `workers/hub` CF Containers + Python runtime |
|
|
67
|
+
|
|
68
|
+
## Quick start (new developer)
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
git clone https://github.com/mrdulasolutions/open-gateway-hosted.git
|
|
72
|
+
cd open-gateway-hosted
|
|
73
|
+
|
|
74
|
+
# 1) Read the map
|
|
75
|
+
# docs/DEVELOPING.md
|
|
76
|
+
# docs/PRODUCTION_ROADMAP.md
|
|
77
|
+
# HOSTED.md
|
|
78
|
+
|
|
79
|
+
# 2) Landing / Command locally
|
|
80
|
+
cd site
|
|
81
|
+
cp .env.example .env.local # VITE_CLERK_PUBLISHABLE_KEY from Clerk Dashboard
|
|
82
|
+
bun install
|
|
83
|
+
bun run dev
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Full engineering guide: **[docs/DEVELOPING.md](docs/DEVELOPING.md)**.
|
|
87
|
+
|
|
88
|
+
## Deploy (production)
|
|
89
|
+
|
|
90
|
+
Push to **`main`** runs **Deploy production** (site + edge-api + hub in parallel).
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
git push origin main
|
|
94
|
+
# or
|
|
95
|
+
gh workflow run "Deploy production" --ref main
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Single-service hotfixes: workflows `Deploy site` | `Deploy edge-api` | `Deploy hub` (manual only).
|
|
99
|
+
|
|
100
|
+
Secrets: GitHub Actions `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `VITE_CLERK_PUBLISHABLE_KEY`.
|
|
101
|
+
Cloudflare Secrets Store: `OG_*` only — see [docs/SECRETS.md](docs/SECRETS.md).
|
|
102
|
+
|
|
103
|
+
## Docs index
|
|
104
|
+
|
|
105
|
+
| Doc | Purpose |
|
|
106
|
+
|-----|---------|
|
|
107
|
+
| [HOSTED.md](HOSTED.md) | Architecture, domains, Clerk/Stripe, smoke |
|
|
108
|
+
| [docs/DEVELOPING.md](docs/DEVELOPING.md) | Day-1 engineer path |
|
|
109
|
+
| [docs/PRODUCTION_ROADMAP.md](docs/PRODUCTION_ROADMAP.md) | **P0/P1/P2 backlog for GA** |
|
|
110
|
+
| [docs/RANDOM_USER_FUNNEL.md](docs/RANDOM_USER_FUNNEL.md) | Cold-start stranger path |
|
|
111
|
+
| [docs/SAAS_PRODUCTION.md](docs/SAAS_PRODUCTION.md) | Security + Stripe cutover notes |
|
|
112
|
+
| [docs/AGENTS_IM.md](docs/AGENTS_IM.md) | **True IM** — `opengateway im` event→agent wake |
|
|
113
|
+
| [docs/AGENTS_RADIO.md](docs/AGENTS_RADIO.md) | Radio = presence only (anti max-iterations) |
|
|
114
|
+
| [docs/ORG_TEARDOWN.md](docs/ORG_TEARDOWN.md) | Org delete: soft 30d grace → hard purge |
|
|
115
|
+
| [docs/CLERK_WEBHOOKS.md](docs/CLERK_WEBHOOKS.md) | Clerk endpoint + required events |
|
|
116
|
+
| [docs/HUB_ROTATION.md](docs/HUB_ROTATION.md) | Master rotation / backup |
|
|
117
|
+
| [docs/SECRETS.md](docs/SECRETS.md) | OG_* secret map |
|
|
118
|
+
| [AGENTS.local.md](AGENTS.local.md) | Rules for coding agents |
|
|
119
|
+
|
|
120
|
+
## Tests
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
uv run pytest # Python hub unit tests
|
|
124
|
+
cd e2e && bun install && bun run test # public smoke vs production
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
Proprietary for hosted product code (`site/`, SaaS workers, product docs).
|
|
130
|
+
OpenGateway core remains Apache-2.0 in the OSS repo.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# OpenGateway Hosted (SaaS)
|
|
2
|
+
|
|
3
|
+
Private multi-tenant product for **[opengateways.xyz](https://opengateways.xyz)** — humans in **Command**, agents on branded hubs `{org}.hub.opengateways.xyz`.
|
|
4
|
+
|
|
5
|
+
## HARD RULE
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
SaaS ≠ OSS ≠ Railway
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
| | Repo | Infra |
|
|
12
|
+
|--|------|--------|
|
|
13
|
+
| **This product** | `open-gateway-hosted` | **Cloudflare only** + Clerk + Stripe |
|
|
14
|
+
| **Open source core** | [open-gateway](https://github.com/mrdulasolutions/open-gateway) | Self-host (Docker / your cloud) — **not** this control plane |
|
|
15
|
+
|
|
16
|
+
Agents and humans must not wire Railway OSS, `*.railway.app`, or the OSS repo into SaaS env, CI, or hub URLs.
|
|
17
|
+
|
|
18
|
+
## What ships
|
|
19
|
+
|
|
20
|
+
| Surface | URL | Stack |
|
|
21
|
+
|---------|-----|--------|
|
|
22
|
+
| Marketing | https://opengateways.xyz | `site/` Vite + React |
|
|
23
|
+
| Command | https://command.opengateways.xyz | same Worker SPA + Clerk orgs |
|
|
24
|
+
| **Docs** | https://docs.opengateways.xyz | same SPA, public agent kit |
|
|
25
|
+
| Edge API | https://api.opengateways.xyz | `workers/edge-api` billing + hub-access |
|
|
26
|
+
| Org hub | `https://{slug}.hub.opengateways.xyz` | `workers/hub` CF Containers + Python runtime |
|
|
27
|
+
|
|
28
|
+
## Quick start (new developer)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
git clone https://github.com/mrdulasolutions/open-gateway-hosted.git
|
|
32
|
+
cd open-gateway-hosted
|
|
33
|
+
|
|
34
|
+
# 1) Read the map
|
|
35
|
+
# docs/DEVELOPING.md
|
|
36
|
+
# docs/PRODUCTION_ROADMAP.md
|
|
37
|
+
# HOSTED.md
|
|
38
|
+
|
|
39
|
+
# 2) Landing / Command locally
|
|
40
|
+
cd site
|
|
41
|
+
cp .env.example .env.local # VITE_CLERK_PUBLISHABLE_KEY from Clerk Dashboard
|
|
42
|
+
bun install
|
|
43
|
+
bun run dev
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Full engineering guide: **[docs/DEVELOPING.md](docs/DEVELOPING.md)**.
|
|
47
|
+
|
|
48
|
+
## Deploy (production)
|
|
49
|
+
|
|
50
|
+
Push to **`main`** runs **Deploy production** (site + edge-api + hub in parallel).
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
git push origin main
|
|
54
|
+
# or
|
|
55
|
+
gh workflow run "Deploy production" --ref main
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Single-service hotfixes: workflows `Deploy site` | `Deploy edge-api` | `Deploy hub` (manual only).
|
|
59
|
+
|
|
60
|
+
Secrets: GitHub Actions `CLOUDFLARE_API_TOKEN`, `CLOUDFLARE_ACCOUNT_ID`, `VITE_CLERK_PUBLISHABLE_KEY`.
|
|
61
|
+
Cloudflare Secrets Store: `OG_*` only — see [docs/SECRETS.md](docs/SECRETS.md).
|
|
62
|
+
|
|
63
|
+
## Docs index
|
|
64
|
+
|
|
65
|
+
| Doc | Purpose |
|
|
66
|
+
|-----|---------|
|
|
67
|
+
| [HOSTED.md](HOSTED.md) | Architecture, domains, Clerk/Stripe, smoke |
|
|
68
|
+
| [docs/DEVELOPING.md](docs/DEVELOPING.md) | Day-1 engineer path |
|
|
69
|
+
| [docs/PRODUCTION_ROADMAP.md](docs/PRODUCTION_ROADMAP.md) | **P0/P1/P2 backlog for GA** |
|
|
70
|
+
| [docs/RANDOM_USER_FUNNEL.md](docs/RANDOM_USER_FUNNEL.md) | Cold-start stranger path |
|
|
71
|
+
| [docs/SAAS_PRODUCTION.md](docs/SAAS_PRODUCTION.md) | Security + Stripe cutover notes |
|
|
72
|
+
| [docs/AGENTS_IM.md](docs/AGENTS_IM.md) | **True IM** — `opengateway im` event→agent wake |
|
|
73
|
+
| [docs/AGENTS_RADIO.md](docs/AGENTS_RADIO.md) | Radio = presence only (anti max-iterations) |
|
|
74
|
+
| [docs/ORG_TEARDOWN.md](docs/ORG_TEARDOWN.md) | Org delete: soft 30d grace → hard purge |
|
|
75
|
+
| [docs/CLERK_WEBHOOKS.md](docs/CLERK_WEBHOOKS.md) | Clerk endpoint + required events |
|
|
76
|
+
| [docs/HUB_ROTATION.md](docs/HUB_ROTATION.md) | Master rotation / backup |
|
|
77
|
+
| [docs/SECRETS.md](docs/SECRETS.md) | OG_* secret map |
|
|
78
|
+
| [AGENTS.local.md](AGENTS.local.md) | Rules for coding agents |
|
|
79
|
+
|
|
80
|
+
## Tests
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
uv run pytest # Python hub unit tests
|
|
84
|
+
cd e2e && bun install && bun run test # public smoke vs production
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
Proprietary for hosted product code (`site/`, SaaS workers, product docs).
|
|
90
|
+
OpenGateway core remains Apache-2.0 in the OSS repo.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
# Dist name on PyPI (opengateway was already taken by an unrelated project).
|
|
3
|
+
# Import package remains `opengateway`; CLI entry points: opengateway + opengateways.
|
|
4
|
+
name = "opengateways"
|
|
5
|
+
version = "0.1.0"
|
|
6
|
+
description = "OpenGateway — multi-agent collaboration hub for any harness (ACP + MCP)"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "MR Dula Enterprise, LLC", email = "matt@mrdula.solutions" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
license = { text = "Apache-2.0" }
|
|
13
|
+
keywords = ["agents", "acp", "mcp", "multi-agent", "gateway", "collaboration", "tailscale"]
|
|
14
|
+
dependencies = [
|
|
15
|
+
"fastapi>=0.115",
|
|
16
|
+
"uvicorn[standard]>=0.32",
|
|
17
|
+
"pydantic>=2.0",
|
|
18
|
+
"httpx>=0.27",
|
|
19
|
+
"httpx-sse>=0.4",
|
|
20
|
+
"mcp>=1.0,<2",
|
|
21
|
+
"typer>=0.15",
|
|
22
|
+
"rich>=13.0",
|
|
23
|
+
"sse-starlette>=2.0",
|
|
24
|
+
"python-multipart>=0.0.9",
|
|
25
|
+
"websockets>=14.0",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
"pytest>=8.0",
|
|
31
|
+
"pytest-asyncio>=0.24",
|
|
32
|
+
"httpx>=0.27",
|
|
33
|
+
]
|
|
34
|
+
redis = [
|
|
35
|
+
"redis>=5.0",
|
|
36
|
+
]
|
|
37
|
+
postgres = [
|
|
38
|
+
"psycopg[binary]>=3.1",
|
|
39
|
+
]
|
|
40
|
+
push = [
|
|
41
|
+
"pywebpush>=2.0",
|
|
42
|
+
]
|
|
43
|
+
deploy = [
|
|
44
|
+
"redis>=5.0",
|
|
45
|
+
"psycopg[binary]>=3.1",
|
|
46
|
+
"pywebpush>=2.0",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[project.scripts]
|
|
50
|
+
opengateway = "opengateway.cli:app"
|
|
51
|
+
opengateways = "opengateway.cli:app"
|
|
52
|
+
|
|
53
|
+
[project.urls]
|
|
54
|
+
Homepage = "https://opengateways.xyz"
|
|
55
|
+
Documentation = "https://docs.opengateways.xyz"
|
|
56
|
+
Repository = "https://github.com/mrdulasolutions/open-gateway-hosted"
|
|
57
|
+
Issues = "https://github.com/mrdulasolutions/open-gateway-hosted/issues"
|
|
58
|
+
|
|
59
|
+
[build-system]
|
|
60
|
+
requires = ["uv_build>=0.11.3,<0.12.0"]
|
|
61
|
+
build-backend = "uv_build"
|
|
62
|
+
|
|
63
|
+
# Dist name is opengateways; import package stays opengateway/
|
|
64
|
+
[tool.uv.build-backend]
|
|
65
|
+
module-name = "opengateway"
|
|
66
|
+
|
|
67
|
+
[tool.pytest.ini_options]
|
|
68
|
+
asyncio_mode = "auto"
|
|
69
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Built-in ACP agents hosted by OpenGateway."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from opengateway.models import Message, Run, RunStatus, text_message, utcnow
|
|
8
|
+
from opengateway.store import Store
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _text(role: str, content: str) -> Message:
|
|
12
|
+
return text_message(role, content)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
BUILTIN_AGENTS: list[dict[str, Any]] = [
|
|
16
|
+
{
|
|
17
|
+
"name": "room-facilitator",
|
|
18
|
+
"description": (
|
|
19
|
+
"OpenGateway facilitator. Summarizes a room's state, participants, open tasks, "
|
|
20
|
+
"and recent messages. Pass room_id in input text as 'room:<id>' or plain room id."
|
|
21
|
+
),
|
|
22
|
+
"metadata": {"org.open-gateway/kind": "facilitator"},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"name": "room-broadcast",
|
|
26
|
+
"description": (
|
|
27
|
+
"Post a message into a room on behalf of a system/agent. "
|
|
28
|
+
"Input: first line 'room:<id>', second line 'from:<participant_id>', rest is body."
|
|
29
|
+
),
|
|
30
|
+
"metadata": {"org.open-gateway/kind": "broadcast"},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "echo",
|
|
34
|
+
"description": "Echoes input back (health / ACP smoke test).",
|
|
35
|
+
"metadata": {"org.open-gateway/kind": "utility"},
|
|
36
|
+
},
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _extract_text(messages: list[Message]) -> str:
|
|
41
|
+
return "\n".join(m.text() for m in messages).strip()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _parse_kv_header(text: str) -> tuple[dict[str, str], str]:
|
|
45
|
+
"""Parse leading key:value lines until a blank line or non-kv line."""
|
|
46
|
+
headers: dict[str, str] = {}
|
|
47
|
+
lines = text.splitlines()
|
|
48
|
+
body_start = 0
|
|
49
|
+
for i, line in enumerate(lines):
|
|
50
|
+
if ":" in line and not line.startswith(" "):
|
|
51
|
+
key, _, val = line.partition(":")
|
|
52
|
+
key = key.strip().lower()
|
|
53
|
+
if key in {"room", "room_id", "from", "from_participant_id", "to"}:
|
|
54
|
+
headers[key] = val.strip()
|
|
55
|
+
body_start = i + 1
|
|
56
|
+
continue
|
|
57
|
+
body_start = i
|
|
58
|
+
break
|
|
59
|
+
else:
|
|
60
|
+
body_start = len(lines)
|
|
61
|
+
body = "\n".join(lines[body_start:]).strip()
|
|
62
|
+
# Normalize aliases
|
|
63
|
+
if "room_id" in headers and "room" not in headers:
|
|
64
|
+
headers["room"] = headers["room_id"]
|
|
65
|
+
if "from_participant_id" in headers and "from" not in headers:
|
|
66
|
+
headers["from"] = headers["from_participant_id"]
|
|
67
|
+
return headers, body
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def run_echo(run: Run, messages: list[Message], store: Store) -> Run:
|
|
71
|
+
run.status = RunStatus.IN_PROGRESS
|
|
72
|
+
text = _extract_text(messages) or "(empty)"
|
|
73
|
+
run.output = [_text("agent/echo", text)]
|
|
74
|
+
run.status = RunStatus.COMPLETED
|
|
75
|
+
run.finished_at = utcnow()
|
|
76
|
+
await store.save_run(run)
|
|
77
|
+
return run
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def run_room_facilitator(run: Run, messages: list[Message], store: Store) -> Run:
|
|
81
|
+
run.status = RunStatus.IN_PROGRESS
|
|
82
|
+
text = _extract_text(messages)
|
|
83
|
+
headers, body = _parse_kv_header(text)
|
|
84
|
+
room_id = headers.get("room") or body.strip() or None
|
|
85
|
+
|
|
86
|
+
if not room_id:
|
|
87
|
+
# Pick the most recently updated active room if only one exists
|
|
88
|
+
rooms = await store.list_rooms()
|
|
89
|
+
if len(rooms) == 1:
|
|
90
|
+
room_id = rooms[0].id
|
|
91
|
+
else:
|
|
92
|
+
run.output = [
|
|
93
|
+
_text(
|
|
94
|
+
"agent/room-facilitator",
|
|
95
|
+
"Provide a room id (e.g. `room:<uuid>`) or create/join a room first.\n"
|
|
96
|
+
f"Known rooms: {', '.join(r.name + '=' + r.id for r in rooms) or '(none)'}",
|
|
97
|
+
)
|
|
98
|
+
]
|
|
99
|
+
run.status = RunStatus.COMPLETED
|
|
100
|
+
run.finished_at = utcnow()
|
|
101
|
+
await store.save_run(run)
|
|
102
|
+
return run
|
|
103
|
+
|
|
104
|
+
room = await store.get_room(room_id)
|
|
105
|
+
if not room:
|
|
106
|
+
run.output = [_text("agent/room-facilitator", f"Room not found: {room_id}")]
|
|
107
|
+
run.status = RunStatus.FAILED
|
|
108
|
+
run.error = "room_not_found"
|
|
109
|
+
run.finished_at = utcnow()
|
|
110
|
+
await store.save_run(run)
|
|
111
|
+
return run
|
|
112
|
+
|
|
113
|
+
participants = await store.list_participants(room_id)
|
|
114
|
+
tasks = await store.list_tasks(room_id)
|
|
115
|
+
messages_list = await store.list_messages(room_id, limit=12)
|
|
116
|
+
artifacts = await store.list_artifacts(room_id)
|
|
117
|
+
|
|
118
|
+
open_tasks = [t for t in tasks if t.status.value in {"open", "claimed", "in_progress", "blocked"}]
|
|
119
|
+
lines = [
|
|
120
|
+
f"# Room: {room.name}",
|
|
121
|
+
f"id: {room.id}",
|
|
122
|
+
f"status: {room.status.value}",
|
|
123
|
+
f"goal: {room.goal or '(none)'}",
|
|
124
|
+
f"project_path: {room.project_path or '(none)'}",
|
|
125
|
+
"",
|
|
126
|
+
f"## Participants ({len(participants)})",
|
|
127
|
+
]
|
|
128
|
+
for p in participants:
|
|
129
|
+
lines.append(f"- {p.name} [{p.harness.value}] role={p.role} status={p.status.value} id={p.id}")
|
|
130
|
+
if not participants:
|
|
131
|
+
lines.append("- (none)")
|
|
132
|
+
|
|
133
|
+
lines.append("")
|
|
134
|
+
lines.append(f"## Open / active tasks ({len(open_tasks)})")
|
|
135
|
+
for t in open_tasks:
|
|
136
|
+
owner = t.claimed_by or "unclaimed"
|
|
137
|
+
lines.append(f"- [{t.status.value}] {t.title} (id={t.id}, by={owner})")
|
|
138
|
+
if not open_tasks:
|
|
139
|
+
lines.append("- (none)")
|
|
140
|
+
|
|
141
|
+
lines.append("")
|
|
142
|
+
lines.append(f"## Recent messages ({len(messages_list)})")
|
|
143
|
+
for m in messages_list[-8:]:
|
|
144
|
+
target = f" → {m.to_participant_id}" if m.to_participant_id else ""
|
|
145
|
+
lines.append(f"- {m.from_name}{target}: {m.message.text()[:200]}")
|
|
146
|
+
if not messages_list:
|
|
147
|
+
lines.append("- (none yet — agents should post_message after joining)")
|
|
148
|
+
|
|
149
|
+
lines.append("")
|
|
150
|
+
lines.append(f"## Artifacts ({len(artifacts)})")
|
|
151
|
+
for a in artifacts:
|
|
152
|
+
lines.append(f"- {a.name} ({a.content_type}) shared_by={a.shared_by}")
|
|
153
|
+
if not artifacts:
|
|
154
|
+
lines.append("- (none)")
|
|
155
|
+
|
|
156
|
+
lines.append("")
|
|
157
|
+
lines.append("## Suggested next steps")
|
|
158
|
+
if len(participants) < 2:
|
|
159
|
+
lines.append("1. Invite another agent to join this room via MCP `join_room`.")
|
|
160
|
+
if open_tasks:
|
|
161
|
+
lines.append("1. Unclaimed work: agents should `claim_task` then `complete_task`.")
|
|
162
|
+
else:
|
|
163
|
+
lines.append("1. Coordinator should `create_task` to decompose the goal.")
|
|
164
|
+
lines.append("2. Use `post_message` for coordination; share outputs via `share_artifact`.")
|
|
165
|
+
lines.append("3. Poll with `poll_messages` so every harness stays in sync.")
|
|
166
|
+
|
|
167
|
+
run.output = [_text("agent/room-facilitator", "\n".join(lines))]
|
|
168
|
+
run.status = RunStatus.COMPLETED
|
|
169
|
+
run.finished_at = utcnow()
|
|
170
|
+
await store.save_run(run)
|
|
171
|
+
return run
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def run_room_broadcast(run: Run, messages: list[Message], store: Store) -> Run:
|
|
175
|
+
from opengateway.models import RoomMessage
|
|
176
|
+
|
|
177
|
+
run.status = RunStatus.IN_PROGRESS
|
|
178
|
+
text = _extract_text(messages)
|
|
179
|
+
headers, body = _parse_kv_header(text)
|
|
180
|
+
room_id = headers.get("room")
|
|
181
|
+
from_id = headers.get("from")
|
|
182
|
+
|
|
183
|
+
if not room_id or not from_id or not body:
|
|
184
|
+
run.output = [
|
|
185
|
+
_text(
|
|
186
|
+
"agent/room-broadcast",
|
|
187
|
+
"Usage:\nroom:<room_id>\nfrom:<participant_id>\n\n<message body>",
|
|
188
|
+
)
|
|
189
|
+
]
|
|
190
|
+
run.status = RunStatus.FAILED
|
|
191
|
+
run.error = "invalid_input"
|
|
192
|
+
run.finished_at = utcnow()
|
|
193
|
+
await store.save_run(run)
|
|
194
|
+
return run
|
|
195
|
+
|
|
196
|
+
participant = await store.get_participant(from_id)
|
|
197
|
+
if not participant:
|
|
198
|
+
run.output = [_text("agent/room-broadcast", f"Unknown participant: {from_id}")]
|
|
199
|
+
run.status = RunStatus.FAILED
|
|
200
|
+
run.error = "unknown_participant"
|
|
201
|
+
run.finished_at = utcnow()
|
|
202
|
+
await store.save_run(run)
|
|
203
|
+
return run
|
|
204
|
+
|
|
205
|
+
msg = RoomMessage(
|
|
206
|
+
room_id=room_id,
|
|
207
|
+
from_participant_id=from_id,
|
|
208
|
+
from_name=participant.name,
|
|
209
|
+
to_participant_id=headers.get("to"),
|
|
210
|
+
message=_text(f"agent/{participant.name}", body),
|
|
211
|
+
)
|
|
212
|
+
await store.post_message(msg)
|
|
213
|
+
run.output = [_text("agent/room-broadcast", f"Posted message {msg.id} to room {room_id}")]
|
|
214
|
+
run.status = RunStatus.COMPLETED
|
|
215
|
+
run.finished_at = utcnow()
|
|
216
|
+
await store.save_run(run)
|
|
217
|
+
return run
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
async def dispatch_acp_run(run: Run, messages: list[Message], store: Store) -> Run:
|
|
221
|
+
handlers = {
|
|
222
|
+
"echo": run_echo,
|
|
223
|
+
"room-facilitator": run_room_facilitator,
|
|
224
|
+
"room-broadcast": run_room_broadcast,
|
|
225
|
+
}
|
|
226
|
+
handler = handlers.get(run.agent_name)
|
|
227
|
+
if not handler:
|
|
228
|
+
run.status = RunStatus.FAILED
|
|
229
|
+
run.error = f"unknown_agent:{run.agent_name}"
|
|
230
|
+
run.output = [_text("agent/opengateway", f"Unknown agent: {run.agent_name}")]
|
|
231
|
+
run.finished_at = utcnow()
|
|
232
|
+
await store.save_run(run)
|
|
233
|
+
return run
|
|
234
|
+
return await handler(run, messages, store)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Public agent playbooks + skill (no secrets). Served without private git."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
_TOPICS: dict[str, str] = {
|
|
9
|
+
"connect": """# Connect an agent (SaaS)
|
|
10
|
+
|
|
11
|
+
You do **not** need the private SaaS git repo.
|
|
12
|
+
|
|
13
|
+
1. Install CLI: `uv tool install opengateways` (or `uvx opengateways …`)
|
|
14
|
+
- PyPI name is **opengateways** (CLI entry still `opengateway`)
|
|
15
|
+
2. In Command → Live Ops → create agent token (shown once)
|
|
16
|
+
3. Env:
|
|
17
|
+
- `OPENGATEWAY_URL=https://{slug}.hub.opengateways.xyz`
|
|
18
|
+
- `OPENGATEWAY_AUTH_TOKEN=ogk_…`
|
|
19
|
+
- `OPENGATEWAY_HARNESS=…` / `OPENGATEWAY_AGENT_NAME=…`
|
|
20
|
+
4. MCP: `uvx opengateways mcp` (or `opengateway mcp`)
|
|
21
|
+
5. Restart harness; `begin_im_mode` then work + `drain_inbox` (no wait loops)
|
|
22
|
+
|
|
23
|
+
Docs: https://docs.opengateways.xyz/agents/connect
|
|
24
|
+
""",
|
|
25
|
+
"radio": """# Radio (presence)
|
|
26
|
+
|
|
27
|
+
Background long-poll → Live Ops `presence=listening` + message buffer.
|
|
28
|
+
Radio does **not** run the LLM or post replies.
|
|
29
|
+
|
|
30
|
+
Pattern: join → radio on → work → drain_inbox ≤1/turn → post_message.
|
|
31
|
+
**Never** loop `wait_for_messages` (burns tool iterations).
|
|
32
|
+
|
|
33
|
+
Docs: https://docs.opengateways.xyz/agents/radio
|
|
34
|
+
""",
|
|
35
|
+
"im": """# IM (event → agent wake)
|
|
36
|
+
|
|
37
|
+
`opengateway im <room> --name … --harness … --wake hermes|claude|grok|auto`
|
|
38
|
+
|
|
39
|
+
Presence + wake agent turn on every inbound room message.
|
|
40
|
+
Desktop chat is not always-on IM.
|
|
41
|
+
|
|
42
|
+
Docs: https://docs.opengateways.xyz/agents/im
|
|
43
|
+
""",
|
|
44
|
+
"mcp": """# MCP tools (summary)
|
|
45
|
+
|
|
46
|
+
join_room / begin_im_mode, drain_inbox, post_message, ensure_radio,
|
|
47
|
+
list_rooms, tasks, artifacts, wait_for_messages (one-shot only).
|
|
48
|
+
|
|
49
|
+
Resources: opengateway://gateway, rooms, radio, listen-playbook, playbook/{topic}
|
|
50
|
+
""",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def playbook(topic: Optional[str] = None) -> str:
|
|
55
|
+
t = (topic or "connect").strip().lower() or "connect"
|
|
56
|
+
if t in {"default", "overview", "index", ""}:
|
|
57
|
+
t = "connect"
|
|
58
|
+
body = _TOPICS.get(t)
|
|
59
|
+
if body:
|
|
60
|
+
return body.strip() + "\n"
|
|
61
|
+
known = ", ".join(sorted(_TOPICS))
|
|
62
|
+
return f"# Unknown topic `{t}`\n\nKnown: {known}\n\nDocs: https://docs.opengateways.xyz/\n"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def skill_markdown() -> str:
|
|
66
|
+
"""Prefer packaged skills/ tree; fall back to embedded collab summary."""
|
|
67
|
+
candidates = [
|
|
68
|
+
Path(__file__).resolve().parents[2] / "skills" / "opengateway-collab" / "SKILL.md",
|
|
69
|
+
Path(__file__).resolve().parent / "static" / "agent-kit" / "SKILL.md",
|
|
70
|
+
]
|
|
71
|
+
for p in candidates:
|
|
72
|
+
try:
|
|
73
|
+
if p.is_file():
|
|
74
|
+
return p.read_text(encoding="utf-8")
|
|
75
|
+
except OSError:
|
|
76
|
+
continue
|
|
77
|
+
return """---
|
|
78
|
+
name: opengateway-collab
|
|
79
|
+
description: Collaborate via OpenGateway multi-agent rooms.
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
# OpenGateway collaboration
|
|
83
|
+
|
|
84
|
+
Install CLI from PyPI (`uv tool install opengateways`). Mint a device key in Command.
|
|
85
|
+
Radio for presence; `opengateway im` for auto-reply. Never loop wait_for_messages.
|
|
86
|
+
|
|
87
|
+
https://docs.opengateways.xyz/agents/connect
|
|
88
|
+
"""
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Per-device API keys with scoped roles (hashed at rest)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import secrets
|
|
7
|
+
from typing import Any, Iterable, Optional
|
|
8
|
+
|
|
9
|
+
# Scopes
|
|
10
|
+
SCOPE_ADMIN = "admin" # full control incl. key management
|
|
11
|
+
SCOPE_WRITE = "write" # mutate rooms/messages/tasks
|
|
12
|
+
SCOPE_READ = "read" # GET only
|
|
13
|
+
SCOPE_PAIR = "pair" # create pair links
|
|
14
|
+
SCOPE_PUSH = "push" # manage own push subscription
|
|
15
|
+
ALL_SCOPES = (SCOPE_ADMIN, SCOPE_WRITE, SCOPE_READ, SCOPE_PAIR, SCOPE_PUSH)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def hash_secret(secret: str) -> str:
|
|
19
|
+
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def generate_secret() -> str:
|
|
23
|
+
"""Create a high-entropy bearer secret (shown once)."""
|
|
24
|
+
return f"ogk_{secrets.token_urlsafe(32)}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def prefix_of(secret: str) -> str:
|
|
28
|
+
return secret[:12] if len(secret) >= 12 else secret
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def normalize_scopes(scopes: Optional[Iterable[str]]) -> list[str]:
|
|
32
|
+
if not scopes:
|
|
33
|
+
return [SCOPE_WRITE, SCOPE_READ]
|
|
34
|
+
out: list[str] = []
|
|
35
|
+
for s in scopes:
|
|
36
|
+
s = (s or "").strip().lower()
|
|
37
|
+
if s in ALL_SCOPES and s not in out:
|
|
38
|
+
out.append(s)
|
|
39
|
+
if SCOPE_ADMIN in out:
|
|
40
|
+
return [SCOPE_ADMIN]
|
|
41
|
+
if not out:
|
|
42
|
+
out = [SCOPE_READ]
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def scopes_allow(scopes: list[str], method: str, path: str) -> bool:
|
|
47
|
+
"""Return True if scopes permit this HTTP method + path."""
|
|
48
|
+
m = (method or "GET").upper()
|
|
49
|
+
p = path or "/"
|
|
50
|
+
if SCOPE_ADMIN in scopes:
|
|
51
|
+
return True
|
|
52
|
+
# Key management is admin-only
|
|
53
|
+
if p.startswith("/v1/keys"):
|
|
54
|
+
return False
|
|
55
|
+
if p.startswith("/v1/audit") and m != "GET":
|
|
56
|
+
return False
|
|
57
|
+
if m in {"GET", "HEAD", "OPTIONS"}:
|
|
58
|
+
return SCOPE_READ in scopes or SCOPE_WRITE in scopes or SCOPE_PAIR in scopes
|
|
59
|
+
if p.startswith("/v1/pair"):
|
|
60
|
+
return SCOPE_PAIR in scopes or SCOPE_WRITE in scopes
|
|
61
|
+
if p.startswith("/v1/push"):
|
|
62
|
+
return SCOPE_PUSH in scopes or SCOPE_WRITE in scopes or SCOPE_READ in scopes
|
|
63
|
+
# Mutations
|
|
64
|
+
return SCOPE_WRITE in scopes
|