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.
- alter_runtime/__init__.py +11 -0
- alter_runtime/adapters/__init__.py +19 -0
- alter_runtime/adapters/claude_jsonl_watcher.py +545 -0
- alter_runtime/adapters/git_watcher.py +457 -0
- alter_runtime/adapters/household/__init__.py +29 -0
- alter_runtime/adapters/household/_base.py +138 -0
- alter_runtime/adapters/household/compost/__init__.py +17 -0
- alter_runtime/adapters/household/compost/adapter.py +81 -0
- alter_runtime/adapters/household/compost/storage.py +75 -0
- alter_runtime/adapters/household/compost/tests/__init__.py +0 -0
- alter_runtime/adapters/household/compost/tests/test_adapter.py +62 -0
- alter_runtime/adapters/household/compost/tests/test_storage.py +23 -0
- alter_runtime/adapters/household/compost/tests/test_traits.py +38 -0
- alter_runtime/adapters/household/compost/traits.py +79 -0
- alter_runtime/adapters/household/self_hoster/__init__.py +30 -0
- alter_runtime/adapters/household/self_hoster/adapter.py +248 -0
- alter_runtime/adapters/household/self_hoster/storage.py +83 -0
- alter_runtime/adapters/household/self_hoster/tests/__init__.py +0 -0
- alter_runtime/adapters/household/self_hoster/tests/test_adapter.py +216 -0
- alter_runtime/adapters/household/self_hoster/tests/test_storage.py +25 -0
- alter_runtime/adapters/household/self_hoster/tests/test_traits.py +55 -0
- alter_runtime/adapters/household/self_hoster/traits.py +105 -0
- alter_runtime/adapters/household/tapo_ecosystem/__init__.py +22 -0
- alter_runtime/adapters/household/tapo_ecosystem/adapter.py +98 -0
- alter_runtime/adapters/household/tapo_ecosystem/storage.py +95 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/__init__.py +0 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_adapter.py +55 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_storage.py +28 -0
- alter_runtime/adapters/household/tapo_ecosystem/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/tapo_ecosystem/traits.py +97 -0
- alter_runtime/adapters/household/workshop_tools/__init__.py +25 -0
- alter_runtime/adapters/household/workshop_tools/adapter.py +77 -0
- alter_runtime/adapters/household/workshop_tools/storage.py +92 -0
- alter_runtime/adapters/household/workshop_tools/tests/__init__.py +0 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_adapter.py +48 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_storage.py +26 -0
- alter_runtime/adapters/household/workshop_tools/tests/test_traits.py +45 -0
- alter_runtime/adapters/household/workshop_tools/traits.py +95 -0
- alter_runtime/adapters/worktree_watcher.py +378 -0
- alter_runtime/atlas/__init__.py +48 -0
- alter_runtime/atlas/base.py +102 -0
- alter_runtime/atlas/ledger.py +196 -0
- alter_runtime/atlas/observations.py +136 -0
- alter_runtime/atlas/schema.py +106 -0
- alter_runtime/cap_cache.py +392 -0
- alter_runtime/cli.py +517 -0
- alter_runtime/clients/__init__.py +0 -0
- alter_runtime/clients/token_usage_client.py +273 -0
- alter_runtime/config.py +648 -0
- alter_runtime/consent.py +425 -0
- alter_runtime/daemon.py +518 -0
- alter_runtime/floor_loop.py +335 -0
- alter_runtime/floor_preflight.py +734 -0
- alter_runtime/http_auth.py +173 -0
- alter_runtime/notifiers/__init__.py +18 -0
- alter_runtime/notifiers/desktop.py +321 -0
- alter_runtime/sdk/__init__.py +12 -0
- alter_runtime/sdk/client.py +399 -0
- alter_runtime/service_install.py +616 -0
- alter_runtime/services/__init__.py +59 -0
- alter_runtime/services/launchd/com.alter.runtime.plist.in +90 -0
- alter_runtime/services/systemd/alter-runtime.service.in +74 -0
- alter_runtime/services/systemd/cf-access-env.conf.in +29 -0
- alter_runtime/sockets/__init__.py +20 -0
- alter_runtime/sockets/dbus.py +272 -0
- alter_runtime/sockets/unix.py +702 -0
- alter_runtime/subscribers/__init__.py +58 -0
- alter_runtime/subscribers/active_sessions_cron_emitter.py +313 -0
- alter_runtime/subscribers/active_sessions_do_publisher.py +1159 -0
- alter_runtime/subscribers/active_sessions_gc.py +432 -0
- alter_runtime/subscribers/active_sessions_writer.py +446 -0
- alter_runtime/subscribers/adapters_writer.py +415 -0
- alter_runtime/subscribers/agent_frames.py +461 -0
- alter_runtime/subscribers/bus.py +188 -0
- alter_runtime/subscribers/cache_writer.py +347 -0
- alter_runtime/subscribers/ceremony_echo.py +290 -0
- alter_runtime/subscribers/do_sse.py +864 -0
- alter_runtime/subscribers/ebpf.py +506 -0
- alter_runtime/subscribers/inbox_writer.py +469 -0
- alter_runtime/subscribers/mcp_fallback.py +391 -0
- alter_runtime/subscribers/presence_writer.py +426 -0
- alter_runtime/subscribers/session_presence.py +467 -0
- alter_runtime/subscribers/sse.py +125 -0
- alter_runtime/subscribers/weave_intent_writer.py +608 -0
- alter_runtime/update_loop.py +519 -0
- alter_runtime/weave/__init__.py +21 -0
- alter_runtime/weave/resolver.py +544 -0
- alter_runtime-0.3.0.dist-info/METADATA +289 -0
- alter_runtime-0.3.0.dist-info/RECORD +92 -0
- alter_runtime-0.3.0.dist-info/WHEEL +4 -0
- alter_runtime-0.3.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|