personalclaw-client 0.1.0__py3-none-any.whl
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.
- personalclaw_client/__init__.py +15 -0
- personalclaw_client/client.py +483 -0
- personalclaw_client/errors.py +64 -0
- personalclaw_client-0.1.0.dist-info/METADATA +70 -0
- personalclaw_client-0.1.0.dist-info/RECORD +7 -0
- personalclaw_client-0.1.0.dist-info/WHEEL +5 -0
- personalclaw_client-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""personalclaw-client — async Python client for the PersonalClaw Gateway.
|
|
2
|
+
|
|
3
|
+
Usage::
|
|
4
|
+
|
|
5
|
+
from personalclaw_client import PersonalClawClient
|
|
6
|
+
|
|
7
|
+
async with PersonalClawClient(app_name="my-app") as mc:
|
|
8
|
+
ok = await mc.ping()
|
|
9
|
+
status = await mc.get_status()
|
|
10
|
+
await mc.send_message("session-1", "hello")
|
|
11
|
+
"""
|
|
12
|
+
from personalclaw_client.client import PersonalClawClient
|
|
13
|
+
from personalclaw_client.errors import PersonalClawError, ErrorCode
|
|
14
|
+
|
|
15
|
+
__all__ = ["PersonalClawClient", "PersonalClawError", "ErrorCode"]
|
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"""PersonalClawClient — async Python client for the PersonalClaw Gateway.
|
|
2
|
+
|
|
3
|
+
Standalone package (no dependency on backend main package).
|
|
4
|
+
Uses aiohttp for HTTP communication.
|
|
5
|
+
|
|
6
|
+
Mirrors the TypeScript @personalclaw/client API surface.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import math
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Awaitable
|
|
19
|
+
|
|
20
|
+
import aiohttp
|
|
21
|
+
|
|
22
|
+
from personalclaw_client.errors import PersonalClawError, ErrorCode, http_error
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
# Single source of truth for this client's default port. Mirrors the gateway's
|
|
27
|
+
# config.loader._DEFAULT_PORT (this package ships standalone and can't import it).
|
|
28
|
+
_DEFAULT_PORT = 10000
|
|
29
|
+
_DEFAULT_BASE_URL = f"http://localhost:{_DEFAULT_PORT}"
|
|
30
|
+
_DEFAULT_TIMEOUT = 30
|
|
31
|
+
_DEFAULT_MAX_RETRIES = 3
|
|
32
|
+
_DEFAULT_RETRY_BASE_DELAY = 1.0 # seconds
|
|
33
|
+
_MAX_BACKOFF = 30.0
|
|
34
|
+
_DEFAULT_MESSAGE_LIMIT = 40_000
|
|
35
|
+
_CONTEXT_BUFFER_LIMIT = 50
|
|
36
|
+
|
|
37
|
+
_LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _is_loopback(url: str) -> bool:
|
|
41
|
+
try:
|
|
42
|
+
from urllib.parse import urlparse
|
|
43
|
+
return urlparse(url).hostname in _LOOPBACK_HOSTS
|
|
44
|
+
except Exception:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _compute_backoff(attempt: int, base_delay: float) -> float:
|
|
49
|
+
return min(base_delay * (2 ** attempt), _MAX_BACKOFF)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _read_app_secret(app_name: str) -> str:
|
|
53
|
+
"""Read the per-app secret from disk. Returns empty string if unavailable."""
|
|
54
|
+
home = os.environ.get("PERSONALCLAW_HOME", str(Path.home() / ".personalclaw"))
|
|
55
|
+
secret_path = Path(home) / "apps" / app_name / ".app_secret"
|
|
56
|
+
try:
|
|
57
|
+
return secret_path.read_text(encoding="utf-8").strip()
|
|
58
|
+
except (OSError, FileNotFoundError):
|
|
59
|
+
return ""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _exchange_app_token(base_url: str, app_name: str, secret: str) -> str:
|
|
63
|
+
"""Exchange an app secret for an app-scoped token via the Gateway."""
|
|
64
|
+
async with aiohttp.ClientSession() as session:
|
|
65
|
+
async with session.post(
|
|
66
|
+
f"{base_url}/api/apps/{app_name}/token",
|
|
67
|
+
headers={"X-App-Secret": secret, "Content-Type": "application/json"},
|
|
68
|
+
) as resp:
|
|
69
|
+
if not resp.ok:
|
|
70
|
+
raise PersonalClawError(
|
|
71
|
+
ErrorCode.AUTH_EXPIRED,
|
|
72
|
+
f"App token exchange failed: HTTP {resp.status}",
|
|
73
|
+
status=resp.status,
|
|
74
|
+
)
|
|
75
|
+
data = await resp.json()
|
|
76
|
+
return str(data.get("token", ""))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class ContextEntry:
|
|
81
|
+
content: str
|
|
82
|
+
source: str | None = None
|
|
83
|
+
ephemeral: bool = True
|
|
84
|
+
max_age: float | None = None # seconds
|
|
85
|
+
injected_at: float = field(default_factory=time.time)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class PersonalClawClient:
|
|
89
|
+
"""Async HTTP client for the PersonalClaw Gateway.
|
|
90
|
+
|
|
91
|
+
Usage::
|
|
92
|
+
|
|
93
|
+
async with PersonalClawClient(app_name="my-app") as mc:
|
|
94
|
+
ok = await mc.ping()
|
|
95
|
+
slots = await mc.list_sessions()
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
*,
|
|
101
|
+
base_url: str = "",
|
|
102
|
+
token: str = "",
|
|
103
|
+
app_name: str = "",
|
|
104
|
+
timeout: int = _DEFAULT_TIMEOUT,
|
|
105
|
+
max_retries: int = _DEFAULT_MAX_RETRIES,
|
|
106
|
+
retry_base_delay: float = _DEFAULT_RETRY_BASE_DELAY,
|
|
107
|
+
message_length_limit: int = _DEFAULT_MESSAGE_LIMIT,
|
|
108
|
+
on_auth_expired: Callable[[], Awaitable[str]] | None = None,
|
|
109
|
+
):
|
|
110
|
+
port = os.environ.get("PERSONALCLAW_PORT", str(_DEFAULT_PORT))
|
|
111
|
+
self.base_url = (base_url or f"http://localhost:{port}").rstrip("/")
|
|
112
|
+
self.token = token
|
|
113
|
+
self.app_name = app_name
|
|
114
|
+
self.timeout = timeout
|
|
115
|
+
self.max_retries = max_retries
|
|
116
|
+
self.retry_base_delay = retry_base_delay
|
|
117
|
+
self.message_length_limit = message_length_limit
|
|
118
|
+
self._session: aiohttp.ClientSession | None = None
|
|
119
|
+
self._pending_buffer: list[ContextEntry] = []
|
|
120
|
+
self._default_session: str | None = None
|
|
121
|
+
|
|
122
|
+
# Auto-token: if app_name is set and no explicit auth, read secret
|
|
123
|
+
self._app_secret = ""
|
|
124
|
+
has_explicit_auth = bool(token or on_auth_expired)
|
|
125
|
+
if app_name and not has_explicit_auth:
|
|
126
|
+
self._app_secret = _read_app_secret(app_name)
|
|
127
|
+
|
|
128
|
+
if self._app_secret and not on_auth_expired:
|
|
129
|
+
self._on_auth_expired = self._auto_refresh_token
|
|
130
|
+
else:
|
|
131
|
+
self._on_auth_expired = on_auth_expired
|
|
132
|
+
|
|
133
|
+
async def _auto_refresh_token(self) -> str:
|
|
134
|
+
"""Exchange the on-disk app secret for a fresh token."""
|
|
135
|
+
return await _exchange_app_token(self.base_url, self.app_name, self._app_secret)
|
|
136
|
+
|
|
137
|
+
async def authenticate(self) -> bool:
|
|
138
|
+
"""Bootstrap app auth by exchanging the on-disk secret for a token.
|
|
139
|
+
|
|
140
|
+
Call once after construction if no explicit token was provided.
|
|
141
|
+
No-op if the client already has a token or no app secret is available.
|
|
142
|
+
"""
|
|
143
|
+
if self.token or not self._app_secret or not self.app_name:
|
|
144
|
+
return True
|
|
145
|
+
try:
|
|
146
|
+
self.token = await _exchange_app_token(
|
|
147
|
+
self.base_url, self.app_name, self._app_secret
|
|
148
|
+
)
|
|
149
|
+
return True
|
|
150
|
+
except Exception:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
async def __aenter__(self) -> PersonalClawClient:
|
|
154
|
+
self._session = aiohttp.ClientSession(
|
|
155
|
+
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
|
156
|
+
)
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
160
|
+
await self.close()
|
|
161
|
+
|
|
162
|
+
async def close(self) -> None:
|
|
163
|
+
if self._session:
|
|
164
|
+
await self._session.close()
|
|
165
|
+
self._session = None
|
|
166
|
+
|
|
167
|
+
def _ensure_session(self) -> aiohttp.ClientSession:
|
|
168
|
+
if not self._session:
|
|
169
|
+
self._session = aiohttp.ClientSession(
|
|
170
|
+
timeout=aiohttp.ClientTimeout(total=self.timeout),
|
|
171
|
+
)
|
|
172
|
+
return self._session
|
|
173
|
+
|
|
174
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
175
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
176
|
+
if self.token:
|
|
177
|
+
headers["Cookie"] = f"pc_token={self.token}"
|
|
178
|
+
return headers
|
|
179
|
+
|
|
180
|
+
def _check_auth(self) -> None:
|
|
181
|
+
if not self.token and not _is_loopback(self.base_url):
|
|
182
|
+
raise PersonalClawError(
|
|
183
|
+
ErrorCode.AUTH_REQUIRED,
|
|
184
|
+
"Auth token required for remote Gateway connections",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def _request(
|
|
188
|
+
self,
|
|
189
|
+
method: str,
|
|
190
|
+
path: str,
|
|
191
|
+
body: Any = None,
|
|
192
|
+
) -> Any:
|
|
193
|
+
"""Core request with retry logic."""
|
|
194
|
+
self._check_auth()
|
|
195
|
+
session = self._ensure_session()
|
|
196
|
+
url = f"{self.base_url}{path}"
|
|
197
|
+
last_error: PersonalClawError | None = None
|
|
198
|
+
|
|
199
|
+
for attempt in range(self.max_retries + 1):
|
|
200
|
+
try:
|
|
201
|
+
kwargs: dict[str, Any] = {"headers": self._auth_headers()}
|
|
202
|
+
if body is not None:
|
|
203
|
+
kwargs["json"] = body
|
|
204
|
+
|
|
205
|
+
async with session.request(method, url, **kwargs) as resp:
|
|
206
|
+
# Auth expired — try refresh once
|
|
207
|
+
if resp.status in (401, 403) and self._on_auth_expired and attempt == 0:
|
|
208
|
+
try:
|
|
209
|
+
self.token = await self._on_auth_expired()
|
|
210
|
+
continue
|
|
211
|
+
except Exception:
|
|
212
|
+
pass
|
|
213
|
+
|
|
214
|
+
if resp.ok:
|
|
215
|
+
ct = resp.headers.get("content-type", "")
|
|
216
|
+
if "application/json" in ct:
|
|
217
|
+
return await resp.json()
|
|
218
|
+
return {}
|
|
219
|
+
|
|
220
|
+
# Non-retryable 4xx (except 429)
|
|
221
|
+
if 400 <= resp.status < 500 and resp.status != 429:
|
|
222
|
+
text = await resp.text()
|
|
223
|
+
raise http_error(resp.status, text or None)
|
|
224
|
+
|
|
225
|
+
# Retryable
|
|
226
|
+
text = await resp.text()
|
|
227
|
+
last_error = http_error(resp.status, text or None)
|
|
228
|
+
|
|
229
|
+
if attempt < self.max_retries:
|
|
230
|
+
if resp.status == 429:
|
|
231
|
+
retry_after = resp.headers.get("Retry-After")
|
|
232
|
+
try:
|
|
233
|
+
delay = float(retry_after) if retry_after else _compute_backoff(attempt, self.retry_base_delay)
|
|
234
|
+
except (ValueError, TypeError):
|
|
235
|
+
delay = _compute_backoff(attempt, self.retry_base_delay)
|
|
236
|
+
else:
|
|
237
|
+
delay = _compute_backoff(attempt, self.retry_base_delay)
|
|
238
|
+
await asyncio.sleep(delay)
|
|
239
|
+
|
|
240
|
+
except PersonalClawError as e:
|
|
241
|
+
# Non-retryable errors (4xx except 429) were raised directly
|
|
242
|
+
# above — re-raise them immediately. Retryable errors (5xx,
|
|
243
|
+
# 429) are stored in last_error and fall through to the
|
|
244
|
+
# backoff sleep, so they never reach this branch.
|
|
245
|
+
raise
|
|
246
|
+
except Exception as exc:
|
|
247
|
+
last_error = PersonalClawError(
|
|
248
|
+
ErrorCode.NETWORK_ERROR,
|
|
249
|
+
str(exc),
|
|
250
|
+
)
|
|
251
|
+
if attempt < self.max_retries:
|
|
252
|
+
await asyncio.sleep(_compute_backoff(attempt, self.retry_base_delay))
|
|
253
|
+
|
|
254
|
+
raise last_error or PersonalClawError(ErrorCode.NETWORK_ERROR, "Request failed")
|
|
255
|
+
|
|
256
|
+
async def _get(self, path: str) -> Any:
|
|
257
|
+
return await self._request("GET", path)
|
|
258
|
+
|
|
259
|
+
async def _post(self, path: str, body: Any = None) -> Any:
|
|
260
|
+
return await self._request("POST", path, body)
|
|
261
|
+
|
|
262
|
+
async def _put(self, path: str, body: Any = None) -> Any:
|
|
263
|
+
return await self._request("PUT", path, body)
|
|
264
|
+
|
|
265
|
+
async def _delete(self, path: str) -> Any:
|
|
266
|
+
return await self._request("DELETE", path)
|
|
267
|
+
|
|
268
|
+
# ── Connection ──
|
|
269
|
+
|
|
270
|
+
async def ping(self) -> bool:
|
|
271
|
+
try:
|
|
272
|
+
await self._get("/api/status")
|
|
273
|
+
return True
|
|
274
|
+
except Exception:
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
async def get_status(self) -> dict[str, Any]:
|
|
278
|
+
return await self._get("/api/status")
|
|
279
|
+
|
|
280
|
+
async def get_system_info(self) -> dict[str, Any]:
|
|
281
|
+
return await self._get("/api/system")
|
|
282
|
+
|
|
283
|
+
# ── Sessions ──
|
|
284
|
+
|
|
285
|
+
async def create_session(self, name: str, agent: str = "") -> dict[str, Any]:
|
|
286
|
+
body: dict[str, str] = {"name": name}
|
|
287
|
+
if agent:
|
|
288
|
+
body["agent"] = agent
|
|
289
|
+
return await self._post("/api/chat/sessions", body)
|
|
290
|
+
|
|
291
|
+
async def list_sessions(self) -> list[dict[str, Any]]:
|
|
292
|
+
result = await self._get("/api/chat/sessions")
|
|
293
|
+
return result if isinstance(result, list) else []
|
|
294
|
+
|
|
295
|
+
async def delete_session(self, session_id: str) -> None:
|
|
296
|
+
await self._delete(f"/api/chat/sessions/{session_id}")
|
|
297
|
+
|
|
298
|
+
async def send_message(self, session_id: str, message: str) -> None:
|
|
299
|
+
if len(message) > self.message_length_limit:
|
|
300
|
+
raise PersonalClawError(
|
|
301
|
+
ErrorCode.VALIDATION_ERROR,
|
|
302
|
+
f"Message length {len(message)} exceeds limit {self.message_length_limit}",
|
|
303
|
+
)
|
|
304
|
+
if self._default_session and session_id == self._default_session and self._pending_buffer:
|
|
305
|
+
await self.flush_pending_context(session_id)
|
|
306
|
+
await self._post("/api/chat", {"message": message, "session": session_id})
|
|
307
|
+
|
|
308
|
+
# ── Subagents ──
|
|
309
|
+
|
|
310
|
+
async def spawn(self, task: str, agent: str = "") -> str:
|
|
311
|
+
body: dict[str, str] = {"task": task}
|
|
312
|
+
if agent:
|
|
313
|
+
body["agent"] = agent
|
|
314
|
+
result = await self._post("/api/spawn", body)
|
|
315
|
+
return str(result.get("id", ""))
|
|
316
|
+
|
|
317
|
+
async def spawn_many(self, tasks: list[str], agents: list[str] | None = None) -> list[str]:
|
|
318
|
+
|
|
319
|
+
coros = [
|
|
320
|
+
self.spawn(task, agents[i] if agents and i < len(agents) else "")
|
|
321
|
+
for i, task in enumerate(tasks)
|
|
322
|
+
]
|
|
323
|
+
return list(await asyncio.gather(*coros))
|
|
324
|
+
|
|
325
|
+
async def list_subagents(self) -> list[dict[str, Any]]:
|
|
326
|
+
result = await self._get("/api/spawn")
|
|
327
|
+
return result if isinstance(result, list) else []
|
|
328
|
+
|
|
329
|
+
async def get_subagent_status(self, agent_id: str) -> dict[str, Any]:
|
|
330
|
+
return await self._get(f"/api/spawn/{agent_id}")
|
|
331
|
+
|
|
332
|
+
# ── Cron ──
|
|
333
|
+
|
|
334
|
+
async def add_cron(self, name: str, **options: Any) -> dict[str, Any]:
|
|
335
|
+
return await self._post("/api/crons", {"name": name, **options})
|
|
336
|
+
|
|
337
|
+
async def list_crons(self) -> list[dict[str, Any]]:
|
|
338
|
+
result = await self._get("/api/crons")
|
|
339
|
+
return result if isinstance(result, list) else []
|
|
340
|
+
|
|
341
|
+
async def update_cron(self, job_id: str, **options: Any) -> dict[str, Any]:
|
|
342
|
+
return await self._put(f"/api/crons/{job_id}", options)
|
|
343
|
+
|
|
344
|
+
async def remove_cron(self, job_id: str) -> None:
|
|
345
|
+
await self._delete(f"/api/crons/{job_id}")
|
|
346
|
+
|
|
347
|
+
async def pause_cron(self, job_id: str) -> None:
|
|
348
|
+
await self._post(f"/api/crons/{job_id}/enable", {"enabled": False})
|
|
349
|
+
|
|
350
|
+
async def resume_cron(self, job_id: str) -> None:
|
|
351
|
+
await self._post(f"/api/crons/{job_id}/enable", {"enabled": True})
|
|
352
|
+
|
|
353
|
+
# ── Lessons ──
|
|
354
|
+
|
|
355
|
+
async def add_lesson(self, rule: str, category: str, scope: str = "") -> None:
|
|
356
|
+
await self._post("/api/lessons", {"rule": rule, "category": category, "scope": scope})
|
|
357
|
+
|
|
358
|
+
async def list_lessons(self) -> list[dict[str, Any]]:
|
|
359
|
+
result = await self._get("/api/lessons")
|
|
360
|
+
return result if isinstance(result, list) else []
|
|
361
|
+
|
|
362
|
+
async def remove_lesson(self, query: str) -> None:
|
|
363
|
+
await self._delete_with_body("/api/lessons", {"rule": query})
|
|
364
|
+
|
|
365
|
+
async def _delete_with_body(self, path: str, body: Any) -> Any:
|
|
366
|
+
return await self._request("DELETE", path, body)
|
|
367
|
+
|
|
368
|
+
# ── Messages ──
|
|
369
|
+
|
|
370
|
+
async def send_notification(self, text: str, **options: Any) -> None:
|
|
371
|
+
if len(text) > self.message_length_limit:
|
|
372
|
+
raise PersonalClawError(
|
|
373
|
+
ErrorCode.VALIDATION_ERROR,
|
|
374
|
+
f"Message length {len(text)} exceeds limit {self.message_length_limit}",
|
|
375
|
+
)
|
|
376
|
+
await self._post("/api/send-message", {"text": text, **options})
|
|
377
|
+
|
|
378
|
+
# ── MCP Servers ──
|
|
379
|
+
|
|
380
|
+
async def list_mcp_servers(self) -> list[dict[str, Any]]:
|
|
381
|
+
result = await self._get("/api/mcp/servers")
|
|
382
|
+
return result if isinstance(result, list) else []
|
|
383
|
+
|
|
384
|
+
async def register_mcp_server(
|
|
385
|
+
self, name: str, command: str, args: list[str] | None = None, env: dict[str, str] | None = None,
|
|
386
|
+
) -> None:
|
|
387
|
+
if not name or not command:
|
|
388
|
+
raise PersonalClawError(ErrorCode.VALIDATION_ERROR, "MCP server requires name and command")
|
|
389
|
+
body: dict[str, Any] = {"command": command}
|
|
390
|
+
if args:
|
|
391
|
+
body["args"] = args
|
|
392
|
+
if env:
|
|
393
|
+
body["env"] = env
|
|
394
|
+
await self._put(f"/api/mcp/servers/{name}", body)
|
|
395
|
+
|
|
396
|
+
async def remove_mcp_server(self, name: str) -> None:
|
|
397
|
+
await self._delete(f"/api/mcp/servers/{name}")
|
|
398
|
+
|
|
399
|
+
# ── Agent Runtime ──
|
|
400
|
+
|
|
401
|
+
async def dispatch_agent(self, agent: str, prompt: str) -> dict[str, Any]:
|
|
402
|
+
return await self._post("/api/chat", {"message": prompt, "agent": agent, "app": self.app_name})
|
|
403
|
+
|
|
404
|
+
async def dispatch_agent_async(self, agent: str, prompt: str) -> str:
|
|
405
|
+
result = await self._post("/api/spawn", {"task": prompt, "agent": agent})
|
|
406
|
+
return str(result.get("id", ""))
|
|
407
|
+
|
|
408
|
+
async def get_task_result(self, task_id: str) -> dict[str, Any]:
|
|
409
|
+
return await self._get(f"/api/spawn/{task_id}")
|
|
410
|
+
|
|
411
|
+
# ── App Storage ──
|
|
412
|
+
|
|
413
|
+
def get_app_data_dir(self) -> Path:
|
|
414
|
+
home = os.environ.get("PERSONALCLAW_HOME", str(Path.home() / ".personalclaw"))
|
|
415
|
+
return Path(home) / "apps" / (self.app_name or "unknown") / "data"
|
|
416
|
+
|
|
417
|
+
async def get_app_config(self) -> dict[str, Any]:
|
|
418
|
+
return await self._get(f"/api/apps/{self.app_name}/config")
|
|
419
|
+
|
|
420
|
+
async def set_app_config(self, config: dict[str, Any]) -> None:
|
|
421
|
+
await self._put(f"/api/apps/{self.app_name}/config", config)
|
|
422
|
+
|
|
423
|
+
# ── Memory ──
|
|
424
|
+
|
|
425
|
+
async def memory_search(self, query: str, top_k: int = 8) -> list[dict[str, Any]]:
|
|
426
|
+
from urllib.parse import quote
|
|
427
|
+
result = await self._get(f"/api/memory/episodic/search?q={quote(query)}&top_k={top_k}")
|
|
428
|
+
return result if isinstance(result, list) else []
|
|
429
|
+
|
|
430
|
+
# ── Context Injection ──
|
|
431
|
+
|
|
432
|
+
async def inject_context(
|
|
433
|
+
self,
|
|
434
|
+
session_id: str | None,
|
|
435
|
+
content: str,
|
|
436
|
+
*,
|
|
437
|
+
source: str | None = None,
|
|
438
|
+
ephemeral: bool = True,
|
|
439
|
+
max_age: float | None = None,
|
|
440
|
+
) -> None:
|
|
441
|
+
entry = ContextEntry(
|
|
442
|
+
content=content, source=source, ephemeral=ephemeral,
|
|
443
|
+
max_age=max_age, injected_at=time.time(),
|
|
444
|
+
)
|
|
445
|
+
if session_id is None:
|
|
446
|
+
self._pending_buffer.append(entry)
|
|
447
|
+
if len(self._pending_buffer) > _CONTEXT_BUFFER_LIMIT:
|
|
448
|
+
self._pending_buffer.pop(0)
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
await self._post(f"/api/chat/sessions/{session_id}/context", {
|
|
452
|
+
"content": content, "source": source,
|
|
453
|
+
"ephemeral": ephemeral, "maxAge": max_age,
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
async def flush_pending_context(self, session_id: str) -> None:
|
|
457
|
+
now = time.time()
|
|
458
|
+
to_flush = [
|
|
459
|
+
e for e in self._pending_buffer
|
|
460
|
+
if e.max_age is None or e.injected_at + e.max_age >= now
|
|
461
|
+
]
|
|
462
|
+
self._pending_buffer.clear()
|
|
463
|
+
failed: list[Any] = []
|
|
464
|
+
last_exc: Exception | None = None
|
|
465
|
+
for entry in to_flush:
|
|
466
|
+
try:
|
|
467
|
+
await self._post(f"/api/chat/sessions/{session_id}/context", {
|
|
468
|
+
"content": entry.content, "source": entry.source,
|
|
469
|
+
"ephemeral": entry.ephemeral, "maxAge": entry.max_age,
|
|
470
|
+
})
|
|
471
|
+
except Exception as exc:
|
|
472
|
+
last_exc = exc
|
|
473
|
+
failed.append(entry)
|
|
474
|
+
self._pending_buffer.extend(failed)
|
|
475
|
+
if last_exc:
|
|
476
|
+
raise last_exc
|
|
477
|
+
|
|
478
|
+
def set_default_session(self, session_id: str) -> None:
|
|
479
|
+
self._default_session = session_id
|
|
480
|
+
|
|
481
|
+
@property
|
|
482
|
+
def pending_context_count(self) -> int:
|
|
483
|
+
return len(self._pending_buffer)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Structured error types for personalclaw-client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ErrorCode(str, Enum):
|
|
9
|
+
AUTH_REQUIRED = "AUTH_REQUIRED"
|
|
10
|
+
AUTH_EXPIRED = "AUTH_EXPIRED"
|
|
11
|
+
VALIDATION_ERROR = "VALIDATION_ERROR"
|
|
12
|
+
NOT_FOUND = "NOT_FOUND"
|
|
13
|
+
RATE_LIMITED = "RATE_LIMITED"
|
|
14
|
+
SERVER_ERROR = "SERVER_ERROR"
|
|
15
|
+
NETWORK_ERROR = "NETWORK_ERROR"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PersonalClawError(Exception):
|
|
19
|
+
"""Structured error with code, message, status, and optional body."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
code: ErrorCode | str,
|
|
24
|
+
message: str,
|
|
25
|
+
status: int | None = None,
|
|
26
|
+
body: Any = None,
|
|
27
|
+
):
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.code = ErrorCode(code) if isinstance(code, str) else code
|
|
30
|
+
self.status = status
|
|
31
|
+
self.body = body
|
|
32
|
+
|
|
33
|
+
def to_dict(self) -> dict[str, Any]:
|
|
34
|
+
d: dict[str, Any] = {"code": self.code.value, "message": str(self)}
|
|
35
|
+
if self.status is not None:
|
|
36
|
+
d["status"] = self.status
|
|
37
|
+
if self.body is not None:
|
|
38
|
+
d["body"] = self.body
|
|
39
|
+
return d
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def http_status_to_code(status: int) -> ErrorCode:
|
|
43
|
+
if status == 401:
|
|
44
|
+
return ErrorCode.AUTH_EXPIRED
|
|
45
|
+
if status == 403:
|
|
46
|
+
return ErrorCode.AUTH_EXPIRED
|
|
47
|
+
if status == 404:
|
|
48
|
+
return ErrorCode.NOT_FOUND
|
|
49
|
+
if status == 429:
|
|
50
|
+
return ErrorCode.RATE_LIMITED
|
|
51
|
+
if status >= 500:
|
|
52
|
+
return ErrorCode.SERVER_ERROR
|
|
53
|
+
return ErrorCode.VALIDATION_ERROR
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def http_error(status: int, body: Any = None) -> PersonalClawError:
|
|
57
|
+
code = http_status_to_code(status)
|
|
58
|
+
if isinstance(body, str):
|
|
59
|
+
message = body
|
|
60
|
+
elif isinstance(body, dict) and "error" in body:
|
|
61
|
+
message = str(body["error"])
|
|
62
|
+
else:
|
|
63
|
+
message = f"HTTP {status}"
|
|
64
|
+
return PersonalClawError(code, message, status=status, body=body)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: personalclaw-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: PersonalClaw Client — async Python client for the PersonalClaw Gateway
|
|
5
|
+
Author: PersonalClaw contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://personalclaw.dev
|
|
8
|
+
Project-URL: Documentation, https://github.com/PersonalClaw/PersonalClaw/tree/main/docs
|
|
9
|
+
Project-URL: Source, https://github.com/PersonalClaw/PersonalClaw
|
|
10
|
+
Project-URL: Issues, https://github.com/PersonalClaw/PersonalClaw/issues
|
|
11
|
+
Keywords: personalclaw,client,ai,agent,gateway
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Framework :: AsyncIO
|
|
22
|
+
Requires-Python: >=3.9
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
Requires-Dist: aiohttp>=3.9
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
28
|
+
Requires-Dist: hypothesis>=6.0; extra == "dev"
|
|
29
|
+
Requires-Dist: aiohttp; extra == "dev"
|
|
30
|
+
|
|
31
|
+
# personalclaw-client
|
|
32
|
+
|
|
33
|
+
An async Python client for the [PersonalClaw](https://personalclaw.dev) Gateway.
|
|
34
|
+
|
|
35
|
+
`personalclaw-client` is the small, dependency-light library that apps and
|
|
36
|
+
external tools use to talk to a running PersonalClaw gateway — send messages,
|
|
37
|
+
read status, resolve an app's data directory — without vendoring the core
|
|
38
|
+
package. It is versioned and published independently of the core gateway so a
|
|
39
|
+
tool can pin a stable client contract.
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
pip install personalclaw-client
|
|
45
|
+
# or
|
|
46
|
+
uv pip install personalclaw-client
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from personalclaw_client import PersonalClawClient
|
|
53
|
+
|
|
54
|
+
async with PersonalClawClient(app_name="my-app") as pc:
|
|
55
|
+
ok = await pc.ping()
|
|
56
|
+
status = await pc.get_status()
|
|
57
|
+
await pc.send_message("session-1", "hello")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Errors
|
|
61
|
+
|
|
62
|
+
Client calls raise `PersonalClawError` (with a machine-readable `ErrorCode`) on
|
|
63
|
+
gateway/transport failures — catch it to distinguish "gateway unreachable" from
|
|
64
|
+
"bad request".
|
|
65
|
+
|
|
66
|
+
## Links
|
|
67
|
+
|
|
68
|
+
- Homepage: https://personalclaw.dev
|
|
69
|
+
- Source & issues: https://github.com/PersonalClaw/PersonalClaw
|
|
70
|
+
- License: MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
personalclaw_client/__init__.py,sha256=YZuuECjU743-Vgqtjttg6EapHgxOuF7CZcFPaH4v4jc,521
|
|
2
|
+
personalclaw_client/client.py,sha256=tNOzL99Kvr8WUcM3LMInB3FsCd03Ixt5MiE4Zl_PRhU,18102
|
|
3
|
+
personalclaw_client/errors.py,sha256=n3sYuJf_Ij6WVGk0NL1tIiCdJxOd7t4WrEDaNkz3YuI,1866
|
|
4
|
+
personalclaw_client-0.1.0.dist-info/METADATA,sha256=RqOkZvkbygMW-PFaD81WFtNF4RBuTk3NnYRQCd0k18I,2400
|
|
5
|
+
personalclaw_client-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
6
|
+
personalclaw_client-0.1.0.dist-info/top_level.txt,sha256=6zwyHfZrTNNkORLxk3uz-cg-VPN-iDGgRt2F8TvXXK4,20
|
|
7
|
+
personalclaw_client-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
personalclaw_client
|