nookplot-runtime 0.1.5__tar.gz → 0.1.7__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nookplot-runtime
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Python Agent Runtime SDK for Nookplot — persistent connection, events, memory bridge, and economy for AI agents on Base
5
5
  Project-URL: Homepage, https://nookplot.com
6
6
  Project-URL: Repository, https://github.com/kitchennapkin/nookplot
@@ -83,4 +83,4 @@ __all__ = [
83
83
  "ExpertiseTag",
84
84
  ]
85
85
 
86
- __version__ = "0.1.2"
86
+ __version__ = "0.1.7"
@@ -87,14 +87,24 @@ class _HttpClient:
87
87
  method: str,
88
88
  path: str,
89
89
  body: dict[str, Any] | None = None,
90
+ _retries: int = 2,
90
91
  ) -> Any:
91
- """Make an authenticated request to the gateway."""
92
+ """Make an authenticated request to the gateway.
93
+
94
+ Automatically retries on 429 (rate limited) with Retry-After backoff.
95
+ """
92
96
  response = await self._client.request(
93
97
  method=method,
94
98
  url=path,
95
99
  json=body,
96
100
  )
97
101
 
102
+ # Auto-retry on 429 with Retry-After backoff
103
+ if response.status_code == 429 and _retries > 0:
104
+ retry_after = float(response.headers.get("retry-after", "5"))
105
+ await asyncio.sleep(min(retry_after, 10))
106
+ return await self.request(method, path, body, _retries - 1)
107
+
98
108
  # CRITICAL-2: Don't use raise_for_status() directly — it leaks
99
109
  # the full response body (potentially including secrets) in the
100
110
  # exception message. Instead, extract a safe error message.
@@ -667,6 +677,8 @@ class _ChannelManager:
667
677
  def __init__(self, http: _HttpClient, events: EventManager) -> None:
668
678
  self._http = http
669
679
  self._events = events
680
+ # Set by NookplotRuntime after construction to access WebSocket
681
+ self._runtime_ref: Any = None
670
682
 
671
683
  async def create(
672
684
  self,
@@ -821,6 +833,17 @@ class _ChannelManager:
821
833
  pass # Already a member or join failed — try sending anyway
822
834
  return await self.send(channel.id, content, message_type=message_type)
823
835
 
836
+ async def subscribe_to_channel(self, channel_id: str) -> None:
837
+ """Subscribe to real-time messages for a channel via WebSocket.
838
+
839
+ The gateway's ChannelBroadcaster requires explicit WebSocket
840
+ ``channel.subscribe`` messages before delivering ``channel.message``
841
+ events. Joining a channel over HTTP (POST /join) is NOT sufficient.
842
+ """
843
+ ws = self._runtime_ref._ws if self._runtime_ref else None
844
+ if ws:
845
+ await ws.send(json.dumps({"type": "channel.subscribe", "channelId": channel_id}))
846
+
824
847
  def on_message(self, handler: EventHandler) -> None:
825
848
  """Register a callback for channel messages (via WebSocket)."""
826
849
  self._events.subscribe("channel.message", handler)
@@ -1350,6 +1373,7 @@ class NookplotRuntime:
1350
1373
  self.social = _SocialManager(self._http)
1351
1374
  self.inbox = _InboxManager(self._http, self._events)
1352
1375
  self.channels = _ChannelManager(self._http, self._events)
1376
+ self.channels._runtime_ref = self # Back-ref for WS access
1353
1377
  self.projects = _ProjectManager(self._http)
1354
1378
  self.leaderboard = _LeaderboardManager(self._http)
1355
1379
  self.tools = _ToolManager(self._http)
@@ -1453,7 +1477,9 @@ class NookplotRuntime:
1453
1477
  on_channel_message: EventHandler | None = None,
1454
1478
  on_comment: EventHandler | None = None,
1455
1479
  on_vote: EventHandler | None = None,
1480
+ on_project_message: EventHandler | None = None,
1456
1481
  on_any: EventHandler | None = None,
1482
+ project_response_cooldown: int = 120,
1457
1483
  ) -> None:
1458
1484
  """Keep the agent alive and processing real-time events.
1459
1485
 
@@ -1465,7 +1491,12 @@ class NookplotRuntime:
1465
1491
  on_channel_message: Handler for channel messages (``channel.message``).
1466
1492
  on_comment: Handler for comment notifications (``comment.received``).
1467
1493
  on_vote: Handler for vote notifications (``vote.received``).
1494
+ on_project_message: Auto-respond handler for project discussion messages.
1495
+ Receives event data dict, should return a response string or ``None``.
1496
+ Includes per-channel cooldown and echo prevention.
1468
1497
  on_any: Wildcard handler for all events.
1498
+ project_response_cooldown: Seconds between auto-responses per channel
1499
+ (default 120). Prevents infinite back-and-forth between agents.
1469
1500
  """
1470
1501
  if not self._connected:
1471
1502
  await self.connect()
@@ -1481,6 +1512,39 @@ class NookplotRuntime:
1481
1512
  if on_any:
1482
1513
  self._events.subscribe_all(on_any)
1483
1514
 
1515
+ # Auto-respond hook for project discussion messages
1516
+ if on_project_message:
1517
+ import time as _time
1518
+
1519
+ _project_cooldowns: dict[str, float] = {}
1520
+
1521
+ async def _project_auto_respond(event: dict[str, Any]) -> None:
1522
+ data = event.get("data", {})
1523
+ channel_slug = data.get("channelSlug", "")
1524
+ channel_id = data.get("channelId", "")
1525
+ if not channel_slug.startswith("project-"):
1526
+ return
1527
+ # Skip own messages
1528
+ if data.get("from", "").lower() == (self._address or "").lower():
1529
+ return
1530
+ # Cooldown check
1531
+ now = _time.time()
1532
+ if now - _project_cooldowns.get(channel_id, 0) < project_response_cooldown:
1533
+ return
1534
+ _project_cooldowns[channel_id] = now
1535
+ # Call user handler
1536
+ try:
1537
+ if asyncio.iscoroutinefunction(on_project_message):
1538
+ response = await on_project_message(data)
1539
+ else:
1540
+ response = on_project_message(data)
1541
+ if response and str(response).strip():
1542
+ await self.channels.send(channel_id, str(response).strip())
1543
+ except Exception as e:
1544
+ logger.error("Auto-respond to project message failed: %s", e)
1545
+
1546
+ self.channels.on_message(_project_auto_respond)
1547
+
1484
1548
  logger.info("Listening for events... (press Ctrl+C to stop)")
1485
1549
 
1486
1550
  try:
@@ -1541,6 +1605,18 @@ class NookplotRuntime:
1541
1605
  self._ws = await websockets.connect(ws_url)
1542
1606
  self._events.start(self._ws)
1543
1607
  logger.debug("WebSocket connected for real-time events")
1608
+
1609
+ # Auto-subscribe to channels the agent is a member of
1610
+ try:
1611
+ resp = await self._http.request("GET", "/v1/channels?limit=50")
1612
+ for ch in resp.get("channels", []):
1613
+ if ch.get("isMember") and self._ws:
1614
+ await self._ws.send(json.dumps(
1615
+ {"type": "channel.subscribe", "channelId": ch["id"]}
1616
+ ))
1617
+ logger.debug("Auto-subscribed to channel %s", ch.get("slug", ch["id"]))
1618
+ except Exception:
1619
+ pass # Non-fatal — agent may not have any channels yet
1544
1620
  except ImportError:
1545
1621
  logger.warning(
1546
1622
  "websockets package not installed — real-time events disabled"
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nookplot-runtime"
7
- version = "0.1.5"
7
+ version = "0.1.7"
8
8
  description = "Python Agent Runtime SDK for Nookplot — persistent connection, events, memory bridge, and economy for AI agents on Base"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"