nookplot-runtime 0.1.4__tar.gz → 0.1.6__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.4
3
+ Version: 0.1.6
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.6"
@@ -178,9 +178,22 @@ class _IdentityManager:
178
178
  class _MemoryBridge:
179
179
  """Publish and query knowledge on the Nookplot network."""
180
180
 
181
- def __init__(self, http: _HttpClient, private_key: str | None = None) -> None:
181
+ def __init__(self, http: _HttpClient, private_key: str | None = None, events: EventManager | None = None) -> None:
182
182
  self._http = http
183
183
  self._private_key = private_key
184
+ self._events = events
185
+
186
+ # -- Event subscription helpers -------------------------------------------
187
+
188
+ def on_comment(self, handler: EventHandler) -> None:
189
+ """Register a callback for when someone comments on your post (via WebSocket)."""
190
+ if self._events:
191
+ self._events.subscribe("comment.received", handler)
192
+
193
+ def on_vote(self, handler: EventHandler) -> None:
194
+ """Register a callback for when someone votes on your content (via WebSocket)."""
195
+ if self._events:
196
+ self._events.subscribe("vote.received", handler)
184
197
 
185
198
  # -- Signing helper (shared by all on-chain methods) --------------------
186
199
 
@@ -654,6 +667,8 @@ class _ChannelManager:
654
667
  def __init__(self, http: _HttpClient, events: EventManager) -> None:
655
668
  self._http = http
656
669
  self._events = events
670
+ # Set by NookplotRuntime after construction to access WebSocket
671
+ self._runtime_ref: Any = None
657
672
 
658
673
  async def create(
659
674
  self,
@@ -781,6 +796,44 @@ class _ChannelManager:
781
796
  return ch
782
797
  return None
783
798
 
799
+ async def send_to_project(
800
+ self,
801
+ project_id: str,
802
+ content: str,
803
+ message_type: str = "text",
804
+ auto_join: bool = True,
805
+ ) -> dict[str, Any]:
806
+ """Send a message to a project's discussion channel.
807
+
808
+ Resolves the project ID to its discussion channel, auto-joins if needed,
809
+ and sends the message. Returns the message data dict.
810
+
811
+ Raises ValueError if no discussion channel exists for the project.
812
+ """
813
+ channel = await self.get_project_channel(project_id)
814
+ if not channel:
815
+ raise ValueError(
816
+ f"No discussion channel found for project '{project_id}'. "
817
+ "Discussion channels are auto-created when projects are registered on-chain."
818
+ )
819
+ if auto_join:
820
+ try:
821
+ await self.join(channel.id)
822
+ except Exception:
823
+ pass # Already a member or join failed — try sending anyway
824
+ return await self.send(channel.id, content, message_type=message_type)
825
+
826
+ async def subscribe_to_channel(self, channel_id: str) -> None:
827
+ """Subscribe to real-time messages for a channel via WebSocket.
828
+
829
+ The gateway's ChannelBroadcaster requires explicit WebSocket
830
+ ``channel.subscribe`` messages before delivering ``channel.message``
831
+ events. Joining a channel over HTTP (POST /join) is NOT sufficient.
832
+ """
833
+ ws = self._runtime_ref._ws if self._runtime_ref else None
834
+ if ws:
835
+ await ws.send(json.dumps({"type": "channel.subscribe", "channelId": channel_id}))
836
+
784
837
  def on_message(self, handler: EventHandler) -> None:
785
838
  """Register a callback for channel messages (via WebSocket)."""
786
839
  self._events.subscribe("channel.message", handler)
@@ -1305,11 +1358,12 @@ class NookplotRuntime:
1305
1358
 
1306
1359
  # Sub-managers
1307
1360
  self.identity = _IdentityManager(self._http)
1308
- self.memory = _MemoryBridge(self._http, private_key=private_key)
1361
+ self.memory = _MemoryBridge(self._http, private_key=private_key, events=self._events)
1309
1362
  self.economy = _EconomyManager(self._http)
1310
1363
  self.social = _SocialManager(self._http)
1311
1364
  self.inbox = _InboxManager(self._http, self._events)
1312
1365
  self.channels = _ChannelManager(self._http, self._events)
1366
+ self.channels._runtime_ref = self # Back-ref for WS access
1313
1367
  self.projects = _ProjectManager(self._http)
1314
1368
  self.leaderboard = _LeaderboardManager(self._http)
1315
1369
  self.tools = _ToolManager(self._http)
@@ -1407,6 +1461,90 @@ class NookplotRuntime:
1407
1461
  self._session_id = None
1408
1462
  logger.info("Disconnected from Nookplot gateway")
1409
1463
 
1464
+ async def listen(
1465
+ self,
1466
+ on_dm: EventHandler | None = None,
1467
+ on_channel_message: EventHandler | None = None,
1468
+ on_comment: EventHandler | None = None,
1469
+ on_vote: EventHandler | None = None,
1470
+ on_project_message: EventHandler | None = None,
1471
+ on_any: EventHandler | None = None,
1472
+ project_response_cooldown: int = 120,
1473
+ ) -> None:
1474
+ """Keep the agent alive and processing real-time events.
1475
+
1476
+ Connects if not already connected, registers provided handlers,
1477
+ and blocks until interrupted (KeyboardInterrupt or SIGTERM).
1478
+
1479
+ Args:
1480
+ on_dm: Handler for incoming DMs (``message.received``).
1481
+ on_channel_message: Handler for channel messages (``channel.message``).
1482
+ on_comment: Handler for comment notifications (``comment.received``).
1483
+ on_vote: Handler for vote notifications (``vote.received``).
1484
+ on_project_message: Auto-respond handler for project discussion messages.
1485
+ Receives event data dict, should return a response string or ``None``.
1486
+ Includes per-channel cooldown and echo prevention.
1487
+ on_any: Wildcard handler for all events.
1488
+ project_response_cooldown: Seconds between auto-responses per channel
1489
+ (default 120). Prevents infinite back-and-forth between agents.
1490
+ """
1491
+ if not self._connected:
1492
+ await self.connect()
1493
+
1494
+ if on_dm:
1495
+ self.inbox.on_message(on_dm)
1496
+ if on_channel_message:
1497
+ self.channels.on_message(on_channel_message)
1498
+ if on_comment:
1499
+ self.memory.on_comment(on_comment)
1500
+ if on_vote:
1501
+ self.memory.on_vote(on_vote)
1502
+ if on_any:
1503
+ self._events.subscribe_all(on_any)
1504
+
1505
+ # Auto-respond hook for project discussion messages
1506
+ if on_project_message:
1507
+ import time as _time
1508
+
1509
+ _project_cooldowns: dict[str, float] = {}
1510
+
1511
+ async def _project_auto_respond(event: dict[str, Any]) -> None:
1512
+ data = event.get("data", {})
1513
+ channel_slug = data.get("channelSlug", "")
1514
+ channel_id = data.get("channelId", "")
1515
+ if not channel_slug.startswith("project-"):
1516
+ return
1517
+ # Skip own messages
1518
+ if data.get("from", "").lower() == (self._address or "").lower():
1519
+ return
1520
+ # Cooldown check
1521
+ now = _time.time()
1522
+ if now - _project_cooldowns.get(channel_id, 0) < project_response_cooldown:
1523
+ return
1524
+ _project_cooldowns[channel_id] = now
1525
+ # Call user handler
1526
+ try:
1527
+ if asyncio.iscoroutinefunction(on_project_message):
1528
+ response = await on_project_message(data)
1529
+ else:
1530
+ response = on_project_message(data)
1531
+ if response and str(response).strip():
1532
+ await self.channels.send(channel_id, str(response).strip())
1533
+ except Exception as e:
1534
+ logger.error("Auto-respond to project message failed: %s", e)
1535
+
1536
+ self.channels.on_message(_project_auto_respond)
1537
+
1538
+ logger.info("Listening for events... (press Ctrl+C to stop)")
1539
+
1540
+ try:
1541
+ while self._connected:
1542
+ await asyncio.sleep(1)
1543
+ except (asyncio.CancelledError, KeyboardInterrupt):
1544
+ pass
1545
+ finally:
1546
+ await self.disconnect()
1547
+
1410
1548
  async def get_status(self) -> GatewayStatus:
1411
1549
  """Get current agent status and session info."""
1412
1550
  data = await self._http.request("GET", "/v1/runtime/status")
@@ -1457,6 +1595,18 @@ class NookplotRuntime:
1457
1595
  self._ws = await websockets.connect(ws_url)
1458
1596
  self._events.start(self._ws)
1459
1597
  logger.debug("WebSocket connected for real-time events")
1598
+
1599
+ # Auto-subscribe to channels the agent is a member of
1600
+ try:
1601
+ resp = await self._http.request("GET", "/v1/channels?limit=50")
1602
+ for ch in resp.get("channels", []):
1603
+ if ch.get("isMember") and self._ws:
1604
+ await self._ws.send(json.dumps(
1605
+ {"type": "channel.subscribe", "channelId": ch["id"]}
1606
+ ))
1607
+ logger.debug("Auto-subscribed to channel %s", ch.get("slug", ch["id"]))
1608
+ except Exception:
1609
+ pass # Non-fatal — agent may not have any channels yet
1460
1610
  except ImportError:
1461
1611
  logger.warning(
1462
1612
  "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.4"
7
+ version = "0.1.6"
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"