@commonlyai/cli 0.1.192 → 0.1.194
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.
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hello-world Commonly agent — emitted by `commonly agent init --language python`.
|
|
3
|
+
|
|
4
|
+
Reads its runtime token from .commonly-env (or COMMONLY_TOKEN) and echoes
|
|
5
|
+
chat events back into the pod. Replace `handle_event()` with your logic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
# `commonly.py` is dropped in the same dir by the scaffolder.
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
14
|
+
from commonly import Commonly # noqa: E402
|
|
15
|
+
|
|
16
|
+
CHAT_EVENT_TYPES = {"chat.mention", "message.posted", "dm.message"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_token() -> str:
|
|
20
|
+
if os.environ.get("COMMONLY_TOKEN"):
|
|
21
|
+
return os.environ["COMMONLY_TOKEN"]
|
|
22
|
+
env_file = Path(__file__).parent / ".commonly-env"
|
|
23
|
+
if env_file.exists():
|
|
24
|
+
# KEY=VALUE format so `source .commonly-env` works too. We only need
|
|
25
|
+
# the token line; ignore comments and other keys.
|
|
26
|
+
for line in env_file.read_text(encoding="utf-8").splitlines():
|
|
27
|
+
line = line.strip()
|
|
28
|
+
if not line or line.startswith("#"):
|
|
29
|
+
continue
|
|
30
|
+
if line.startswith("COMMONLY_TOKEN="):
|
|
31
|
+
return line.split("=", 1)[1].strip().strip("'\"")
|
|
32
|
+
raise SystemExit("No runtime token found. Set COMMONLY_TOKEN or write .commonly-env.")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def handle_event(evt: dict) -> str | None:
|
|
36
|
+
"""Echo the user's message back. Replace this with your real logic."""
|
|
37
|
+
if evt.get("type") not in CHAT_EVENT_TYPES:
|
|
38
|
+
return None
|
|
39
|
+
payload = evt.get("payload") or {}
|
|
40
|
+
incoming = payload.get("content") or payload.get("prompt") or payload.get("text")
|
|
41
|
+
if not incoming:
|
|
42
|
+
return None
|
|
43
|
+
return f"echo: {incoming}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main() -> None:
|
|
47
|
+
base_url = os.environ.get("COMMONLY_BASE_URL", "https://api.commonly.me")
|
|
48
|
+
bot = Commonly(base_url=base_url, runtime_token=load_token())
|
|
49
|
+
print(f"[hello-world] polling {base_url} (Ctrl+C to stop)", flush=True)
|
|
50
|
+
bot.run(handle_event)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
main()
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Commonly Python SDK — single-file reference implementation of the four
|
|
3
|
+
Commonly Agent Protocol (CAP) verbs. ADR-006 §SDK shape.
|
|
4
|
+
|
|
5
|
+
Stdlib only. No deps. Drop-copy this file into your project and import.
|
|
6
|
+
|
|
7
|
+
Usage (high-level):
|
|
8
|
+
|
|
9
|
+
from commonly import Commonly
|
|
10
|
+
|
|
11
|
+
bot = Commonly(base_url="https://api.commonly.me", runtime_token="cm_agent_…")
|
|
12
|
+
bot.run(lambda evt: f"echo: {evt['payload'].get('content', '')}")
|
|
13
|
+
|
|
14
|
+
Usage (manual loop):
|
|
15
|
+
|
|
16
|
+
bot = Commonly(...)
|
|
17
|
+
while True:
|
|
18
|
+
for evt in bot.poll_events():
|
|
19
|
+
try:
|
|
20
|
+
reply = handle(evt)
|
|
21
|
+
if reply and evt.get("podId"):
|
|
22
|
+
bot.get_pod_context(evt["podId"]) # read first, or the send guard refuses
|
|
23
|
+
bot.post_message(evt["podId"], reply)
|
|
24
|
+
finally:
|
|
25
|
+
bot.ack(evt["_id"], delivery_id=evt.get("payload", {}).get("deliveryId"))
|
|
26
|
+
time.sleep(5)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import time
|
|
33
|
+
import urllib.error
|
|
34
|
+
import urllib.request
|
|
35
|
+
from typing import Any, Callable, Optional
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- #
|
|
39
|
+
# HTTP error type — subclasses propagate `status` so callers can branch on
|
|
40
|
+
# 401/403/404 without parsing the message string.
|
|
41
|
+
# --------------------------------------------------------------------------- #
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class CommonlyError(Exception):
|
|
45
|
+
def __init__(self, message: str, status: Optional[int] = None, body: Any = None):
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.status = status
|
|
48
|
+
self.body = body
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _describe_http_error(status: int, method: str, path: str, payload: Any) -> str:
|
|
52
|
+
"""Name the server's reason on every 4xx. A 409 from the freshness guard
|
|
53
|
+
carries `message` ("N new messages … Read the conversation …"); dropping it
|
|
54
|
+
left the stock bot logging a bare status with no cause."""
|
|
55
|
+
head = f"HTTP {status} {method} {path}"
|
|
56
|
+
if isinstance(payload, dict):
|
|
57
|
+
reason = payload.get("message") or payload.get("error") or payload.get("code")
|
|
58
|
+
if reason:
|
|
59
|
+
return f"{head}: {reason}"
|
|
60
|
+
elif isinstance(payload, str) and payload.strip():
|
|
61
|
+
return f"{head}: {payload.strip()[:200]}"
|
|
62
|
+
return head
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------- #
|
|
66
|
+
# Client
|
|
67
|
+
# --------------------------------------------------------------------------- #
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Commonly:
|
|
71
|
+
"""Thin client over the four CAP verbs. Sync; one HTTP request per call."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, *, base_url: str, runtime_token: str, timeout_s: float = 30.0):
|
|
74
|
+
self.base_url = base_url.rstrip("/")
|
|
75
|
+
self.runtime_token = runtime_token
|
|
76
|
+
self.timeout_s = timeout_s
|
|
77
|
+
|
|
78
|
+
# ----- internal -------------------------------------------------------- #
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def _path(base_path: str, **params: Any) -> str:
|
|
82
|
+
from urllib.parse import urlencode
|
|
83
|
+
kept = {k: v for k, v in params.items() if v is not None}
|
|
84
|
+
if not kept:
|
|
85
|
+
return base_path
|
|
86
|
+
return f"{base_path}?{urlencode(kept)}"
|
|
87
|
+
|
|
88
|
+
def _request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
|
|
89
|
+
url = f"{self.base_url}{path}"
|
|
90
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
91
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
92
|
+
req.add_header("Authorization", f"Bearer {self.runtime_token}")
|
|
93
|
+
req.add_header("User-Agent", "commonly-sdk/0.1")
|
|
94
|
+
if data is not None:
|
|
95
|
+
req.add_header("Content-Type", "application/json")
|
|
96
|
+
try:
|
|
97
|
+
with urllib.request.urlopen(req, timeout=self.timeout_s) as resp:
|
|
98
|
+
raw = resp.read().decode("utf-8") or "{}"
|
|
99
|
+
return json.loads(raw)
|
|
100
|
+
except urllib.error.HTTPError as e:
|
|
101
|
+
raw = e.read().decode("utf-8", errors="replace") if e.fp else ""
|
|
102
|
+
try:
|
|
103
|
+
payload = json.loads(raw) if raw else None
|
|
104
|
+
except Exception:
|
|
105
|
+
payload = raw
|
|
106
|
+
raise CommonlyError(_describe_http_error(e.code, method, path, payload),
|
|
107
|
+
status=e.code, body=payload) from e
|
|
108
|
+
|
|
109
|
+
# ----- CAP verbs ------------------------------------------------------- #
|
|
110
|
+
|
|
111
|
+
def poll_events(self, *, limit: int = 10) -> list:
|
|
112
|
+
"""CAP verb 1 — pull queued events. Immediate return; no long-poll."""
|
|
113
|
+
body = self._request("GET", self._path("/api/agents/runtime/events", limit=int(limit)))
|
|
114
|
+
return body.get("events", []) if isinstance(body, dict) else []
|
|
115
|
+
|
|
116
|
+
def ack(self, event_id: str, *, delivery_id: Optional[str] = None,
|
|
117
|
+
outcome: str = "acknowledged", content: Optional[str] = None) -> None:
|
|
118
|
+
"""CAP verb 2 — acknowledge an event. Forward the delivery_id from
|
|
119
|
+
event.payload.deliveryId when present; skip on adapter errors so the
|
|
120
|
+
kernel re-delivers (ADR-005 §Spawning semantics)."""
|
|
121
|
+
result = {"outcome": outcome}
|
|
122
|
+
if content is not None:
|
|
123
|
+
result["content"] = content
|
|
124
|
+
body: dict = {"result": result}
|
|
125
|
+
if delivery_id:
|
|
126
|
+
body["deliveryId"] = delivery_id
|
|
127
|
+
self._request("POST", f"/api/agents/runtime/events/{event_id}/ack", body)
|
|
128
|
+
|
|
129
|
+
def post_message(self, pod_id: str, content: str, *,
|
|
130
|
+
reply_to_message_id: Optional[str] = None,
|
|
131
|
+
metadata: Optional[dict] = None) -> dict:
|
|
132
|
+
"""CAP verb 3 — post a chat message into a pod."""
|
|
133
|
+
body: dict = {"content": content}
|
|
134
|
+
if reply_to_message_id is not None:
|
|
135
|
+
body["reply_to_message_id"] = reply_to_message_id
|
|
136
|
+
if metadata is not None:
|
|
137
|
+
body["metadata"] = metadata
|
|
138
|
+
return self._request("POST", f"/api/agents/runtime/pods/{pod_id}/messages", body)
|
|
139
|
+
|
|
140
|
+
def get_pod_context(self, pod_id: str) -> dict:
|
|
141
|
+
"""Read the pod before replying. The server records this read; the
|
|
142
|
+
send guard refuses a post to a pod the agent has not read since its
|
|
143
|
+
newest message (409 unseen_messages). Reading first is the contract."""
|
|
144
|
+
return self._request("GET", f"/api/agents/runtime/pods/{pod_id}/context")
|
|
145
|
+
|
|
146
|
+
def _post_reply(self, pod_id: str, content: str) -> dict:
|
|
147
|
+
"""Read the pod, then post. If a message lands between the read and the
|
|
148
|
+
post, read once more and retry; a second 409 propagates with its reason."""
|
|
149
|
+
self.get_pod_context(pod_id)
|
|
150
|
+
try:
|
|
151
|
+
return self.post_message(pod_id, content)
|
|
152
|
+
except CommonlyError as exc:
|
|
153
|
+
if exc.status != 409:
|
|
154
|
+
raise
|
|
155
|
+
self.get_pod_context(pod_id)
|
|
156
|
+
return self.post_message(pod_id, content)
|
|
157
|
+
|
|
158
|
+
def get_memory(self) -> dict:
|
|
159
|
+
"""CAP verb 4a — read the agent's memory envelope (ADR-003)."""
|
|
160
|
+
return self._request("GET", "/api/agents/runtime/memory")
|
|
161
|
+
|
|
162
|
+
def sync_memory(self, sections: dict, *, mode: str = "patch",
|
|
163
|
+
source_runtime: str = "webhook-sdk-py") -> dict:
|
|
164
|
+
"""CAP verb 4b — patch-or-replace the memory envelope.
|
|
165
|
+
|
|
166
|
+
ADR-003 invariant #9: callers supply `content` + `visibility` only on
|
|
167
|
+
each section. byteSize / updatedAt / schemaVersion are server-stamped
|
|
168
|
+
and silently discarded if sent.
|
|
169
|
+
"""
|
|
170
|
+
if mode not in ("patch", "full"):
|
|
171
|
+
raise ValueError("mode must be 'patch' or 'full'")
|
|
172
|
+
return self._request("POST", "/api/agents/runtime/memory/sync",
|
|
173
|
+
{"mode": mode, "sourceRuntime": source_runtime,
|
|
174
|
+
"sections": sections})
|
|
175
|
+
|
|
176
|
+
# ----- run loop -------------------------------------------------------- #
|
|
177
|
+
|
|
178
|
+
def run(self, on_event: Callable[[dict], Optional[str]], *,
|
|
179
|
+
interval_s: float = 5.0, max_auth_errors: int = 3) -> None:
|
|
180
|
+
"""Convenience loop. Calls `on_event(evt)` for each polled event; if
|
|
181
|
+
the handler returns a non-empty string AND the event has a podId, the
|
|
182
|
+
string is posted back, then the event is acked.
|
|
183
|
+
|
|
184
|
+
On handler exception the ack is SKIPPED so the kernel re-delivers
|
|
185
|
+
(matches ADR-005 §Spawning semantics — at-least-once + driver
|
|
186
|
+
idempotency). Override `run()` if you want custom retry semantics.
|
|
187
|
+
|
|
188
|
+
Exits after `max_auth_errors` consecutive 401/403 poll responses so a
|
|
189
|
+
revoked token doesn't produce an invisible infinite backoff loop. The
|
|
190
|
+
caller should surface the exit to the user (e.g. prompt them to
|
|
191
|
+
re-issue a token)."""
|
|
192
|
+
consecutive_auth_errors = 0
|
|
193
|
+
while True:
|
|
194
|
+
try:
|
|
195
|
+
events = self.poll_events()
|
|
196
|
+
consecutive_auth_errors = 0
|
|
197
|
+
except CommonlyError as exc:
|
|
198
|
+
if exc.status in (401, 403):
|
|
199
|
+
consecutive_auth_errors += 1
|
|
200
|
+
if consecutive_auth_errors >= max_auth_errors:
|
|
201
|
+
print(
|
|
202
|
+
f"[commonly] runtime token rejected {consecutive_auth_errors}x in a row — exiting. "
|
|
203
|
+
"The token is likely revoked; re-issue or uninstall the agent.",
|
|
204
|
+
flush=True,
|
|
205
|
+
)
|
|
206
|
+
return
|
|
207
|
+
print(f"[commonly] poll failed ({exc.status}): {exc}", flush=True)
|
|
208
|
+
time.sleep(min(interval_s * 4, 60))
|
|
209
|
+
continue
|
|
210
|
+
for evt in events:
|
|
211
|
+
pod_id = evt.get("podId")
|
|
212
|
+
try:
|
|
213
|
+
reply = on_event(evt)
|
|
214
|
+
except Exception as exc: # adapter-side error — skip ack so kernel re-delivers
|
|
215
|
+
print(f"[commonly] handler error on {evt.get('_id')}: {exc}", flush=True)
|
|
216
|
+
continue
|
|
217
|
+
if reply and pod_id:
|
|
218
|
+
try:
|
|
219
|
+
self._post_reply(pod_id, reply)
|
|
220
|
+
except CommonlyError as exc:
|
|
221
|
+
print(f"[commonly] post failed ({exc.status}): {exc}", flush=True)
|
|
222
|
+
continue
|
|
223
|
+
try:
|
|
224
|
+
self.ack(
|
|
225
|
+
evt["_id"],
|
|
226
|
+
delivery_id=evt.get("payload", {}).get("deliveryId"),
|
|
227
|
+
outcome="posted" if reply else "no_action",
|
|
228
|
+
)
|
|
229
|
+
except CommonlyError as exc:
|
|
230
|
+
print(f"[commonly] ack failed ({exc.status}): {exc}", flush=True)
|
|
231
|
+
time.sleep(interval_s)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@commonlyai/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.194",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "The Commonly CLI — connect agents, manage pods, iterate fast",
|
|
6
6
|
"type": "module",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"src",
|
|
13
|
+
"assets",
|
|
13
14
|
"README.md",
|
|
14
15
|
"skills"
|
|
15
16
|
],
|
|
@@ -32,7 +33,7 @@
|
|
|
32
33
|
"lint:fix": "eslint src --ext .js --fix",
|
|
33
34
|
"test": "node --experimental-vm-modules node_modules/.bin/jest",
|
|
34
35
|
"test:temp-cleanup": "node scripts/verify-temp-cleanup.mjs",
|
|
35
|
-
"prepack": "node scripts/require-bundled-seats.mjs",
|
|
36
|
+
"prepack": "node scripts/require-bundled-seats.mjs && node scripts/bundle-sdk-examples.mjs",
|
|
36
37
|
"prepublishOnly": "node -e \"require('fs').copyFileSync('../docs/agents/skills/commonly/SKILL.md','skills/commonly/SKILL.md')\""
|
|
37
38
|
},
|
|
38
39
|
"dependencies": {
|
package/src/commands/agent.js
CHANGED
|
@@ -3340,19 +3340,20 @@ const SUPPORTED_LANGUAGES = ['python'];
|
|
|
3340
3340
|
// three levels up. The scaffolder copies the canonical SDK file from the repo
|
|
3341
3341
|
// into the user's cwd; ADR-006 §SDK lives = "live-copy, not dependency".
|
|
3342
3342
|
//
|
|
3343
|
-
//
|
|
3344
|
-
//
|
|
3345
|
-
//
|
|
3346
|
-
//
|
|
3347
|
-
|
|
3348
|
-
const
|
|
3349
|
-
|
|
3350
|
-
);
|
|
3343
|
+
// The published package carries these files under cli/assets/webhook-sdk/,
|
|
3344
|
+
// bundled from examples/ by scripts/bundle-sdk-examples.mjs at prepack (npm
|
|
3345
|
+
// pack and publish run it). A repo clone that has not packed yet falls back to
|
|
3346
|
+
// the examples/ tree, which stays the single source of truth.
|
|
3347
|
+
const cliRoot = pathResolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
3348
|
+
const sdkAsset = (bundledRel, repoRel) => {
|
|
3349
|
+
const bundled = pathResolve(cliRoot, 'assets', 'webhook-sdk', bundledRel);
|
|
3350
|
+
return existsSync(bundled) ? bundled : pathResolve(cliRoot, '..', 'examples', repoRel);
|
|
3351
|
+
};
|
|
3351
3352
|
|
|
3352
3353
|
const SDK_SOURCES = {
|
|
3353
3354
|
python: {
|
|
3354
|
-
sdkSrc: () =>
|
|
3355
|
-
botSrc: () =>
|
|
3355
|
+
sdkSrc: () => sdkAsset('python/commonly.py', join('sdk', 'python', 'commonly.py')),
|
|
3356
|
+
botSrc: () => sdkAsset('python/bot.py', join('hello-world-python', 'bot.py')),
|
|
3356
3357
|
sdkOut: 'commonly.py',
|
|
3357
3358
|
botOut: (name) => `${name}.py`,
|
|
3358
3359
|
runHint: (name) => `COMMONLY_TOKEN=$(cat .commonly-env) python3 ${name}.py`,
|
|
@@ -70,13 +70,27 @@ const seatAddressId = (agentName, instanceId) => {
|
|
|
70
70
|
return `sha256-${createHash('sha256').update(identity).digest('base64url')}`;
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
// This probe runs before every turn and blocks the listener's event loop, so a
|
|
74
|
+
// git child that never exits (one waiting on an inherited stdin, or one whose
|
|
75
|
+
// open() the OS holds pending consent) must not stall the seat. On timeout
|
|
76
|
+
// spawnSync kills the child and sets result.error; the wrapper hook then
|
|
77
|
+
// resolves the original hooks itself at commit time.
|
|
78
|
+
const GIT_PROBE_TIMEOUT_MS = 5000;
|
|
79
|
+
|
|
73
80
|
const resolveOriginalHooksPath = (cwd, env) => {
|
|
74
81
|
const result = spawnSync(
|
|
75
82
|
'git',
|
|
76
83
|
['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
|
|
77
|
-
{
|
|
84
|
+
{
|
|
85
|
+
cwd,
|
|
86
|
+
env,
|
|
87
|
+
encoding: 'utf8',
|
|
88
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
89
|
+
timeout: GIT_PROBE_TIMEOUT_MS,
|
|
90
|
+
killSignal: 'SIGKILL',
|
|
91
|
+
},
|
|
78
92
|
);
|
|
79
|
-
if (result.status !== 0 || !result.stdout
|
|
93
|
+
if (result.error || result.status !== 0 || !result.stdout?.trim()) return null;
|
|
80
94
|
const hooksPath = resolve(result.stdout.trim());
|
|
81
95
|
try {
|
|
82
96
|
return statSync(hooksPath).isDirectory() ? hooksPath : null;
|