alter-runtime 0.3.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.
Files changed (92) hide show
  1. alter_runtime/__init__.py +11 -0
  2. alter_runtime/adapters/__init__.py +19 -0
  3. alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
  4. alter_runtime/adapters/git_watcher.py +457 -0
  5. alter_runtime/adapters/household/__init__.py +29 -0
  6. alter_runtime/adapters/household/_base.py +138 -0
  7. alter_runtime/adapters/household/compost/__init__.py +17 -0
  8. alter_runtime/adapters/household/compost/adapter.py +81 -0
  9. alter_runtime/adapters/household/compost/storage.py +75 -0
  10. alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
  11. alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
  12. alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
  13. alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
  14. alter_runtime/adapters/household/compost/traits.py +79 -0
  15. alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
  16. alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
  17. alter_runtime/adapters/household/self_hoster/storage.py +83 -0
  18. alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
  19. alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
  20. alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
  21. alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
  22. alter_runtime/adapters/household/self_hoster/traits.py +105 -0
  23. alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
  24. alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
  25. alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
  26. alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
  27. alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
  28. alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
  29. alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
  30. alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
  31. alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
  32. alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
  33. alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
  34. alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
  35. alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
  36. alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
  37. alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
  38. alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
  39. alter_runtime/adapters/worktree_watcher.py +378 -0
  40. alter_runtime/atlas/__init__.py +48 -0
  41. alter_runtime/atlas/base.py +102 -0
  42. alter_runtime/atlas/ledger.py +196 -0
  43. alter_runtime/atlas/observations.py +136 -0
  44. alter_runtime/atlas/schema.py +106 -0
  45. alter_runtime/cap_cache.py +392 -0
  46. alter_runtime/cli.py +517 -0
  47. alter_runtime/clients/__init__.py +0 -0
  48. alter_runtime/clients/token_usage_client.py +273 -0
  49. alter_runtime/config.py +648 -0
  50. alter_runtime/consent.py +425 -0
  51. alter_runtime/daemon.py +518 -0
  52. alter_runtime/floor_loop.py +335 -0
  53. alter_runtime/floor_preflight.py +734 -0
  54. alter_runtime/http_auth.py +173 -0
  55. alter_runtime/notifiers/__init__.py +18 -0
  56. alter_runtime/notifiers/desktop.py +321 -0
  57. alter_runtime/sdk/__init__.py +12 -0
  58. alter_runtime/sdk/client.py +399 -0
  59. alter_runtime/service_install.py +616 -0
  60. alter_runtime/services/__init__.py +59 -0
  61. alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
  62. alter_runtime/services/systemd/alter-runtime.service.in +74 -0
  63. alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
  64. alter_runtime/sockets/__init__.py +20 -0
  65. alter_runtime/sockets/dbus.py +272 -0
  66. alter_runtime/sockets/unix.py +702 -0
  67. alter_runtime/subscribers/__init__.py +58 -0
  68. alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
  69. alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
  70. alter_runtime/subscribers/active_sessions_gc.py +432 -0
  71. alter_runtime/subscribers/active_sessions_writer.py +446 -0
  72. alter_runtime/subscribers/adapters_writer.py +415 -0
  73. alter_runtime/subscribers/agent_frames.py +461 -0
  74. alter_runtime/subscribers/bus.py +188 -0
  75. alter_runtime/subscribers/cache_writer.py +347 -0
  76. alter_runtime/subscribers/ceremony_echo.py +290 -0
  77. alter_runtime/subscribers/do_sse.py +864 -0
  78. alter_runtime/subscribers/ebpf.py +506 -0
  79. alter_runtime/subscribers/inbox_writer.py +469 -0
  80. alter_runtime/subscribers/mcp_fallback.py +391 -0
  81. alter_runtime/subscribers/presence_writer.py +426 -0
  82. alter_runtime/subscribers/session_presence.py +467 -0
  83. alter_runtime/subscribers/sse.py +125 -0
  84. alter_runtime/subscribers/weave_intent_writer.py +608 -0
  85. alter_runtime/update_loop.py +519 -0
  86. alter_runtime/weave/__init__.py +21 -0
  87. alter_runtime/weave/resolver.py +544 -0
  88. alter_runtime-0.3.0.dist-info/METADATA +289 -0
  89. alter_runtime-0.3.0.dist-info/RECORD +92 -0
  90. alter_runtime-0.3.0.dist-info/WHEEL +4 -0
  91. alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
  92. alter_runtime-0.3.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,399 @@
1
+ """AlterClient - async JSON-RPC 2.0 client for ALTER's identity MCP server.
2
+
3
+ Ported from ``backend/openclaw-skill/alter_bot/mcp_client.py`` with two changes:
4
+
5
+ 1. **Class renamed** from ``AlterMCPClient`` to ``AlterClient`` to match the
6
+ public SDK surface described in the plan and D-RT5. The original class
7
+ remains in the backend for internal tooling.
8
+
9
+ 2. **Dual-source discovery** - ``AlterClient.auto_discover()`` first tries
10
+ to reach the local daemon's Unix socket (once Wave 2 ships it) and falls
11
+ back to direct MCP polling at ``api.truealter.com/api/v1/mcp`` per D-RT9.
12
+
13
+ Everything else - JSON-RPC 2.0 envelope, retry/backoff, session management
14
+ via the ``Mcp-Session-Id`` header, typed tool methods - is preserved verbatim
15
+ from the backend source.
16
+
17
+ Decisions:
18
+ - D-RT4 - Python asyncio core; this SDK IS that core
19
+ - D-RT5 - PyPI name ``alter-runtime`` (this package)
20
+ - D-RT9 - graceful fallback to direct MCP when L1 DO unreachable
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import logging
27
+ from dataclasses import dataclass
28
+ from itertools import count
29
+ from typing import Any
30
+
31
+ import httpx
32
+
33
+ __all__ = ["AlterClient", "MCPResponse"]
34
+
35
+ logger = logging.getLogger("alter_runtime.sdk")
36
+
37
+ _request_counter = count(1)
38
+
39
+ # Retry configuration (verbatim from backend mcp_client.py)
40
+ MAX_RETRIES = 2
41
+ RETRY_BACKOFF_BASE = 1.0 # seconds
42
+ RETRYABLE_HTTP_CODES = frozenset({429, 502, 503, 504})
43
+
44
+ # Default endpoint - matches scripts/alter-identity.sh MCP_ENDPOINT
45
+ DEFAULT_MCP_ENDPOINT = "https://api.truealter.com/api/v1/mcp"
46
+
47
+ # MCP protocol version - matches backend mcp_client.py initialize() payload
48
+ MCP_PROTOCOL_VERSION = "2025-03-26"
49
+
50
+
51
+ # ---------------------------------------------------------------------------
52
+ # MCPResponse - shape preserved from backend alter_bot.types
53
+ # ---------------------------------------------------------------------------
54
+
55
+
56
+ @dataclass
57
+ class MCPResponse:
58
+ """Typed wrapper for a JSON-RPC 2.0 response."""
59
+
60
+ result: Any | None = None
61
+ error: dict[str, Any] | None = None
62
+ request_id: int | str | None = None
63
+
64
+ @property
65
+ def ok(self) -> bool:
66
+ return self.error is None
67
+
68
+ def extract_text(self) -> str | None:
69
+ """Extract the ``content[0].text`` field from an MCP tools/call result."""
70
+ if not self.ok or not isinstance(self.result, dict):
71
+ return None
72
+ content = self.result.get("content")
73
+ if not isinstance(content, list) or not content:
74
+ return None
75
+ first = content[0]
76
+ if isinstance(first, dict) and isinstance(first.get("text"), str):
77
+ return first["text"]
78
+ return None
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # AlterClient
83
+ # ---------------------------------------------------------------------------
84
+
85
+
86
+ class AlterClient:
87
+ """Async HTTP client for ALTER's MCP server (Streamable HTTP transport).
88
+
89
+ Handles JSON-RPC 2.0 request/response over HTTP POST, with optional
90
+ session management via ``Mcp-Session-Id`` headers. Includes automatic
91
+ retry for transient failures and rate limiting.
92
+
93
+ Usage::
94
+
95
+ async with AlterClient(api_key="alt_xxx") as client: # pragma: allowlist secret
96
+ resp = await client.whoami()
97
+ print(resp.extract_text())
98
+
99
+ For the graceful-fallback discovery path used by the L3 daemon, use
100
+ ``AlterClient.auto_discover()`` which tries the local unix socket first.
101
+ """
102
+
103
+ def __init__(
104
+ self,
105
+ base_url: str = DEFAULT_MCP_ENDPOINT,
106
+ api_key: str | None = None,
107
+ bearer_token: str | None = None,
108
+ timeout: float = 30.0,
109
+ client_name: str = "alter-runtime",
110
+ client_version: str = "0.3.0",
111
+ ) -> None:
112
+ self.base_url = base_url.rstrip("/")
113
+ self.api_key = api_key
114
+ self.bearer_token = bearer_token
115
+ self.timeout = timeout
116
+ self.client_name = client_name
117
+ self.client_version = client_version
118
+ self.session_id: str | None = None
119
+ self._http: httpx.AsyncClient | None = None
120
+
121
+ # ------------------------------------------------------------------
122
+ # Lifecycle
123
+ # ------------------------------------------------------------------
124
+
125
+ async def __aenter__(self) -> AlterClient:
126
+ self._ensure_http()
127
+ await self.initialize()
128
+ return self
129
+
130
+ async def __aexit__(self, *_exc: Any) -> None:
131
+ await self.close()
132
+
133
+ def _ensure_http(self) -> None:
134
+ if self._http is None:
135
+ self._http = httpx.AsyncClient(
136
+ timeout=self.timeout,
137
+ headers=self._build_headers(),
138
+ )
139
+
140
+ def _build_headers(self) -> dict[str, str]:
141
+ # D-MIN-VERSION-FLOOR-1 §3 — the canonical X-Alter-Client-* identity
142
+ # bundle. The SDK is the most-likely third-party touchpoint and
143
+ # honours its ``client_name`` / ``client_version`` constructor args
144
+ # so embedded callers (mcp-alter, mcp-alter-collective, and third
145
+ # parties using AlterClient directly) identify themselves to the
146
+ # server-side floor middleware. The ``binary`` channel is the
147
+ # closest match for an SDK-shipped client; callers may override
148
+ # by setting headers post-construction.
149
+ headers: dict[str, str] = {
150
+ "Content-Type": "application/json",
151
+ "Accept": "application/json",
152
+ "X-Alter-Client-Id": self.client_name,
153
+ "X-Alter-Client-Version": self.client_version,
154
+ "X-Alter-Client-Channel": "binary",
155
+ }
156
+ if self.api_key:
157
+ headers["X-ALTER-API-Key"] = self.api_key
158
+ if self.bearer_token:
159
+ headers["Authorization"] = f"Bearer {self.bearer_token}"
160
+ return headers
161
+
162
+ async def close(self) -> None:
163
+ """Close the MCP session and HTTP client."""
164
+ if self._http is None:
165
+ return
166
+ if self.session_id:
167
+ try:
168
+ await self._http.delete(
169
+ self.base_url,
170
+ headers={"Mcp-Session-Id": self.session_id},
171
+ )
172
+ except httpx.RequestError:
173
+ pass
174
+ await self._http.aclose()
175
+ self._http = None
176
+
177
+ # ------------------------------------------------------------------
178
+ # JSON-RPC core (verbatim shape from backend mcp_client.py)
179
+ # ------------------------------------------------------------------
180
+
181
+ async def _rpc(
182
+ self,
183
+ method: str,
184
+ params: dict[str, Any] | None = None,
185
+ *,
186
+ retries: int = MAX_RETRIES,
187
+ ) -> MCPResponse:
188
+ """Send a JSON-RPC 2.0 request with automatic retry for transient failures."""
189
+ self._ensure_http()
190
+ assert self._http is not None
191
+
192
+ request_id = next(_request_counter)
193
+ payload: dict[str, Any] = {
194
+ "jsonrpc": "2.0",
195
+ "method": method,
196
+ "id": request_id,
197
+ }
198
+ if params is not None:
199
+ payload["params"] = params
200
+
201
+ headers: dict[str, str] = {}
202
+ if self.session_id:
203
+ headers["Mcp-Session-Id"] = self.session_id
204
+
205
+ last_error: MCPResponse | None = None
206
+
207
+ for attempt in range(1 + retries):
208
+ try:
209
+ resp = await self._http.post(
210
+ self.base_url,
211
+ json=payload,
212
+ headers=headers,
213
+ )
214
+ resp.raise_for_status()
215
+ except httpx.HTTPStatusError as exc:
216
+ status = exc.response.status_code
217
+ if status in RETRYABLE_HTTP_CODES and attempt < retries:
218
+ wait = RETRY_BACKOFF_BASE * (2**attempt)
219
+ logger.warning(
220
+ "MCP HTTP %s (attempt %d/%d), retrying in %.1fs",
221
+ status,
222
+ attempt + 1,
223
+ 1 + retries,
224
+ wait,
225
+ )
226
+ last_error = MCPResponse(
227
+ error={"code": status, "message": str(exc)},
228
+ request_id=request_id,
229
+ )
230
+ await asyncio.sleep(wait)
231
+ continue
232
+ logger.error("MCP HTTP error: %s %s", status, exc.response.text[:200])
233
+ return MCPResponse(
234
+ error={"code": status, "message": str(exc)},
235
+ request_id=request_id,
236
+ )
237
+ except httpx.RequestError as exc:
238
+ if attempt < retries:
239
+ wait = RETRY_BACKOFF_BASE * (2**attempt)
240
+ logger.warning(
241
+ "MCP request error (attempt %d/%d): %s, retrying in %.1fs",
242
+ attempt + 1,
243
+ 1 + retries,
244
+ exc,
245
+ wait,
246
+ )
247
+ last_error = MCPResponse(
248
+ error={"code": -1, "message": str(exc)},
249
+ request_id=request_id,
250
+ )
251
+ await asyncio.sleep(wait)
252
+ continue
253
+ logger.error("MCP request failed: %s", exc)
254
+ return MCPResponse(
255
+ error={"code": -1, "message": str(exc)},
256
+ request_id=request_id,
257
+ )
258
+
259
+ data = resp.json()
260
+
261
+ # Capture session ID from response headers
262
+ if sid := resp.headers.get("Mcp-Session-Id"):
263
+ self.session_id = sid
264
+
265
+ return MCPResponse(
266
+ result=data.get("result"),
267
+ error=data.get("error"),
268
+ request_id=data.get("id", request_id),
269
+ )
270
+
271
+ # Should not reach here, but return last error if it does
272
+ return last_error or MCPResponse(
273
+ error={"code": -1, "message": "Exhausted retries"},
274
+ request_id=request_id,
275
+ )
276
+
277
+ async def _call_tool(
278
+ self,
279
+ tool_name: str,
280
+ arguments: dict[str, Any] | None = None,
281
+ ) -> MCPResponse:
282
+ """Invoke an MCP tool via tools/call."""
283
+ return await self._rpc(
284
+ "tools/call",
285
+ {"name": tool_name, "arguments": arguments or {}},
286
+ )
287
+
288
+ # ------------------------------------------------------------------
289
+ # MCP protocol - session management
290
+ # ------------------------------------------------------------------
291
+
292
+ async def initialize(self) -> MCPResponse:
293
+ """Initialise the MCP session. Called automatically by ``__aenter__``."""
294
+ return await self._rpc(
295
+ "initialize",
296
+ {
297
+ "protocolVersion": MCP_PROTOCOL_VERSION,
298
+ "capabilities": {},
299
+ "clientInfo": {
300
+ "name": self.client_name,
301
+ "version": self.client_version,
302
+ },
303
+ },
304
+ )
305
+
306
+ async def list_tools(self) -> MCPResponse:
307
+ """List available tools on the MCP server."""
308
+ return await self._rpc("tools/list")
309
+
310
+ # ------------------------------------------------------------------
311
+ # Ergonomic wrappers around the free tools that scripts/alter-identity.sh
312
+ # already exercises. These three are the minimum viable surface for the
313
+ # L3 daemon's MCP fallback path (D-RT9).
314
+ # ------------------------------------------------------------------
315
+
316
+ async def whoami(self) -> MCPResponse:
317
+ """Return the authenticated caller's handle + session metadata."""
318
+ return await self._call_tool("alter_whoami")
319
+
320
+ async def attunement(self) -> MCPResponse:
321
+ """Return the caller's current engagement level and attunement score."""
322
+ return await self._call_tool("alter_attunement")
323
+
324
+ async def portfolio(self) -> MCPResponse:
325
+ """Return the caller's identity portfolio (earnings, achievements, style)."""
326
+ return await self._call_tool("alter_portfolio")
327
+
328
+ async def style(self) -> MCPResponse:
329
+ """Return the caller's trait-derived style profile."""
330
+ return await self._call_tool("alter_style")
331
+
332
+ async def consent(self) -> MCPResponse:
333
+ """Return the caller's current consent configuration."""
334
+ return await self._call_tool("alter_consent")
335
+
336
+ async def login_status(self) -> MCPResponse:
337
+ """Return the caller's login status (reads local session file)."""
338
+ return await self._call_tool("alter_login_status")
339
+
340
+ # ------------------------------------------------------------------
341
+ # Identity layer free tools (network building)
342
+ # ------------------------------------------------------------------
343
+
344
+ async def verify_identity(
345
+ self,
346
+ *,
347
+ candidate_id: str | None = None,
348
+ email: str | None = None,
349
+ ) -> MCPResponse:
350
+ """Check if a human is registered. The viral trigger."""
351
+ args: dict[str, Any] = {}
352
+ if candidate_id:
353
+ args["candidate_id"] = candidate_id
354
+ if email:
355
+ args["email"] = email
356
+ return await self._call_tool("verify_identity", args)
357
+
358
+ async def get_profile(self, candidate_id: str) -> MCPResponse:
359
+ """Fetch a public profile by candidate id."""
360
+ return await self._call_tool("get_profile", {"candidate_id": candidate_id})
361
+
362
+ async def get_engagement_level(self, candidate_id: str) -> MCPResponse:
363
+ """Get the engagement level (L1-L4) for a candidate."""
364
+ return await self._call_tool("get_engagement_level", {"candidate_id": candidate_id})
365
+
366
+ # ------------------------------------------------------------------
367
+ # Generic passthrough for tools without a typed wrapper
368
+ # ------------------------------------------------------------------
369
+
370
+ async def call(self, tool_name: str, **kwargs: Any) -> MCPResponse:
371
+ """Call any MCP tool by name with keyword arguments."""
372
+ return await self._call_tool(tool_name, kwargs)
373
+
374
+ # ------------------------------------------------------------------
375
+ # Discovery - local daemon first, direct MCP as fallback (D-RT9)
376
+ # ------------------------------------------------------------------
377
+
378
+ @classmethod
379
+ def auto_discover(
380
+ cls,
381
+ *,
382
+ api_key: str | None = None,
383
+ bearer_token: str | None = None,
384
+ mcp_endpoint: str = DEFAULT_MCP_ENDPOINT,
385
+ ) -> AlterClient:
386
+ """Construct a client that prefers the local daemon over direct MCP.
387
+
388
+ Wave 1 skeleton: returns a direct MCP client. Wave 2 stream 2b wires
389
+ the actual local socket detection path. The shape is already correct
390
+ so consumers can call :meth:`auto_discover` today and upgrade
391
+ transparently when Wave 2 ships.
392
+ """
393
+ # TODO(Wave 2): probe unix_socket_path() and return a socket-backed
394
+ # client if reachable. For now, return a direct MCP client.
395
+ return cls(
396
+ base_url=mcp_endpoint,
397
+ api_key=api_key,
398
+ bearer_token=bearer_token,
399
+ )