ringforge 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.
ringforge/__init__.py ADDED
@@ -0,0 +1,47 @@
1
+ """Ringforge SDK — Python client for the Ringforge agent mesh platform."""
2
+
3
+ from ringforge.client import RingforgeClient
4
+ from ringforge.types import (
5
+ RingforgeConfig,
6
+ AgentConfig,
7
+ AgentState,
8
+ PresenceAgent,
9
+ ActivityKind,
10
+ ActivityEvent,
11
+ BroadcastActivityParams,
12
+ MemoryEntry,
13
+ SetMemoryParams,
14
+ QueryMemoryParams,
15
+ DirectMessage,
16
+ SendMessageParams,
17
+ Group,
18
+ CreateGroupParams,
19
+ TaskSubmitParams,
20
+ TaskResult,
21
+ TaskStatus,
22
+ TaskEvent,
23
+ )
24
+
25
+ __version__ = "0.1.0"
26
+
27
+ __all__ = [
28
+ "RingforgeClient",
29
+ "RingforgeConfig",
30
+ "AgentConfig",
31
+ "AgentState",
32
+ "PresenceAgent",
33
+ "ActivityKind",
34
+ "ActivityEvent",
35
+ "BroadcastActivityParams",
36
+ "MemoryEntry",
37
+ "SetMemoryParams",
38
+ "QueryMemoryParams",
39
+ "DirectMessage",
40
+ "SendMessageParams",
41
+ "Group",
42
+ "CreateGroupParams",
43
+ "TaskSubmitParams",
44
+ "TaskResult",
45
+ "TaskStatus",
46
+ "TaskEvent",
47
+ ]
ringforge/activity.py ADDED
@@ -0,0 +1,61 @@
1
+ """Activity sub-API for broadcasting and subscribing to fleet events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from ringforge.types import ActivityEvent, BroadcastActivityParams
9
+
10
+ if TYPE_CHECKING:
11
+ from ringforge.client import RingforgeClient
12
+
13
+
14
+ class ActivityAPI:
15
+ """Broadcast and subscribe to fleet-wide activity events."""
16
+
17
+ def __init__(self, client: RingforgeClient) -> None:
18
+ self._client = client
19
+
20
+ async def broadcast(
21
+ self,
22
+ params: BroadcastActivityParams,
23
+ idempotency_key: str | None = None,
24
+ ) -> dict[str, str]:
25
+ """Broadcast an activity event to the fleet."""
26
+ payload = {k: v for k, v in asdict(params).items() if v is not None and v != []}
27
+ return await self._client.push(
28
+ "activity:broadcast",
29
+ {"payload": payload},
30
+ idempotency_key=idempotency_key,
31
+ )
32
+
33
+ async def history(
34
+ self,
35
+ *,
36
+ limit: int | None = None,
37
+ kinds: list[str] | None = None,
38
+ ) -> list[ActivityEvent]:
39
+ """Get recent activity history."""
40
+ opts: dict[str, Any] = {}
41
+ if limit is not None:
42
+ opts["limit"] = limit
43
+ if kinds is not None:
44
+ opts["kinds"] = kinds
45
+ resp = await self._client.push("activity:history", {"payload": opts})
46
+ events_data = resp.get("payload", {}).get("events", [])
47
+ return [ActivityEvent(**e) for e in events_data]
48
+
49
+ async def subscribe(self, tags: list[str]) -> list[str]:
50
+ """Subscribe to tagged activity events."""
51
+ resp = await self._client.push(
52
+ "activity:subscribe", {"payload": {"tags": tags}}
53
+ )
54
+ return resp.get("subscribed_tags", [])
55
+
56
+ async def unsubscribe(self, tags: list[str]) -> list[str]:
57
+ """Unsubscribe from tagged activity events."""
58
+ resp = await self._client.push(
59
+ "activity:unsubscribe", {"payload": {"tags": tags}}
60
+ )
61
+ return resp.get("subscribed_tags", [])
ringforge/client.py ADDED
@@ -0,0 +1,405 @@
1
+ """Ringforge async WebSocket client using the Phoenix Channel v2 wire protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import time
9
+ from typing import Any, Callable, Coroutine
10
+
11
+ import websockets
12
+ import websockets.exceptions
13
+
14
+ from ringforge.types import RingforgeConfig
15
+ from ringforge.presence import PresenceAPI
16
+ from ringforge.activity import ActivityAPI
17
+ from ringforge.memory import MemoryAPI
18
+ from ringforge.messaging import DirectMessageAPI
19
+ from ringforge.groups import GroupAPI
20
+ from ringforge.tasks import TasksAPI
21
+
22
+ logger = logging.getLogger("ringforge")
23
+
24
+ # Type alias for event handlers
25
+ EventHandler = Callable[..., Any] | Callable[..., Coroutine[Any, Any, Any]]
26
+
27
+
28
+ class RingforgeClient:
29
+ """Async client for the Ringforge agent mesh platform.
30
+
31
+ Implements the Phoenix Channel v2 JSON wire protocol over WebSocket.
32
+ Messages are JSON arrays: ``[join_ref, ref, topic, event, payload]``
33
+
34
+ Example::
35
+
36
+ from ringforge import RingforgeClient, RingforgeConfig, AgentConfig
37
+
38
+ config = RingforgeConfig(
39
+ server="wss://ringforge.example.com/ws/websocket",
40
+ api_key="rf_live_...",
41
+ agent=AgentConfig(name="my-agent", capabilities=["search"]),
42
+ )
43
+ client = RingforgeClient(config)
44
+ await client.connect()
45
+ """
46
+
47
+ def __init__(self, config: RingforgeConfig) -> None:
48
+ self._config = config
49
+ self._ws: websockets.WebSocketClientProtocol | None = None
50
+ self._ref: int = 0
51
+ self._agent_id: str | None = None
52
+ self._fleet_id: str | None = None
53
+ self._connected = False
54
+ self._running = False
55
+ self._reconnect_attempts = 0
56
+
57
+ # Pending replies: ref → Future
58
+ self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {}
59
+
60
+ # Event listeners: event_name → set of handlers
61
+ self._listeners: dict[str, set[EventHandler]] = {}
62
+
63
+ # Idempotency cache: key → (result, expires_at)
64
+ self._idempotency_cache: dict[str, tuple[Any, float]] = {}
65
+
66
+ # Background tasks
67
+ self._heartbeat_task: asyncio.Task[None] | None = None
68
+ self._receive_task: asyncio.Task[None] | None = None
69
+
70
+ # Sub-APIs
71
+ self.presence = PresenceAPI(self)
72
+ self.activity = ActivityAPI(self)
73
+ self.memory = MemoryAPI(self)
74
+ self.dm = DirectMessageAPI(self)
75
+ self.groups = GroupAPI(self)
76
+ self.tasks = TasksAPI(self)
77
+
78
+ # ── Properties ────────────────────────────────────────
79
+
80
+ @property
81
+ def agent_id(self) -> str | None:
82
+ """Agent ID assigned after connection."""
83
+ return self._agent_id
84
+
85
+ @property
86
+ def fleet_id(self) -> str | None:
87
+ """Fleet ID resolved after connection."""
88
+ return self._fleet_id
89
+
90
+ @property
91
+ def connected(self) -> bool:
92
+ """Whether the client is connected to the fleet."""
93
+ return self._connected
94
+
95
+ # ── Connection ────────────────────────────────────────
96
+
97
+ async def connect(self) -> str:
98
+ """Connect to the Ringforge fleet.
99
+
100
+ Returns the assigned agent ID.
101
+ """
102
+ self._running = True
103
+ await self._do_connect()
104
+ assert self._agent_id is not None
105
+ return self._agent_id
106
+
107
+ async def _do_connect(self) -> None:
108
+ """Establish WebSocket connection and join the fleet channel."""
109
+ url = self._config.server
110
+ try:
111
+ self._ws = await websockets.connect(url)
112
+ except Exception as exc:
113
+ self._emit_sync("error", exc)
114
+ raise
115
+
116
+ # Start receive loop
117
+ self._receive_task = asyncio.create_task(self._receive_loop())
118
+
119
+ # Join the fleet channel
120
+ topic = (
121
+ f"fleet:{self._config.fleet_id}"
122
+ if self._config.fleet_id
123
+ else "fleet:lobby"
124
+ )
125
+ self._topic = topic
126
+
127
+ join_payload = {
128
+ "api_key": self._config.api_key,
129
+ "agent": {
130
+ "name": self._config.agent.name,
131
+ "framework": self._config.agent.framework,
132
+ "capabilities": self._config.agent.capabilities,
133
+ },
134
+ "payload": {
135
+ "state": self._config.agent.state,
136
+ "task": self._config.agent.task,
137
+ "metadata": self._config.agent.metadata,
138
+ "name": self._config.agent.name,
139
+ "framework": self._config.agent.framework,
140
+ "capabilities": self._config.agent.capabilities,
141
+ },
142
+ }
143
+
144
+ resp = await self._push_raw(topic, "phx_join", join_payload, join_ref="join")
145
+ status = resp.get("status", "")
146
+ response = resp.get("response", {})
147
+
148
+ if status != "ok":
149
+ raise ConnectionError(f"Fleet join failed: {resp}")
150
+
151
+ self._agent_id = response.get("agent_id")
152
+ self._fleet_id = response.get("fleet_id") or self._config.fleet_id
153
+ self._connected = True
154
+ self._reconnect_attempts = 0
155
+
156
+ # Start heartbeat
157
+ self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
158
+
159
+ self._emit_sync("connected", self._agent_id)
160
+
161
+ async def disconnect(self) -> None:
162
+ """Disconnect from the fleet."""
163
+ self._running = False
164
+ self._connected = False
165
+
166
+ if self._heartbeat_task:
167
+ self._heartbeat_task.cancel()
168
+ self._heartbeat_task = None
169
+
170
+ if self._receive_task:
171
+ self._receive_task.cancel()
172
+ self._receive_task = None
173
+
174
+ if self._ws:
175
+ try:
176
+ # Send leave
177
+ ref = self._next_ref()
178
+ msg = json.dumps([None, ref, self._topic, "phx_leave", {}])
179
+ await self._ws.send(msg)
180
+ except Exception:
181
+ pass
182
+ await self._ws.close()
183
+ self._ws = None
184
+
185
+ self._agent_id = None
186
+ self._fleet_id = None
187
+ self._emit_sync("disconnected", "client_disconnect")
188
+
189
+ # ── Event System ──────────────────────────────────────
190
+
191
+ def on(self, event: str, handler: EventHandler) -> "RingforgeClient":
192
+ """Register an event handler."""
193
+ if event not in self._listeners:
194
+ self._listeners[event] = set()
195
+ self._listeners[event].add(handler)
196
+ return self
197
+
198
+ def off(self, event: str, handler: EventHandler) -> "RingforgeClient":
199
+ """Remove an event handler."""
200
+ if event in self._listeners:
201
+ self._listeners[event].discard(handler)
202
+ return self
203
+
204
+ def _emit_sync(self, event: str, *args: Any) -> None:
205
+ """Emit an event to all registered handlers."""
206
+ handlers = self._listeners.get(event, set())
207
+ for handler in handlers:
208
+ try:
209
+ result = handler(*args)
210
+ # If handler is a coroutine, schedule it
211
+ if asyncio.iscoroutine(result):
212
+ asyncio.ensure_future(result)
213
+ except Exception as exc:
214
+ logger.error("Error in %s handler: %s", event, exc)
215
+
216
+ # ── Push (public) ─────────────────────────────────────
217
+
218
+ async def push(
219
+ self,
220
+ event: str,
221
+ payload: dict[str, Any],
222
+ idempotency_key: str | None = None,
223
+ ) -> dict[str, Any]:
224
+ """Push a message to the fleet channel.
225
+
226
+ Args:
227
+ event: Channel event name (e.g. ``"presence:update"``).
228
+ payload: Event payload dict.
229
+ idempotency_key: Optional key for deduplication (cached 5 min).
230
+
231
+ Returns:
232
+ Server response payload.
233
+ """
234
+ if not self._connected or not self._ws:
235
+ raise RuntimeError("Not connected")
236
+
237
+ # Check idempotency cache
238
+ if idempotency_key:
239
+ cached = self._idempotency_cache.get(idempotency_key)
240
+ if cached and cached[1] > time.monotonic():
241
+ return cached[0]
242
+ payload = {**payload, "_idempotency_key": idempotency_key}
243
+
244
+ resp = await self._push_raw(self._topic, event, payload)
245
+ status = resp.get("status", "")
246
+ response = resp.get("response", resp)
247
+
248
+ if status == "error":
249
+ raise RuntimeError(f"Push '{event}' failed: {json.dumps(response)}")
250
+
251
+ # Cache idempotent result
252
+ if idempotency_key:
253
+ self._idempotency_cache[idempotency_key] = (
254
+ response,
255
+ time.monotonic() + 300,
256
+ )
257
+
258
+ return response
259
+
260
+ # ── Wire Protocol ─────────────────────────────────────
261
+
262
+ def _next_ref(self) -> str:
263
+ """Generate the next message reference."""
264
+ self._ref += 1
265
+ return str(self._ref)
266
+
267
+ async def _push_raw(
268
+ self,
269
+ topic: str,
270
+ event: str,
271
+ payload: dict[str, Any],
272
+ join_ref: str | None = None,
273
+ ) -> dict[str, Any]:
274
+ """Send a raw Phoenix Channel message and wait for the reply.
275
+
276
+ Wire format: ``[join_ref, ref, topic, event, payload]``
277
+ """
278
+ if not self._ws:
279
+ raise RuntimeError("WebSocket not connected")
280
+
281
+ ref = self._next_ref()
282
+ msg = json.dumps([join_ref, ref, topic, event, payload])
283
+
284
+ # Create future for reply
285
+ loop = asyncio.get_running_loop()
286
+ future: asyncio.Future[dict[str, Any]] = loop.create_future()
287
+ self._pending[ref] = future
288
+
289
+ await self._ws.send(msg)
290
+
291
+ try:
292
+ return await asyncio.wait_for(future, timeout=self._config.timeout)
293
+ except asyncio.TimeoutError:
294
+ self._pending.pop(ref, None)
295
+ raise TimeoutError(f"Push '{event}' timed out after {self._config.timeout}s")
296
+
297
+ async def _receive_loop(self) -> None:
298
+ """Background loop reading messages from the WebSocket."""
299
+ try:
300
+ async for raw in self._ws: # type: ignore[union-attr]
301
+ try:
302
+ msg = json.loads(raw)
303
+ except json.JSONDecodeError:
304
+ logger.warning("Received non-JSON message: %s", raw[:200])
305
+ continue
306
+
307
+ if not isinstance(msg, list) or len(msg) < 5:
308
+ logger.warning("Unexpected message format: %s", raw[:200])
309
+ continue
310
+
311
+ _join_ref, ref, topic, event, payload = msg
312
+
313
+ # Handle replies to our pushes
314
+ if event == "phx_reply" and ref and ref in self._pending:
315
+ future = self._pending.pop(ref)
316
+ if not future.done():
317
+ future.set_result(payload)
318
+ continue
319
+
320
+ # Handle server-pushed events (broadcasts)
321
+ if event == "phx_error":
322
+ self._emit_sync("error", RuntimeError(f"Channel error: {payload}"))
323
+ continue
324
+
325
+ if event == "phx_close":
326
+ self._connected = False
327
+ self._emit_sync("disconnected", "server_close")
328
+ break
329
+
330
+ # Forward all other events to handlers
331
+ event_payload = payload.get("payload", payload) if isinstance(payload, dict) else payload
332
+ self._emit_sync(event, event_payload)
333
+
334
+ except websockets.exceptions.ConnectionClosed as exc:
335
+ self._connected = False
336
+ self._emit_sync("disconnected", f"connection_closed: {exc}")
337
+ except asyncio.CancelledError:
338
+ return
339
+ except Exception as exc:
340
+ self._connected = False
341
+ self._emit_sync("error", exc)
342
+ self._emit_sync("disconnected", f"error: {exc}")
343
+
344
+ # Auto-reconnect
345
+ if self._running and self._config.auto_reconnect:
346
+ asyncio.ensure_future(self._reconnect())
347
+
348
+ async def _heartbeat_loop(self) -> None:
349
+ """Send Phoenix heartbeat every 30 seconds."""
350
+ try:
351
+ while self._connected and self._ws:
352
+ await asyncio.sleep(30)
353
+ if self._ws and self._connected:
354
+ ref = self._next_ref()
355
+ msg = json.dumps([None, ref, "phoenix", "heartbeat", {}])
356
+ try:
357
+ await self._ws.send(msg)
358
+ except Exception:
359
+ break
360
+ except asyncio.CancelledError:
361
+ return
362
+
363
+ async def _reconnect(self) -> None:
364
+ """Attempt to reconnect with exponential backoff."""
365
+ while (
366
+ self._running
367
+ and self._reconnect_attempts < self._config.max_reconnect_attempts
368
+ ):
369
+ self._reconnect_attempts += 1
370
+ delay = min(
371
+ self._config.reconnect_interval * (1.5 ** self._reconnect_attempts),
372
+ 30.0,
373
+ )
374
+ self._emit_sync("reconnecting", self._reconnect_attempts)
375
+ logger.info(
376
+ "Reconnecting (attempt %d/%d) in %.1fs...",
377
+ self._reconnect_attempts,
378
+ self._config.max_reconnect_attempts,
379
+ delay,
380
+ )
381
+ await asyncio.sleep(delay)
382
+
383
+ try:
384
+ await self._do_connect()
385
+ logger.info("Reconnected successfully")
386
+ return
387
+ except Exception as exc:
388
+ logger.warning("Reconnect attempt %d failed: %s", self._reconnect_attempts, exc)
389
+
390
+ if self._running:
391
+ self._emit_sync(
392
+ "error",
393
+ RuntimeError(
394
+ f"Failed to reconnect after {self._config.max_reconnect_attempts} attempts"
395
+ ),
396
+ )
397
+
398
+ # ── Cleanup ───────────────────────────────────────────
399
+
400
+ async def __aenter__(self) -> "RingforgeClient":
401
+ await self.connect()
402
+ return self
403
+
404
+ async def __aexit__(self, *exc: Any) -> None:
405
+ await self.disconnect()
ringforge/groups.py ADDED
@@ -0,0 +1,61 @@
1
+ """Groups sub-API for agent collaboration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from ringforge.types import CreateGroupParams, Group
9
+
10
+ if TYPE_CHECKING:
11
+ from ringforge.client import RingforgeClient
12
+
13
+
14
+ class GroupAPI:
15
+ """Create and manage agent groups for collaboration."""
16
+
17
+ def __init__(self, client: RingforgeClient) -> None:
18
+ self._client = client
19
+
20
+ async def create(self, params: CreateGroupParams) -> Group:
21
+ """Create a new group."""
22
+ payload = {k: v for k, v in asdict(params).items() if v is not None and v != []}
23
+ resp = await self._client.push("group:create", {"payload": payload})
24
+ return Group(**resp)
25
+
26
+ async def join(self, group_id: str) -> None:
27
+ """Join an existing group."""
28
+ await self._client.push("group:join", {"payload": {"group_id": group_id}})
29
+
30
+ async def leave(self, group_id: str) -> None:
31
+ """Leave a group."""
32
+ await self._client.push("group:leave", {"payload": {"group_id": group_id}})
33
+
34
+ async def message(self, group_id: str, message: Any) -> None:
35
+ """Send a message to a group."""
36
+ await self._client.push(
37
+ "group:message", {"payload": {"group_id": group_id, "message": message}}
38
+ )
39
+
40
+ async def list(
41
+ self, *, status: str | None = None, type: str | None = None
42
+ ) -> list[Group]:
43
+ """List groups in the fleet."""
44
+ opts: dict[str, Any] = {}
45
+ if status is not None:
46
+ opts["status"] = status
47
+ if type is not None:
48
+ opts["type"] = type
49
+ resp = await self._client.push("group:list", {"payload": opts})
50
+ return [Group(**g) for g in resp.get("groups", [])]
51
+
52
+ async def my_groups(self) -> list[Group]:
53
+ """Get groups the current agent belongs to."""
54
+ resp = await self._client.push("group:my_groups", {})
55
+ return [Group(**g) for g in resp.get("groups", [])]
56
+
57
+ async def dissolve(self, group_id: str, result: Any = None) -> None:
58
+ """Dissolve a group (owner only)."""
59
+ await self._client.push(
60
+ "group:dissolve", {"payload": {"group_id": group_id, "result": result}}
61
+ )
ringforge/memory.py ADDED
@@ -0,0 +1,77 @@
1
+ """Memory sub-API for shared key-value storage."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from ringforge.types import MemoryEntry, QueryMemoryParams, SetMemoryParams
9
+
10
+ if TYPE_CHECKING:
11
+ from ringforge.client import RingforgeClient
12
+
13
+
14
+ class MemoryAPI:
15
+ """Shared key-value store accessible by all agents in the fleet."""
16
+
17
+ def __init__(self, client: RingforgeClient) -> None:
18
+ self._client = client
19
+
20
+ async def set(
21
+ self,
22
+ key: str,
23
+ params: SetMemoryParams,
24
+ idempotency_key: str | None = None,
25
+ ) -> dict[str, Any]:
26
+ """Set a memory entry."""
27
+ payload = {k: v for k, v in asdict(params).items() if v is not None and v != []}
28
+ payload["key"] = key
29
+ return await self._client.push(
30
+ "memory:set", {"payload": payload}, idempotency_key=idempotency_key
31
+ )
32
+
33
+ async def get(self, key: str) -> MemoryEntry:
34
+ """Get a memory entry by key."""
35
+ resp = await self._client.push("memory:get", {"payload": {"key": key}})
36
+ return MemoryEntry(**resp.get("payload", {}))
37
+
38
+ async def delete(self, key: str) -> None:
39
+ """Delete a memory entry."""
40
+ await self._client.push("memory:delete", {"payload": {"key": key}})
41
+
42
+ async def list(
43
+ self,
44
+ *,
45
+ limit: int | None = None,
46
+ offset: int | None = None,
47
+ tags: list[str] | None = None,
48
+ author: str | None = None,
49
+ ) -> list[MemoryEntry]:
50
+ """List memory entries."""
51
+ opts: dict[str, Any] = {}
52
+ if limit is not None:
53
+ opts["limit"] = limit
54
+ if offset is not None:
55
+ opts["offset"] = offset
56
+ if tags is not None:
57
+ opts["tags"] = tags
58
+ if author is not None:
59
+ opts["author"] = author
60
+ resp = await self._client.push("memory:list", {"payload": opts})
61
+ entries_data = resp.get("payload", {}).get("entries", [])
62
+ return [MemoryEntry(**e) for e in entries_data]
63
+
64
+ async def query(self, params: QueryMemoryParams) -> list[MemoryEntry]:
65
+ """Search/query memory entries."""
66
+ payload = {k: v for k, v in asdict(params).items() if v is not None and v != []}
67
+ resp = await self._client.push("memory:query", {"payload": payload})
68
+ entries_data = resp.get("payload", {}).get("entries", [])
69
+ return [MemoryEntry(**e) for e in entries_data]
70
+
71
+ async def subscribe(
72
+ self, pattern: str, events: list[str] | None = None
73
+ ) -> dict[str, str]:
74
+ """Subscribe to memory changes by pattern."""
75
+ return await self._client.push(
76
+ "memory:subscribe", {"payload": {"pattern": pattern, "events": events}}
77
+ )
ringforge/messaging.py ADDED
@@ -0,0 +1,38 @@
1
+ """Messaging sub-API for direct messages between agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from ringforge.types import DirectMessage, SendMessageParams
9
+
10
+ if TYPE_CHECKING:
11
+ from ringforge.client import RingforgeClient
12
+
13
+
14
+ class DirectMessageAPI:
15
+ """Send and receive direct messages between agents."""
16
+
17
+ def __init__(self, client: RingforgeClient) -> None:
18
+ self._client = client
19
+
20
+ async def send(
21
+ self,
22
+ params: SendMessageParams,
23
+ idempotency_key: str | None = None,
24
+ ) -> dict[str, str]:
25
+ """Send a direct message to another agent."""
26
+ payload = {k: v for k, v in asdict(params).items() if v is not None}
27
+ resp = await self._client.push(
28
+ "direct:send", {"payload": payload}, idempotency_key=idempotency_key
29
+ )
30
+ return resp.get("payload", resp)
31
+
32
+ async def history(self, with_agent: str, limit: int = 50) -> list[DirectMessage]:
33
+ """Get conversation history with another agent."""
34
+ resp = await self._client.push(
35
+ "direct:history", {"payload": {"with": with_agent, "limit": limit}}
36
+ )
37
+ messages_data = resp.get("payload", {}).get("messages", [])
38
+ return [DirectMessage(**m) for m in messages_data]
ringforge/presence.py ADDED
@@ -0,0 +1,43 @@
1
+ """Presence sub-API for tracking agent state across the fleet."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from ringforge.types import AgentState, PresenceAgent
8
+
9
+ if TYPE_CHECKING:
10
+ from ringforge.client import RingforgeClient
11
+
12
+
13
+ class PresenceAPI:
14
+ """Manage agent presence within the fleet."""
15
+
16
+ def __init__(self, client: RingforgeClient) -> None:
17
+ self._client = client
18
+
19
+ async def update(
20
+ self,
21
+ *,
22
+ state: AgentState | None = None,
23
+ task: str | None = None,
24
+ load: float | None = None,
25
+ metadata: dict[str, Any] | None = None,
26
+ ) -> None:
27
+ """Update this agent's presence."""
28
+ params: dict[str, Any] = {}
29
+ if state is not None:
30
+ params["state"] = state
31
+ if task is not None:
32
+ params["task"] = task
33
+ if load is not None:
34
+ params["load"] = load
35
+ if metadata is not None:
36
+ params["metadata"] = metadata
37
+ await self._client.push("presence:update", {"payload": params})
38
+
39
+ async def roster(self) -> list[PresenceAgent]:
40
+ """Request the current fleet roster."""
41
+ resp = await self._client.push("presence:roster", {})
42
+ agents_data = resp.get("payload", {}).get("agents", [])
43
+ return [PresenceAgent(**a) for a in agents_data]
ringforge/tasks.py ADDED
@@ -0,0 +1,63 @@
1
+ """Tasks sub-API for orchestrating work across agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import asdict
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from ringforge.types import TaskEvent, TaskResult, TaskSubmitParams
9
+
10
+ if TYPE_CHECKING:
11
+ from ringforge.client import RingforgeClient
12
+
13
+
14
+ class TasksAPI:
15
+ """Orchestrate work across agents with the task system."""
16
+
17
+ def __init__(self, client: RingforgeClient) -> None:
18
+ self._client = client
19
+
20
+ async def submit(
21
+ self,
22
+ params: TaskSubmitParams,
23
+ idempotency_key: str | None = None,
24
+ ) -> dict[str, str]:
25
+ """Submit a new task for assignment.
26
+
27
+ Returns dict with ``task_id`` and ``status``.
28
+ """
29
+ payload = {k: v for k, v in asdict(params).items() if v is not None and v != []}
30
+ return await self._client.push(
31
+ "task:submit", {"payload": payload}, idempotency_key=idempotency_key
32
+ )
33
+
34
+ async def status(self, task_id: str) -> TaskEvent:
35
+ """Get the status of a task."""
36
+ resp = await self._client.push(
37
+ "task:status", {"payload": {"task_id": task_id}}
38
+ )
39
+ return _parse_task_event(resp.get("payload", resp))
40
+
41
+ async def claim(self, task_id: str) -> TaskEvent:
42
+ """Claim an assigned task (worker agents)."""
43
+ resp = await self._client.push(
44
+ "task:claim", {"payload": {"task_id": task_id}}
45
+ )
46
+ return _parse_task_event(resp.get("payload", resp))
47
+
48
+ async def result(self, task_id: str, result: TaskResult) -> TaskEvent:
49
+ """Submit a task result (worker agents)."""
50
+ resp = await self._client.push(
51
+ "task:result",
52
+ {"payload": {"task_id": task_id, "result": asdict(result)}},
53
+ )
54
+ return _parse_task_event(resp.get("payload", resp))
55
+
56
+
57
+ def _parse_task_event(data: dict[str, Any]) -> TaskEvent:
58
+ """Parse a raw dict into a TaskEvent, handling nested TaskResult."""
59
+ result_data = data.pop("result", None)
60
+ task = TaskEvent(**{k: v for k, v in data.items() if k in TaskEvent.__dataclass_fields__})
61
+ if result_data and isinstance(result_data, dict):
62
+ task.result = TaskResult(**{k: v for k, v in result_data.items() if k in TaskResult.__dataclass_fields__})
63
+ return task
ringforge/types.py ADDED
@@ -0,0 +1,243 @@
1
+ """Type definitions for the Ringforge SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Any, Literal
8
+
9
+
10
+ # ── Agent State ────────────────────────────────────────────
11
+
12
+ AgentState = Literal["online", "busy", "away", "offline"]
13
+
14
+
15
+ # ── Config ─────────────────────────────────────────────────
16
+
17
+
18
+ @dataclass
19
+ class AgentConfig:
20
+ """Agent identity configuration."""
21
+
22
+ name: str
23
+ framework: str = "ringforge-sdk-py"
24
+ capabilities: list[str] = field(default_factory=list)
25
+ state: AgentState = "online"
26
+ task: str = ""
27
+ metadata: dict[str, Any] = field(default_factory=dict)
28
+
29
+
30
+ @dataclass
31
+ class RingforgeConfig:
32
+ """Client connection configuration."""
33
+
34
+ server: str
35
+ api_key: str
36
+ agent: AgentConfig
37
+ fleet_id: str | None = None
38
+ auto_reconnect: bool = True
39
+ reconnect_interval: float = 3.0
40
+ max_reconnect_attempts: int = 10
41
+ timeout: float = 10.0
42
+ private_key: str | None = None
43
+
44
+
45
+ # ── Presence ───────────────────────────────────────────────
46
+
47
+
48
+ @dataclass
49
+ class PresenceAgent:
50
+ """An agent visible in the fleet roster."""
51
+
52
+ agent_id: str
53
+ name: str | None = None
54
+ framework: str | None = None
55
+ capabilities: list[str] = field(default_factory=list)
56
+ state: AgentState = "online"
57
+ task: str | None = None
58
+ load: float = 0.0
59
+ connected_at: str = ""
60
+
61
+
62
+ # ── Activity ───────────────────────────────────────────────
63
+
64
+ ActivityKind = Literal[
65
+ "task_started",
66
+ "task_progress",
67
+ "task_completed",
68
+ "task_failed",
69
+ "discovery",
70
+ "question",
71
+ "alert",
72
+ "custom",
73
+ ]
74
+
75
+
76
+ @dataclass
77
+ class ActivityEvent:
78
+ """A fleet activity event."""
79
+
80
+ event_id: str
81
+ from_agent: dict[str, str] = field(default_factory=dict) # {agent_id, name}
82
+ kind: str = "custom"
83
+ description: str = ""
84
+ tags: list[str] = field(default_factory=list)
85
+ data: dict[str, Any] = field(default_factory=dict)
86
+ timestamp: str = ""
87
+
88
+
89
+ @dataclass
90
+ class BroadcastActivityParams:
91
+ """Parameters for broadcasting an activity event."""
92
+
93
+ kind: str # ActivityKind
94
+ description: str
95
+ tags: list[str] = field(default_factory=list)
96
+ data: dict[str, Any] = field(default_factory=dict)
97
+ scope: Literal["fleet", "tagged", "direct"] = "fleet"
98
+ to: str | None = None
99
+
100
+
101
+ # ── Memory ─────────────────────────────────────────────────
102
+
103
+
104
+ @dataclass
105
+ class MemoryEntry:
106
+ """A shared memory entry."""
107
+
108
+ id: str = ""
109
+ key: str = ""
110
+ fleet_id: str = ""
111
+ value: str = ""
112
+ type: str = "string"
113
+ tags: list[str] = field(default_factory=list)
114
+ author: str | None = None
115
+ created_at: str = ""
116
+ updated_at: str = ""
117
+ ttl: str | None = None
118
+ access_count: int = 0
119
+ metadata: dict[str, Any] = field(default_factory=dict)
120
+
121
+
122
+ @dataclass
123
+ class SetMemoryParams:
124
+ """Parameters for setting a memory entry."""
125
+
126
+ value: str
127
+ tags: list[str] = field(default_factory=list)
128
+ type: str = "string"
129
+ ttl: int | None = None
130
+ metadata: dict[str, Any] = field(default_factory=dict)
131
+
132
+
133
+ @dataclass
134
+ class QueryMemoryParams:
135
+ """Parameters for querying memory entries."""
136
+
137
+ tags: list[str] = field(default_factory=list)
138
+ text_search: str | None = None
139
+ author: str | None = None
140
+ since: str | None = None
141
+ limit: int | None = None
142
+ sort: Literal["relevance", "created_at", "updated_at", "access_count"] | None = None
143
+
144
+
145
+ # ── Direct Messages ────────────────────────────────────────
146
+
147
+
148
+ @dataclass
149
+ class DirectMessage:
150
+ """A direct message between agents."""
151
+
152
+ message_id: str = ""
153
+ from_agent: dict[str, str] = field(default_factory=dict)
154
+ to: str = ""
155
+ correlation_id: str | None = None
156
+ message: Any = None
157
+ timestamp: str = ""
158
+
159
+
160
+ @dataclass
161
+ class SendMessageParams:
162
+ """Parameters for sending a direct message."""
163
+
164
+ to: str
165
+ message: Any
166
+ correlation_id: str | None = None
167
+
168
+
169
+ # ── Groups ─────────────────────────────────────────────────
170
+
171
+
172
+ @dataclass
173
+ class Group:
174
+ """An agent group."""
175
+
176
+ group_id: str = ""
177
+ name: str = ""
178
+ type: str = "squad"
179
+ capabilities: list[str] = field(default_factory=list)
180
+ status: str = ""
181
+ created_by: str = ""
182
+ member_count: int = 0
183
+ settings: dict[str, Any] = field(default_factory=dict)
184
+ inserted_at: str = ""
185
+
186
+
187
+ @dataclass
188
+ class CreateGroupParams:
189
+ """Parameters for creating a group."""
190
+
191
+ name: str
192
+ type: Literal["squad", "pod", "channel"] = "squad"
193
+ capabilities: list[str] = field(default_factory=list)
194
+ settings: dict[str, Any] = field(default_factory=dict)
195
+ invite: list[str] = field(default_factory=list)
196
+
197
+
198
+ # ── Tasks ──────────────────────────────────────────────────
199
+
200
+ TaskStatus = Literal["pending", "assigned", "claimed", "running", "completed", "failed", "timeout"]
201
+
202
+
203
+ @dataclass
204
+ class TaskSubmitParams:
205
+ """Parameters for submitting a task."""
206
+
207
+ name: str
208
+ description: str
209
+ capabilities: list[str] = field(default_factory=list)
210
+ assign_to: str | None = None
211
+ priority: int = 0
212
+ timeout: int = 300
213
+ params: dict[str, Any] = field(default_factory=dict)
214
+ tags: list[str] = field(default_factory=list)
215
+
216
+
217
+ @dataclass
218
+ class TaskResult:
219
+ """Result of a completed task."""
220
+
221
+ success: bool
222
+ data: Any = None
223
+ error: str | None = None
224
+ metadata: dict[str, Any] = field(default_factory=dict)
225
+
226
+
227
+ @dataclass
228
+ class TaskEvent:
229
+ """A task lifecycle event."""
230
+
231
+ task_id: str = ""
232
+ name: str = ""
233
+ status: str = "pending" # TaskStatus
234
+ submitted_by: str = ""
235
+ assigned_to: str | None = None
236
+ claimed_by: str | None = None
237
+ priority: int = 0
238
+ params: dict[str, Any] = field(default_factory=dict)
239
+ result: TaskResult | None = None
240
+ tags: list[str] = field(default_factory=list)
241
+ timeout: int = 300
242
+ created_at: str = ""
243
+ updated_at: str = ""
@@ -0,0 +1,283 @@
1
+ Metadata-Version: 2.4
2
+ Name: ringforge
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the Ringforge agent mesh platform
5
+ License-Expression: MIT
6
+ Keywords: agent,ai,mesh,ringforge,sdk
7
+ Requires-Python: >=3.10
8
+ Requires-Dist: pynacl>=1.5.0
9
+ Requires-Dist: websockets>=12.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # ringforge
16
+
17
+ Python SDK for the **Ringforge** agent mesh platform. Connect AI agents into a real-time fleet with presence tracking, shared memory, task orchestration, direct messaging, and group collaboration — all async over WebSocket.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pip install ringforge
23
+ ```
24
+
25
+ ## Quick Start
26
+
27
+ ```python
28
+ import asyncio
29
+ from ringforge import RingforgeClient, RingforgeConfig, AgentConfig
30
+
31
+ async def main():
32
+ config = RingforgeConfig(
33
+ server="wss://ringforge.example.com/ws/websocket",
34
+ api_key="rf_live_...",
35
+ agent=AgentConfig(name="my-agent", capabilities=["search", "summarize"]),
36
+ )
37
+
38
+ async with RingforgeClient(config) as client:
39
+ print(f"Connected as {client.agent_id}")
40
+
41
+ client.on("direct:message", lambda msg: print(f"Got: {msg}"))
42
+
43
+ # Keep running
44
+ await asyncio.Event().wait()
45
+
46
+ asyncio.run(main())
47
+ ```
48
+
49
+ ## Configuration
50
+
51
+ ```python
52
+ from ringforge import RingforgeConfig, AgentConfig
53
+
54
+ config = RingforgeConfig(
55
+ server="wss://ringforge.example.com/ws/websocket",
56
+ api_key="rf_live_...",
57
+ agent=AgentConfig(
58
+ name="my-agent",
59
+ framework="langchain", # optional
60
+ capabilities=["search"], # optional
61
+ state="online", # initial state
62
+ task="Waiting for work", # initial task description
63
+ metadata={"version": "1.0"}, # arbitrary metadata
64
+ ),
65
+ fleet_id="fleet_abc123", # optional — omit to auto-resolve
66
+ auto_reconnect=True, # default: True
67
+ reconnect_interval=3.0, # default: 3.0s
68
+ max_reconnect_attempts=10, # default: 10
69
+ timeout=10.0, # push timeout, default: 10s
70
+ )
71
+ ```
72
+
73
+ ## Sub-APIs
74
+
75
+ ### Presence
76
+
77
+ Track agent state across the fleet.
78
+
79
+ ```python
80
+ # Update your presence
81
+ await client.presence.update(state="busy", task="Processing request #42")
82
+
83
+ # Get the full fleet roster
84
+ agents = await client.presence.roster()
85
+ for agent in agents:
86
+ print(f"{agent.name} [{agent.state}]")
87
+ ```
88
+
89
+ ### Activity
90
+
91
+ Broadcast and subscribe to fleet-wide activity events.
92
+
93
+ ```python
94
+ from ringforge import BroadcastActivityParams
95
+
96
+ # Broadcast an activity event
97
+ await client.activity.broadcast(BroadcastActivityParams(
98
+ kind="task_completed",
99
+ description="Finished data analysis",
100
+ tags=["analytics"],
101
+ data={"rows_processed": 50_000},
102
+ ))
103
+
104
+ # Get recent history
105
+ events = await client.activity.history(limit=20)
106
+
107
+ # Subscribe to tags
108
+ await client.activity.subscribe(["alerts", "deployments"])
109
+ ```
110
+
111
+ ### Memory
112
+
113
+ Shared key-value store accessible by all agents.
114
+
115
+ ```python
116
+ from ringforge import SetMemoryParams, QueryMemoryParams
117
+
118
+ # Set a value
119
+ await client.memory.set("config:model", SetMemoryParams(
120
+ value="gpt-4o",
121
+ tags=["config"],
122
+ ttl=3600,
123
+ ))
124
+
125
+ # Get a value
126
+ entry = await client.memory.get("config:model")
127
+ print(entry.value) # "gpt-4o"
128
+
129
+ # Search memory
130
+ results = await client.memory.query(QueryMemoryParams(
131
+ tags=["config"],
132
+ text_search="model",
133
+ sort="relevance",
134
+ ))
135
+
136
+ # Subscribe to changes
137
+ await client.memory.subscribe("config:*")
138
+ client.on("memory:changed", lambda e: print(f"{e['key']} changed"))
139
+ ```
140
+
141
+ ### Direct Messages
142
+
143
+ Send messages to specific agents.
144
+
145
+ ```python
146
+ from ringforge import SendMessageParams
147
+
148
+ # Send a message
149
+ await client.dm.send(SendMessageParams(
150
+ to="agent_abc123",
151
+ message={"type": "question", "text": "What is the current status?"},
152
+ correlation_id="req-001",
153
+ ))
154
+
155
+ # Listen for incoming messages
156
+ client.on("direct:message", lambda msg: print(f"From {msg['from']['name']}: {msg['message']}"))
157
+
158
+ # Get conversation history
159
+ history = await client.dm.history("agent_abc123", limit=25)
160
+ ```
161
+
162
+ ### Groups
163
+
164
+ Create and manage agent groups.
165
+
166
+ ```python
167
+ from ringforge import CreateGroupParams
168
+
169
+ # Create a group
170
+ group = await client.groups.create(CreateGroupParams(
171
+ name="research-squad",
172
+ type="squad",
173
+ capabilities=["search", "summarize"],
174
+ invite=["agent_abc123"],
175
+ ))
176
+
177
+ # Send a group message
178
+ await client.groups.message(group.group_id, {"status": "in_progress"})
179
+
180
+ # Listen for group messages
181
+ client.on("group:message", lambda msg: print(f"[{msg['group_id']}] {msg}"))
182
+
183
+ # List groups
184
+ groups = await client.groups.list()
185
+ my_groups = await client.groups.my_groups()
186
+ ```
187
+
188
+ ### Tasks
189
+
190
+ Orchestrate work across agents.
191
+
192
+ ```python
193
+ from ringforge import TaskSubmitParams, TaskResult
194
+
195
+ # ── Submitter: create a task ──
196
+ result = await client.tasks.submit(TaskSubmitParams(
197
+ name="analyze-dataset",
198
+ description="Run sentiment analysis on Q4 reviews",
199
+ capabilities=["nlp", "sentiment"],
200
+ priority=5,
201
+ timeout=300,
202
+ params={"dataset": "s3://bucket/reviews-q4.csv"},
203
+ tags=["analytics", "nlp"],
204
+ ))
205
+ task_id = result["task_id"]
206
+
207
+ # Check task status
208
+ task = await client.tasks.status(task_id)
209
+ print(f"Task {task.name}: {task.status}")
210
+
211
+ # Listen for results
212
+ client.on("task:result", lambda e: print(f"Result: {e}"))
213
+
214
+ # ── Worker: claim and complete tasks ──
215
+ async def handle_task(event):
216
+ await client.tasks.claim(event["task_id"])
217
+ # ... do work ...
218
+ await client.tasks.result(event["task_id"], TaskResult(
219
+ success=True,
220
+ data={"sentiment": "positive", "confidence": 0.92},
221
+ metadata={"duration_ms": 4200},
222
+ ))
223
+
224
+ client.on("task:assigned", handle_task)
225
+ ```
226
+
227
+ ## Events
228
+
229
+ Subscribe to real-time events with `client.on(event, handler)`:
230
+
231
+ | Event | Payload | Description |
232
+ |-------|---------|-------------|
233
+ | `connected` | `agent_id: str` | Successfully joined the fleet |
234
+ | `disconnected` | `reason: str` | Disconnected from fleet |
235
+ | `reconnecting` | `attempt: int` | Attempting to reconnect |
236
+ | `error` | `exception` | Connection or protocol error |
237
+ | `presence:joined` | `PresenceAgent` | Agent joined the fleet |
238
+ | `presence:left` | `{agent_id}` | Agent left the fleet |
239
+ | `presence:state_changed` | `PresenceAgent` | Agent updated their state |
240
+ | `presence:roster` | `[PresenceAgent]` | Full roster response |
241
+ | `activity:broadcast` | `ActivityEvent` | Activity event received |
242
+ | `direct:message` | `DirectMessage` | Direct message received |
243
+ | `memory:changed` | `{key, action, author, timestamp}` | Memory entry changed |
244
+ | `group:created` | `Group` | Group created |
245
+ | `group:member_joined` | `{group_id, agent_id}` | Agent joined a group |
246
+ | `group:member_left` | `{group_id, agent_id}` | Agent left a group |
247
+ | `group:dissolved` | `{group_id, result, dissolved_by}` | Group dissolved |
248
+ | `group:message` | `{group_id, from, message, timestamp}` | Group message received |
249
+ | `group:invite` | `{group_id, name, type, invited_by}` | Invited to a group |
250
+ | `task:assigned` | `TaskEvent` | Task assigned to you |
251
+ | `task:claimed` | `TaskEvent` | Task was claimed |
252
+ | `task:result` | `TaskEvent` | Task result submitted |
253
+ | `task:timeout` | `TaskEvent` | Task timed out |
254
+ | `system:quota_warning` | `{resource, used, limit}` | Approaching resource limit |
255
+
256
+ Handlers can be sync functions or async coroutines — both work.
257
+
258
+ ## Idempotency
259
+
260
+ All mutating operations accept an optional `idempotency_key`. Duplicate pushes with the same key within 5 minutes return the cached result.
261
+
262
+ ```python
263
+ import uuid
264
+
265
+ key = str(uuid.uuid4())
266
+ await client.activity.broadcast(params, idempotency_key=key)
267
+ # Safe to retry
268
+ await client.activity.broadcast(params, idempotency_key=key)
269
+ ```
270
+
271
+ ## Features
272
+
273
+ - **Async/await** — built on `asyncio` and `websockets`
274
+ - **Auto-reconnect** — exponential backoff, configurable attempts
275
+ - **Type hints everywhere** — dataclasses, not dicts
276
+ - **Phoenix Channel v2** — native wire protocol implementation
277
+ - **Event-driven** — sync and async handlers
278
+ - **Idempotency** — built-in client-side dedup cache
279
+ - **Context manager** — `async with RingforgeClient(config) as client:`
280
+
281
+ ## License
282
+
283
+ See [LICENSE](../../LICENSE) in the repository root.
@@ -0,0 +1,12 @@
1
+ ringforge/__init__.py,sha256=IoktcvqZcEY_oznRqB_yDbd1zmsy9p0s8ZaM-R6VRzA,942
2
+ ringforge/activity.py,sha256=PzQdHevl2fERmH0XOVCxrFAywq7tIj-DoWr8nYEl30Q,2056
3
+ ringforge/client.py,sha256=bv74NDGRCus7kXZf_f-5zQeLXXwDtgHlPnb1KtzuDB4,14289
4
+ ringforge/groups.py,sha256=FSm4zhVOWZYUpJnV_b-JPlZbIzdMSocWYPqTMqU4NCI,2220
5
+ ringforge/memory.py,sha256=VpEf_CVRCRvZb_ETcaSZXpeKVWJTxd7lNDitFpgUjkE,2749
6
+ ringforge/messaging.py,sha256=1u5JaSOwuJd3L2RM_uAopDv3Idv8fb9uToSnOd9hGRA,1314
7
+ ringforge/presence.py,sha256=FJbArqGg0kY5xsD7Ias39kEDxma7MwgEVVqSdjnSqPg,1345
8
+ ringforge/tasks.py,sha256=-GfFFZxnPp6WyOxmx5Kgy0iD0A2Kn5mA1CtMT3EBqJA,2282
9
+ ringforge/types.py,sha256=5ra2urVNLfYb1tmHljfexKOp0DgA-j6bpnq1IHDdRk0,6593
10
+ ringforge-0.1.0.dist-info/METADATA,sha256=5uNkrX6bKucLAg-gIXPFrFvDf8btCl7ZyS6hvD0nvJc,8254
11
+ ringforge-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ ringforge-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any