port42 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.
- port42-0.1.0/.github/workflows/publish.yml +23 -0
- port42-0.1.0/.gitignore +10 -0
- port42-0.1.0/PKG-INFO +88 -0
- port42-0.1.0/README.md +71 -0
- port42-0.1.0/examples/basic/agent.py +9 -0
- port42-0.1.0/examples/langchain/agent.py +23 -0
- port42-0.1.0/examples/pipeline/agent.py +25 -0
- port42-0.1.0/port42/__init__.py +4 -0
- port42-0.1.0/port42/agent.py +304 -0
- port42-0.1.0/port42/cli.py +90 -0
- port42-0.1.0/port42/langchain/__init__.py +11 -0
- port42-0.1.0/port42/langchain/callback.py +50 -0
- port42-0.1.0/port42/langchain/tools.py +55 -0
- port42-0.1.0/port42/types.py +50 -0
- port42-0.1.0/pyproject.toml +31 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags:
|
|
6
|
+
- "v*"
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
publish:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.11"
|
|
18
|
+
- name: Install hatch
|
|
19
|
+
run: pip install hatch
|
|
20
|
+
- name: Build
|
|
21
|
+
run: hatch build
|
|
22
|
+
- name: Publish
|
|
23
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
port42-0.1.0/.gitignore
ADDED
port42-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: port42
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for Port42 — connect external agents to your macOS companion
|
|
5
|
+
Project-URL: Homepage, https://github.com/gordonmattey/port42-python
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: websockets>=12.0
|
|
9
|
+
Provides-Extra: all
|
|
10
|
+
Requires-Dist: click>=8.0; extra == 'all'
|
|
11
|
+
Requires-Dist: langchain-core>=0.3; extra == 'all'
|
|
12
|
+
Provides-Extra: cli
|
|
13
|
+
Requires-Dist: click>=8.0; extra == 'cli'
|
|
14
|
+
Provides-Extra: langchain
|
|
15
|
+
Requires-Dist: langchain-core>=0.3; extra == 'langchain'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# port42
|
|
19
|
+
|
|
20
|
+
Python SDK for [Port42](https://github.com/gordonmattey/port42-native) — connect external agents to your macOS companion.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install port42
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from port42 import Agent
|
|
32
|
+
|
|
33
|
+
agent = Agent("my-agent", channels=["#general"])
|
|
34
|
+
|
|
35
|
+
@agent.on_mention
|
|
36
|
+
def handle(msg):
|
|
37
|
+
return f"Hello {msg.sender}!"
|
|
38
|
+
|
|
39
|
+
agent.run()
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Port42 must be running. The agent connects via WebSocket and appears in your companion list.
|
|
43
|
+
|
|
44
|
+
## With LangChain
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install port42[langchain]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from port42 import Agent
|
|
52
|
+
from port42.langchain import Port42CallbackHandler
|
|
53
|
+
from langchain_anthropic import ChatAnthropic
|
|
54
|
+
|
|
55
|
+
agent = Agent("assistant", channels=["#general"])
|
|
56
|
+
handler = Port42CallbackHandler(agent)
|
|
57
|
+
|
|
58
|
+
chain = ChatAnthropic(model="claude-sonnet-4-5")
|
|
59
|
+
|
|
60
|
+
@agent.on_mention
|
|
61
|
+
def handle(msg):
|
|
62
|
+
result = chain.invoke(msg.text, config={"callbacks": [handler]})
|
|
63
|
+
return result.content
|
|
64
|
+
|
|
65
|
+
agent.run()
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Scaffold a project
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install port42[cli]
|
|
72
|
+
port42 init my-agent
|
|
73
|
+
cd my-agent && python agent.py
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Templates: `basic`, `langchain`, `pipeline`
|
|
77
|
+
|
|
78
|
+
## Bridge APIs
|
|
79
|
+
|
|
80
|
+
Once connected, your agent can call Port42 device APIs:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
agent.terminal_exec("ls ~/Desktop")
|
|
84
|
+
agent.screen_capture(scale=0.5)
|
|
85
|
+
agent.notify("Done", body="Pipeline complete")
|
|
86
|
+
agent.port_push(port_id, {"key": "value"})
|
|
87
|
+
agent.rest_call("https://api.example.com/data", secret="MY_API_KEY")
|
|
88
|
+
```
|
port42-0.1.0/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# port42
|
|
2
|
+
|
|
3
|
+
Python SDK for [Port42](https://github.com/gordonmattey/port42-native) — connect external agents to your macOS companion.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install port42
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quickstart
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from port42 import Agent
|
|
15
|
+
|
|
16
|
+
agent = Agent("my-agent", channels=["#general"])
|
|
17
|
+
|
|
18
|
+
@agent.on_mention
|
|
19
|
+
def handle(msg):
|
|
20
|
+
return f"Hello {msg.sender}!"
|
|
21
|
+
|
|
22
|
+
agent.run()
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Port42 must be running. The agent connects via WebSocket and appears in your companion list.
|
|
26
|
+
|
|
27
|
+
## With LangChain
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install port42[langchain]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from port42 import Agent
|
|
35
|
+
from port42.langchain import Port42CallbackHandler
|
|
36
|
+
from langchain_anthropic import ChatAnthropic
|
|
37
|
+
|
|
38
|
+
agent = Agent("assistant", channels=["#general"])
|
|
39
|
+
handler = Port42CallbackHandler(agent)
|
|
40
|
+
|
|
41
|
+
chain = ChatAnthropic(model="claude-sonnet-4-5")
|
|
42
|
+
|
|
43
|
+
@agent.on_mention
|
|
44
|
+
def handle(msg):
|
|
45
|
+
result = chain.invoke(msg.text, config={"callbacks": [handler]})
|
|
46
|
+
return result.content
|
|
47
|
+
|
|
48
|
+
agent.run()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Scaffold a project
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install port42[cli]
|
|
55
|
+
port42 init my-agent
|
|
56
|
+
cd my-agent && python agent.py
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Templates: `basic`, `langchain`, `pipeline`
|
|
60
|
+
|
|
61
|
+
## Bridge APIs
|
|
62
|
+
|
|
63
|
+
Once connected, your agent can call Port42 device APIs:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
agent.terminal_exec("ls ~/Desktop")
|
|
67
|
+
agent.screen_capture(scale=0.5)
|
|
68
|
+
agent.notify("Done", body="Pipeline complete")
|
|
69
|
+
agent.port_push(port_id, {"key": "value"})
|
|
70
|
+
agent.rest_call("https://api.example.com/data", secret="MY_API_KEY")
|
|
71
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from port42 import Agent
|
|
2
|
+
from port42.langchain import Port42CallbackHandler
|
|
3
|
+
from langchain_anthropic import ChatAnthropic
|
|
4
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
5
|
+
|
|
6
|
+
agent = Agent("assistant", channels=["#general"])
|
|
7
|
+
handler = Port42CallbackHandler(agent)
|
|
8
|
+
|
|
9
|
+
prompt = ChatPromptTemplate.from_messages([
|
|
10
|
+
("system", "You are a helpful assistant."),
|
|
11
|
+
("human", "{input}"),
|
|
12
|
+
])
|
|
13
|
+
chain = prompt | ChatAnthropic(model="claude-sonnet-4-5")
|
|
14
|
+
|
|
15
|
+
@agent.on_mention
|
|
16
|
+
def handle(msg):
|
|
17
|
+
result = chain.invoke(
|
|
18
|
+
{"input": msg.text},
|
|
19
|
+
config={"callbacks": [handler]}
|
|
20
|
+
)
|
|
21
|
+
return result.content
|
|
22
|
+
|
|
23
|
+
agent.run()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from port42 import Agent
|
|
2
|
+
|
|
3
|
+
agent = Agent("pipeline", channels=["#ops"])
|
|
4
|
+
|
|
5
|
+
@agent.on_mention
|
|
6
|
+
def handle(msg):
|
|
7
|
+
agent.typing()
|
|
8
|
+
|
|
9
|
+
# Run your pipeline
|
|
10
|
+
result = f"Processed: {msg.text}"
|
|
11
|
+
|
|
12
|
+
# Push a port with results
|
|
13
|
+
html = f"""<!DOCTYPE html>
|
|
14
|
+
<html>
|
|
15
|
+
<head><title>Pipeline Result</title><meta name="version" content="1"></head>
|
|
16
|
+
<body style="padding:16px;font-family:monospace">
|
|
17
|
+
<p style="color:#00d4aa">Result</p>
|
|
18
|
+
<pre>{result}</pre>
|
|
19
|
+
</body>
|
|
20
|
+
</html>"""
|
|
21
|
+
agent.port_create(html=html, title="Pipeline Result")
|
|
22
|
+
|
|
23
|
+
return result
|
|
24
|
+
|
|
25
|
+
agent.run()
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from .types import Message, Feedback
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Agent:
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
name: str,
|
|
14
|
+
channels: list[str] | None = None,
|
|
15
|
+
trigger: str = "mention",
|
|
16
|
+
gateway: str = "ws://127.0.0.1:4242",
|
|
17
|
+
tokens: list[str] | None = None,
|
|
18
|
+
):
|
|
19
|
+
self.name = name
|
|
20
|
+
self.channels = channels or []
|
|
21
|
+
self.trigger = trigger
|
|
22
|
+
self.gateway_url = gateway.rstrip("/") + "/ws"
|
|
23
|
+
self.http_url = gateway.rstrip("/").replace("ws://", "http://").replace("wss://", "https://")
|
|
24
|
+
self._channel_tokens: dict[str, str] = dict(zip(channels or [], tokens or []))
|
|
25
|
+
self._channel_ids: list[str] = []
|
|
26
|
+
self._ws = None
|
|
27
|
+
self._handlers: dict[str, list] = {
|
|
28
|
+
"mention": [],
|
|
29
|
+
"message": [],
|
|
30
|
+
"feedback": [],
|
|
31
|
+
}
|
|
32
|
+
self._pending_calls: dict[str, threading.Event] = {}
|
|
33
|
+
self._call_results: dict[str, dict] = {}
|
|
34
|
+
self._recv_thread: threading.Thread | None = None
|
|
35
|
+
self._state_file = f".port42/{name}.json"
|
|
36
|
+
self.sender_id: str | None = None
|
|
37
|
+
self._load_state()
|
|
38
|
+
if not self.sender_id:
|
|
39
|
+
self.sender_id = f"agent-{name}-{uuid.uuid4().hex[:12]}"
|
|
40
|
+
|
|
41
|
+
# --- State persistence ---
|
|
42
|
+
|
|
43
|
+
def _load_state(self):
|
|
44
|
+
try:
|
|
45
|
+
with open(self._state_file) as f:
|
|
46
|
+
state = json.load(f)
|
|
47
|
+
self.sender_id = state.get("sender_id")
|
|
48
|
+
except FileNotFoundError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
def _save_state(self):
|
|
52
|
+
os.makedirs(os.path.dirname(self._state_file), exist_ok=True)
|
|
53
|
+
with open(self._state_file, "w") as f:
|
|
54
|
+
json.dump({"sender_id": self.sender_id}, f)
|
|
55
|
+
|
|
56
|
+
# --- Decorators ---
|
|
57
|
+
|
|
58
|
+
def on_mention(self, fn):
|
|
59
|
+
self._handlers["mention"].append(fn)
|
|
60
|
+
return fn
|
|
61
|
+
|
|
62
|
+
def on_message(self, fn):
|
|
63
|
+
self._handlers["message"].append(fn)
|
|
64
|
+
return fn
|
|
65
|
+
|
|
66
|
+
def on_feedback(self, fn):
|
|
67
|
+
self._handlers["feedback"].append(fn)
|
|
68
|
+
return fn
|
|
69
|
+
|
|
70
|
+
# --- Run ---
|
|
71
|
+
|
|
72
|
+
def run(self):
|
|
73
|
+
"""Block and listen for messages."""
|
|
74
|
+
self._connect()
|
|
75
|
+
self._recv_thread = threading.Thread(target=self._recv_loop, daemon=True)
|
|
76
|
+
self._recv_thread.start()
|
|
77
|
+
try:
|
|
78
|
+
self._recv_thread.join()
|
|
79
|
+
except KeyboardInterrupt:
|
|
80
|
+
self._disconnect()
|
|
81
|
+
|
|
82
|
+
# --- Connection ---
|
|
83
|
+
|
|
84
|
+
def _connect(self):
|
|
85
|
+
from websockets.sync.client import connect as ws_connect
|
|
86
|
+
|
|
87
|
+
print(f"[port42] connecting to {self.gateway_url} as {self.name} ({self.sender_id})")
|
|
88
|
+
self._ws = ws_connect(self.gateway_url)
|
|
89
|
+
|
|
90
|
+
# Auth handshake
|
|
91
|
+
auth_msg = json.loads(self._ws.recv())
|
|
92
|
+
if auth_msg["type"] == "challenge":
|
|
93
|
+
raise ConnectionError("Remote auth not yet supported — use a local gateway or provide a token")
|
|
94
|
+
|
|
95
|
+
# Identify
|
|
96
|
+
self._ws.send(json.dumps({
|
|
97
|
+
"type": "identify",
|
|
98
|
+
"sender_id": self.sender_id,
|
|
99
|
+
"sender_name": self.name,
|
|
100
|
+
}))
|
|
101
|
+
welcome = json.loads(self._ws.recv())
|
|
102
|
+
if welcome.get("type") != "welcome":
|
|
103
|
+
raise ConnectionError(f"Expected welcome, got: {welcome}")
|
|
104
|
+
self._save_state()
|
|
105
|
+
print(f"[port42] connected")
|
|
106
|
+
|
|
107
|
+
# Resolve channel names → IDs
|
|
108
|
+
if self.channels and not self._channel_ids:
|
|
109
|
+
self._channel_ids = self._resolve_channels(self.channels)
|
|
110
|
+
|
|
111
|
+
# Join channels
|
|
112
|
+
for ch_id in self._channel_ids:
|
|
113
|
+
join = {"type": "join", "channel_id": ch_id}
|
|
114
|
+
token = self._channel_tokens.get(ch_id)
|
|
115
|
+
if token:
|
|
116
|
+
join["token"] = token
|
|
117
|
+
self._ws.send(json.dumps(join))
|
|
118
|
+
# Drain until presence (joined) or error
|
|
119
|
+
for _ in range(10):
|
|
120
|
+
resp = json.loads(self._ws.recv())
|
|
121
|
+
if resp["type"] in ("presence", "error"):
|
|
122
|
+
if resp["type"] == "error":
|
|
123
|
+
print(f"[port42] failed to join {ch_id}: {resp.get('error')}")
|
|
124
|
+
break
|
|
125
|
+
|
|
126
|
+
def _resolve_channels(self, names: list[str]) -> list[str]:
|
|
127
|
+
import urllib.request
|
|
128
|
+
url = self.http_url + "/call"
|
|
129
|
+
data = json.dumps({"method": "channel.list"}).encode()
|
|
130
|
+
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
|
131
|
+
with urllib.request.urlopen(req) as resp:
|
|
132
|
+
result = json.loads(resp.read())
|
|
133
|
+
# result is {"content": "[{...}]"} or direct list
|
|
134
|
+
content = result.get("content", result)
|
|
135
|
+
if isinstance(content, str):
|
|
136
|
+
channels = json.loads(content)
|
|
137
|
+
else:
|
|
138
|
+
channels = content
|
|
139
|
+
name_set = {n.lstrip("#").lower() for n in names}
|
|
140
|
+
ids = [ch["id"] for ch in channels if ch.get("name", "").lower() in name_set]
|
|
141
|
+
if not ids:
|
|
142
|
+
print(f"[port42] warning: no channels matched {names}")
|
|
143
|
+
return ids
|
|
144
|
+
|
|
145
|
+
def _disconnect(self):
|
|
146
|
+
if self._ws:
|
|
147
|
+
self._ws.close()
|
|
148
|
+
|
|
149
|
+
# --- Recv loop ---
|
|
150
|
+
|
|
151
|
+
def _recv_loop(self):
|
|
152
|
+
while True:
|
|
153
|
+
try:
|
|
154
|
+
raw = self._ws.recv()
|
|
155
|
+
except Exception as e:
|
|
156
|
+
print(f"[port42] disconnected: {e}")
|
|
157
|
+
break
|
|
158
|
+
env = json.loads(raw)
|
|
159
|
+
call_id = env.get("call_id")
|
|
160
|
+
if call_id and call_id in self._pending_calls:
|
|
161
|
+
self._call_results[call_id] = env
|
|
162
|
+
self._pending_calls[call_id].set()
|
|
163
|
+
else:
|
|
164
|
+
threading.Thread(target=self._dispatch, args=(env,), daemon=True).start()
|
|
165
|
+
|
|
166
|
+
def _dispatch(self, env: dict):
|
|
167
|
+
msg_type = env.get("type")
|
|
168
|
+
|
|
169
|
+
if msg_type == "message":
|
|
170
|
+
# Skip own messages
|
|
171
|
+
if env.get("sender_id") == self.sender_id:
|
|
172
|
+
return
|
|
173
|
+
msg = Message.from_envelope(env)
|
|
174
|
+
is_mention = f"@{self.name.lower()}" in (msg.text or "").lower()
|
|
175
|
+
if is_mention:
|
|
176
|
+
for handler in self._handlers["mention"]:
|
|
177
|
+
try:
|
|
178
|
+
result = handler(msg)
|
|
179
|
+
if result is not None:
|
|
180
|
+
self.send(str(result), channel_id=msg.channel_id)
|
|
181
|
+
except Exception as e:
|
|
182
|
+
print(f"[port42] handler error: {e}")
|
|
183
|
+
else:
|
|
184
|
+
for handler in self._handlers["message"]:
|
|
185
|
+
try:
|
|
186
|
+
result = handler(msg)
|
|
187
|
+
if result is not None:
|
|
188
|
+
self.send(str(result), channel_id=msg.channel_id)
|
|
189
|
+
except Exception as e:
|
|
190
|
+
print(f"[port42] handler error: {e}")
|
|
191
|
+
|
|
192
|
+
elif msg_type == "feedback":
|
|
193
|
+
fb = Feedback.from_envelope(env)
|
|
194
|
+
for handler in self._handlers["feedback"]:
|
|
195
|
+
try:
|
|
196
|
+
handler(fb)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
print(f"[port42] feedback handler error: {e}")
|
|
199
|
+
|
|
200
|
+
# --- Send ---
|
|
201
|
+
|
|
202
|
+
def send(self, text: str, channel_id: str | None = None):
|
|
203
|
+
ch = channel_id or (self._channel_ids[0] if self._channel_ids else None)
|
|
204
|
+
if not ch:
|
|
205
|
+
raise RuntimeError("No channel to send to — specify channel_id or join a channel first")
|
|
206
|
+
env = {
|
|
207
|
+
"type": "message",
|
|
208
|
+
"channel_id": ch,
|
|
209
|
+
"sender_id": self.sender_id,
|
|
210
|
+
"sender_name": self.name,
|
|
211
|
+
"message_id": f"agent-{uuid.uuid4().hex[:16]}",
|
|
212
|
+
"payload": json.dumps({"text": text}),
|
|
213
|
+
}
|
|
214
|
+
self._ws.send(json.dumps(env))
|
|
215
|
+
|
|
216
|
+
def typing(self, channel_id: str | None = None):
|
|
217
|
+
ch = channel_id or (self._channel_ids[0] if self._channel_ids else None)
|
|
218
|
+
if not ch:
|
|
219
|
+
return
|
|
220
|
+
self._ws.send(json.dumps({
|
|
221
|
+
"type": "typing",
|
|
222
|
+
"channel_id": ch,
|
|
223
|
+
"sender_id": self.sender_id,
|
|
224
|
+
}))
|
|
225
|
+
|
|
226
|
+
# --- Bridge API ---
|
|
227
|
+
|
|
228
|
+
def _call(self, method: str, args: dict | None = None) -> dict:
|
|
229
|
+
call_id = f"sdk-{uuid.uuid4().hex[:12]}"
|
|
230
|
+
event = threading.Event()
|
|
231
|
+
self._pending_calls[call_id] = event
|
|
232
|
+
self._ws.send(json.dumps({
|
|
233
|
+
"type": "call",
|
|
234
|
+
"method": method,
|
|
235
|
+
"args": args or {},
|
|
236
|
+
"call_id": call_id,
|
|
237
|
+
"sender_id": self.sender_id,
|
|
238
|
+
}))
|
|
239
|
+
if not event.wait(timeout=30):
|
|
240
|
+
del self._pending_calls[call_id]
|
|
241
|
+
raise TimeoutError(f"{method} timed out")
|
|
242
|
+
result = self._call_results.pop(call_id)
|
|
243
|
+
del self._pending_calls[call_id]
|
|
244
|
+
if result.get("error"):
|
|
245
|
+
raise RuntimeError(result["error"])
|
|
246
|
+
payload = result.get("payload", "{}")
|
|
247
|
+
if isinstance(payload, (bytes, str)):
|
|
248
|
+
try:
|
|
249
|
+
return json.loads(payload)
|
|
250
|
+
except Exception:
|
|
251
|
+
return {"content": payload}
|
|
252
|
+
return payload or {}
|
|
253
|
+
|
|
254
|
+
def port_push(self, port_id: str, data: dict):
|
|
255
|
+
return self._call("port_push", {"id": port_id, "data": data})
|
|
256
|
+
|
|
257
|
+
def port_exec(self, port_id: str, js: str):
|
|
258
|
+
return self._call("port_exec", {"id": port_id, "js": js})
|
|
259
|
+
|
|
260
|
+
def port_patch(self, port_id: str, search: str, replace: str):
|
|
261
|
+
return self._call("port_patch", {"id": port_id, "search": search, "replace": replace})
|
|
262
|
+
|
|
263
|
+
def port_create(self, html: str, title: str | None = None):
|
|
264
|
+
return self._call("messages.send", {"text": f"```port\n{html}\n```"})
|
|
265
|
+
|
|
266
|
+
def ports_list(self):
|
|
267
|
+
return self._call("ports_list")
|
|
268
|
+
|
|
269
|
+
def rest_call(self, url: str, method: str = "GET", secret: str | None = None, **kwargs):
|
|
270
|
+
args: dict = {"url": url, "method": method}
|
|
271
|
+
if secret:
|
|
272
|
+
args["secret"] = secret
|
|
273
|
+
args.update(kwargs)
|
|
274
|
+
return self._call("rest.call", args)
|
|
275
|
+
|
|
276
|
+
def clipboard_read(self):
|
|
277
|
+
return self._call("clipboard.read")
|
|
278
|
+
|
|
279
|
+
def clipboard_write(self, data: str):
|
|
280
|
+
return self._call("clipboard.write", {"data": data})
|
|
281
|
+
|
|
282
|
+
def notify(self, title: str, body: str | None = None):
|
|
283
|
+
args: dict = {"title": title}
|
|
284
|
+
if body:
|
|
285
|
+
args["body"] = body
|
|
286
|
+
return self._call("notify.send", args)
|
|
287
|
+
|
|
288
|
+
def screen_capture(self, scale: float = 0.5):
|
|
289
|
+
return self._call("screen_capture", {"scale": scale})
|
|
290
|
+
|
|
291
|
+
def terminal_exec(self, command: str):
|
|
292
|
+
return self._call("terminal.exec", {"command": command})
|
|
293
|
+
|
|
294
|
+
def files_read(self, path: str):
|
|
295
|
+
return self._call("files.read", {"path": path})
|
|
296
|
+
|
|
297
|
+
def files_write(self, path: str, data: str):
|
|
298
|
+
return self._call("files.write", {"path": path, "data": data})
|
|
299
|
+
|
|
300
|
+
def storage_get(self, key: str, scope: str = "global"):
|
|
301
|
+
return self._call("storage.get", {"key": key, "scope": scope})
|
|
302
|
+
|
|
303
|
+
def storage_set(self, key: str, value, scope: str = "global"):
|
|
304
|
+
return self._call("storage.set", {"key": key, "value": value, "scope": scope})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
try:
|
|
2
|
+
import click
|
|
3
|
+
except ImportError:
|
|
4
|
+
raise ImportError("click is required: pip install port42[cli]")
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
BASIC_TEMPLATE = '''from port42 import Agent
|
|
10
|
+
|
|
11
|
+
agent = Agent("{name}", channels=["#general"])
|
|
12
|
+
|
|
13
|
+
@agent.on_mention
|
|
14
|
+
def handle(msg):
|
|
15
|
+
return f"Hello {{msg.sender}}! You said: {{msg.text}}"
|
|
16
|
+
|
|
17
|
+
agent.run()
|
|
18
|
+
'''
|
|
19
|
+
|
|
20
|
+
LANGCHAIN_TEMPLATE = '''from port42 import Agent
|
|
21
|
+
from port42.langchain import Port42CallbackHandler
|
|
22
|
+
from langchain_anthropic import ChatAnthropic
|
|
23
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
24
|
+
|
|
25
|
+
agent = Agent("{name}", channels=["#general"])
|
|
26
|
+
handler = Port42CallbackHandler(agent)
|
|
27
|
+
|
|
28
|
+
prompt = ChatPromptTemplate.from_messages([
|
|
29
|
+
("system", "You are a helpful assistant."),
|
|
30
|
+
("human", "{{input}}"),
|
|
31
|
+
])
|
|
32
|
+
chain = prompt | ChatAnthropic(model="claude-sonnet-4-5")
|
|
33
|
+
|
|
34
|
+
@agent.on_mention
|
|
35
|
+
def handle(msg):
|
|
36
|
+
result = chain.invoke(
|
|
37
|
+
{{"input": msg.text}},
|
|
38
|
+
config={{"callbacks": [handler]}}
|
|
39
|
+
)
|
|
40
|
+
return result.content
|
|
41
|
+
|
|
42
|
+
agent.run()
|
|
43
|
+
'''
|
|
44
|
+
|
|
45
|
+
PIPELINE_TEMPLATE = '''from port42 import Agent
|
|
46
|
+
|
|
47
|
+
agent = Agent("{name}", channels=["#ops"])
|
|
48
|
+
|
|
49
|
+
@agent.on_mention
|
|
50
|
+
def handle(msg):
|
|
51
|
+
agent.typing()
|
|
52
|
+
|
|
53
|
+
# TODO: run your pipeline here
|
|
54
|
+
result = f"processed: {{msg.text}}"
|
|
55
|
+
|
|
56
|
+
# Optionally create a port to display results
|
|
57
|
+
# agent.port_create(html="<html>...</html>", title="Result")
|
|
58
|
+
|
|
59
|
+
return result
|
|
60
|
+
|
|
61
|
+
agent.run()
|
|
62
|
+
'''
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@click.group()
|
|
66
|
+
def main():
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@main.command()
|
|
71
|
+
@click.argument("name")
|
|
72
|
+
@click.option("--template", default="basic", help="Template: basic, langchain, pipeline")
|
|
73
|
+
def init(name: str, template: str):
|
|
74
|
+
"""Create a new Port42 agent project."""
|
|
75
|
+
os.makedirs(name, exist_ok=True)
|
|
76
|
+
|
|
77
|
+
templates = {
|
|
78
|
+
"basic": BASIC_TEMPLATE,
|
|
79
|
+
"langchain": LANGCHAIN_TEMPLATE,
|
|
80
|
+
"pipeline": PIPELINE_TEMPLATE,
|
|
81
|
+
}
|
|
82
|
+
if template not in templates:
|
|
83
|
+
raise click.BadParameter(f"Unknown template '{template}'. Choose: basic, langchain, pipeline")
|
|
84
|
+
|
|
85
|
+
agent_py = os.path.join(name, "agent.py")
|
|
86
|
+
with open(agent_py, "w") as f:
|
|
87
|
+
f.write(templates[template].format(name=name))
|
|
88
|
+
|
|
89
|
+
click.echo(f"Created {name}/agent.py")
|
|
90
|
+
click.echo(f"Run with: cd {name} && python agent.py")
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from .callback import Port42CallbackHandler
|
|
2
|
+
from .tools import port42_tools, RestCallTool, ScreenCaptureTool, ClipboardTool, TerminalTool
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"Port42CallbackHandler",
|
|
6
|
+
"port42_tools",
|
|
7
|
+
"RestCallTool",
|
|
8
|
+
"ScreenCaptureTool",
|
|
9
|
+
"ClipboardTool",
|
|
10
|
+
"TerminalTool",
|
|
11
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
5
|
+
except ImportError:
|
|
6
|
+
raise ImportError("langchain-core is required: pip install port42[langchain]")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Port42CallbackHandler(BaseCallbackHandler):
|
|
10
|
+
"""Streams LangChain execution progress into Port42 channels."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, agent, channel_id: str | None = None):
|
|
13
|
+
self.agent = agent
|
|
14
|
+
self.channel_id = channel_id
|
|
15
|
+
|
|
16
|
+
def on_chain_start(self, serialized, inputs, **kwargs):
|
|
17
|
+
self.agent.typing(self.channel_id)
|
|
18
|
+
|
|
19
|
+
def on_chain_end(self, outputs, **kwargs):
|
|
20
|
+
if hasattr(outputs, "model_dump"):
|
|
21
|
+
# Pydantic model — render as port
|
|
22
|
+
self._render_as_port(outputs)
|
|
23
|
+
elif isinstance(outputs, str):
|
|
24
|
+
self.agent.send(outputs, channel_id=self.channel_id)
|
|
25
|
+
|
|
26
|
+
def on_chain_error(self, error, **kwargs):
|
|
27
|
+
self.agent.send(f"Chain error: {error}", channel_id=self.channel_id)
|
|
28
|
+
|
|
29
|
+
def on_tool_start(self, serialized, input_str, **kwargs):
|
|
30
|
+
self.agent.typing(self.channel_id)
|
|
31
|
+
|
|
32
|
+
def on_tool_end(self, output, **kwargs):
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
def _render_as_port(self, model):
|
|
36
|
+
fields = model.model_dump()
|
|
37
|
+
rows = "".join(
|
|
38
|
+
f"<tr><td>{k}</td><td>{v}</td></tr>"
|
|
39
|
+
for k, v in fields.items()
|
|
40
|
+
)
|
|
41
|
+
html = f"""<!DOCTYPE html>
|
|
42
|
+
<html>
|
|
43
|
+
<head><title>{type(model).__name__}</title><meta name="version" content="1"></head>
|
|
44
|
+
<body>
|
|
45
|
+
<table style="width:100%;border-collapse:collapse;font-family:monospace;font-size:12px">
|
|
46
|
+
{rows}
|
|
47
|
+
</table>
|
|
48
|
+
</body>
|
|
49
|
+
</html>"""
|
|
50
|
+
self.agent.port_create(html=html, title=type(model).__name__)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
try:
|
|
4
|
+
from langchain_core.tools import BaseTool
|
|
5
|
+
except ImportError:
|
|
6
|
+
raise ImportError("langchain-core is required: pip install port42[langchain]")
|
|
7
|
+
|
|
8
|
+
from pydantic import Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Port42Tool(BaseTool):
|
|
12
|
+
agent: object = Field(exclude=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class RestCallTool(Port42Tool):
|
|
16
|
+
name: str = "rest_call"
|
|
17
|
+
description: str = "Make an HTTP request using Port42's secret store"
|
|
18
|
+
|
|
19
|
+
def _run(self, url: str, method: str = "GET", secret: str | None = None, **kwargs):
|
|
20
|
+
return self.agent.rest_call(url, method=method, secret=secret, **kwargs)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ScreenCaptureTool(Port42Tool):
|
|
24
|
+
name: str = "screen_capture"
|
|
25
|
+
description: str = "Take a screenshot of the user's screen"
|
|
26
|
+
|
|
27
|
+
def _run(self, scale: float = 0.5):
|
|
28
|
+
return self.agent.screen_capture(scale=scale)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ClipboardTool(Port42Tool):
|
|
32
|
+
name: str = "clipboard"
|
|
33
|
+
description: str = "Read or write the user's clipboard"
|
|
34
|
+
|
|
35
|
+
def _run(self, action: str = "read", data: str | None = None):
|
|
36
|
+
if action == "write" and data:
|
|
37
|
+
return self.agent.clipboard_write(data)
|
|
38
|
+
return self.agent.clipboard_read()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TerminalTool(Port42Tool):
|
|
42
|
+
name: str = "terminal_exec"
|
|
43
|
+
description: str = "Run a shell command on the user's machine"
|
|
44
|
+
|
|
45
|
+
def _run(self, command: str):
|
|
46
|
+
return self.agent.terminal_exec(command)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def port42_tools(agent) -> list[BaseTool]:
|
|
50
|
+
return [
|
|
51
|
+
RestCallTool(agent=agent),
|
|
52
|
+
ScreenCaptureTool(agent=agent),
|
|
53
|
+
ClipboardTool(agent=agent),
|
|
54
|
+
TerminalTool(agent=agent),
|
|
55
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Message:
|
|
7
|
+
text: str
|
|
8
|
+
sender: str
|
|
9
|
+
sender_id: str
|
|
10
|
+
channel_id: str
|
|
11
|
+
message_id: str
|
|
12
|
+
timestamp: int
|
|
13
|
+
history: list = field(default_factory=list)
|
|
14
|
+
|
|
15
|
+
@classmethod
|
|
16
|
+
def from_envelope(cls, env: dict) -> "Message":
|
|
17
|
+
payload = json.loads(env.get("payload", "{}") or "{}")
|
|
18
|
+
if isinstance(payload, bytes):
|
|
19
|
+
payload = json.loads(payload)
|
|
20
|
+
return cls(
|
|
21
|
+
text=payload.get("text", ""),
|
|
22
|
+
sender=env.get("sender_name", ""),
|
|
23
|
+
sender_id=env.get("sender_id", ""),
|
|
24
|
+
channel_id=env.get("channel_id", ""),
|
|
25
|
+
message_id=env.get("message_id", ""),
|
|
26
|
+
timestamp=env.get("timestamp", 0),
|
|
27
|
+
history=payload.get("history", []),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Feedback:
|
|
33
|
+
message_id: str
|
|
34
|
+
type: str
|
|
35
|
+
content: str
|
|
36
|
+
sender: str
|
|
37
|
+
sender_id: str
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_envelope(cls, env: dict) -> "Feedback":
|
|
41
|
+
payload = json.loads(env.get("payload", "{}") or "{}")
|
|
42
|
+
if isinstance(payload, bytes):
|
|
43
|
+
payload = json.loads(payload)
|
|
44
|
+
return cls(
|
|
45
|
+
message_id=env.get("message_id", ""),
|
|
46
|
+
type=payload.get("feedback_type", ""),
|
|
47
|
+
content=payload.get("content", ""),
|
|
48
|
+
sender=env.get("sender_name", ""),
|
|
49
|
+
sender_id=env.get("sender_id", ""),
|
|
50
|
+
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "port42"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Python SDK for Port42 — connect external agents to your macOS companion"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
dependencies = [
|
|
13
|
+
"websockets>=12.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
langchain = [
|
|
18
|
+
"langchain-core>=0.3",
|
|
19
|
+
]
|
|
20
|
+
cli = [
|
|
21
|
+
"click>=8.0",
|
|
22
|
+
]
|
|
23
|
+
all = [
|
|
24
|
+
"port42[langchain,cli]",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
port42 = "port42.cli:main"
|
|
29
|
+
|
|
30
|
+
[project.urls]
|
|
31
|
+
Homepage = "https://github.com/gordonmattey/port42-python"
|