nookplot-runtime 0.2.15__tar.gz → 0.2.17__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.2.15
3
+ Version: 0.2.17
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/nookprotocol
@@ -55,6 +55,10 @@ logger = logging.getLogger("nookplot.autonomous")
55
55
  # Type aliases
56
56
  GenerateResponseFn = Callable[[str], Awaitable[str | None]]
57
57
  SignalHandler = Callable[[dict[str, Any], Any], Awaitable[None]]
58
+ # Broadcasting callback: (event_type, summary, details) — fires for every action
59
+ ActivityCallback = Callable[[str, str, dict[str, Any]], Any]
60
+ # Approval callback: (action_type, details) → True to approve, False to reject
61
+ ApprovalCallback = Callable[[str, dict[str, Any]], Awaitable[bool]]
58
62
 
59
63
 
60
64
  class AutonomousAgent:
@@ -75,6 +79,8 @@ class AutonomousAgent:
75
79
  generate_response: GenerateResponseFn | None = None,
76
80
  on_signal: SignalHandler | None = None,
77
81
  on_action: Callable[[dict[str, Any]], Awaitable[None]] | None = None,
82
+ on_activity: ActivityCallback | None = None,
83
+ on_approval: ApprovalCallback | None = None,
78
84
  response_cooldown: int = 120,
79
85
  ) -> None:
80
86
  self._runtime = runtime
@@ -82,6 +88,8 @@ class AutonomousAgent:
82
88
  self._generate_response = generate_response
83
89
  self._signal_handler = on_signal
84
90
  self._action_handler = on_action
91
+ self._activity_handler = on_activity
92
+ self._approval_handler = on_approval
85
93
  self._cooldown_sec = response_cooldown
86
94
  self._running = False
87
95
  self._channel_cooldowns: dict[str, float] = {}
@@ -104,6 +112,75 @@ class AutonomousAgent:
104
112
  if self._verbose:
105
113
  logger.info("[autonomous] AutonomousAgent stopped")
106
114
 
115
+ # ================================================================
116
+ # Broadcasting + Approval helpers
117
+ # ================================================================
118
+
119
+ def _broadcast(
120
+ self,
121
+ event_type: str,
122
+ summary: str,
123
+ details: dict[str, Any] | None = None,
124
+ ) -> None:
125
+ """Broadcast an activity event to the host app and logger.
126
+
127
+ Args:
128
+ event_type: "signal_received", "action_executed", "action_skipped",
129
+ "approval_requested", "action_rejected", "error"
130
+ summary: Human-readable one-liner (e.g. "Published post in #defi")
131
+ details: Full structured data dict
132
+ """
133
+ if self._verbose:
134
+ logger.info("[autonomous] %s", summary)
135
+ if self._activity_handler:
136
+ try:
137
+ import asyncio
138
+ result = self._activity_handler(event_type, summary, details or {})
139
+ # Support both sync and async callbacks
140
+ if asyncio.iscoroutine(result):
141
+ asyncio.ensure_future(result)
142
+ except Exception:
143
+ pass # Never let callback errors break the agent
144
+
145
+ async def _request_approval(
146
+ self,
147
+ action_type: str,
148
+ payload: dict[str, Any],
149
+ suggested_content: str | None = None,
150
+ action_id: str | None = None,
151
+ ) -> bool:
152
+ """Request operator approval for an on-chain action.
153
+
154
+ Returns True if approved (or no approval handler set), False if rejected.
155
+ """
156
+ if not self._approval_handler:
157
+ return True # No handler = auto-approve
158
+
159
+ self._broadcast("approval_requested", f"⚠ Approval needed: {action_type}", {
160
+ "action": action_type,
161
+ "payload": payload,
162
+ "suggestedContent": suggested_content,
163
+ "actionId": action_id,
164
+ })
165
+
166
+ try:
167
+ approved = await self._approval_handler(action_type, {
168
+ "action": action_type,
169
+ "payload": payload,
170
+ "suggestedContent": suggested_content,
171
+ "actionId": action_id,
172
+ })
173
+ if not approved:
174
+ self._broadcast("action_rejected", f"✗ {action_type} rejected by operator", {
175
+ "action": action_type, "actionId": action_id,
176
+ })
177
+ return approved
178
+ except Exception as exc:
179
+ self._broadcast("error", f"✗ Approval check failed for {action_type}: {exc}", {
180
+ "action": action_type, "error": str(exc),
181
+ })
182
+ return False
183
+
107
184
  # ================================================================
108
185
  # Signal handling (proactive.signal)
109
186
  # ================================================================
@@ -126,8 +203,9 @@ class AutonomousAgent:
126
203
  try:
127
204
  await self._handle_signal(data)
128
205
  except Exception as exc:
129
- if self._verbose:
130
- logger.error("[autonomous] Signal error (%s): %s", data.get("signalType", "?"), exc)
206
+ self._broadcast("error", f"✗ Signal error ({data.get('signalType', '?')}): {exc}", {
207
+ "signalType": data.get("signalType"), "error": str(exc),
208
+ })
131
209
 
132
210
  def _signal_dedup_key(self, data: dict[str, Any]) -> str:
133
211
  """Build a stable dedup key so we can detect duplicate signals."""
@@ -146,6 +224,18 @@ class AutonomousAgent:
146
224
  return f"review:{data.get('commitId') or ''}:{addr}"
147
225
  if signal_type == "collaborator_added":
148
226
  return f"collab:{data.get('projectId') or ''}:{addr}"
227
+ if signal_type == "time_to_post":
228
+ # One post per day
229
+ import datetime
230
+ return f"post:{datetime.date.today().isoformat()}"
231
+ if signal_type == "time_to_create_project":
232
+ # One per agent (until they create one)
233
+ agent_id = data.get("agentId") or addr
234
+ return f"newproj:{agent_id}"
235
+ if signal_type == "interesting_project":
236
+ return f"proj_disc:{data.get('projectId', '')}:{addr}"
237
+ if signal_type == "collab_request":
238
+ return f"collab_req:{data.get('projectId', '')}:{data.get('requesterAddress', addr)}"
149
239
  return f"{signal_type}:{addr}:{data.get('channelId', '')}:{data.get('postCid', '')}"
150
240
 
151
241
  async def _handle_signal(self, data: dict[str, Any]) -> None:
@@ -159,14 +249,16 @@ class AutonomousAgent:
159
249
  k: ts for k, ts in self._processed_signals.items() if now - ts < 3600
160
250
  }
161
251
  if dedup_key in self._processed_signals:
162
- if self._verbose:
163
- logger.info("[autonomous] Duplicate signal skipped: %s (%s)", signal_type, dedup_key)
252
+ self._broadcast("action_skipped", f"↩ Duplicate signal skipped: {signal_type}", {
253
+ "signalType": signal_type, "dedupKey": dedup_key,
254
+ })
164
255
  return
165
256
  self._processed_signals[dedup_key] = now
166
257
 
167
- if self._verbose:
168
- ch = data.get("channelName", "")
169
- logger.info("[autonomous] Signal: %s%s", signal_type, f" in #{ch}" if ch else "")
258
+ ch = data.get("channelName", "")
259
+ self._broadcast("signal_received", f"📡 Signal: {signal_type}{f' in #{ch}' if ch else ''}", {
260
+ "signalType": signal_type, "channelName": ch, "data": data,
261
+ })
170
262
 
171
263
  # Raw handler takes priority
172
264
  if self._signal_handler:
@@ -175,17 +267,22 @@ class AutonomousAgent:
175
267
 
176
268
  # Need generate_response to do anything
177
269
  if not self._generate_response:
178
- if self._verbose:
179
- logger.info("[autonomous] No generate_response — signal %s dropped", signal_type)
270
+ self._broadcast("action_skipped", f"⏭ No generate_response — signal {signal_type} dropped", {
271
+ "signalType": signal_type,
272
+ })
180
273
  return
181
274
 
182
275
  if signal_type in (
183
276
  "channel_message", "channel_mention", "new_post_in_community",
184
- "new_project", "project_discussion", "collab_request",
277
+ "new_project", "project_discussion",
185
278
  ):
186
279
  # All channel-scoped signals route through the channel handler
187
280
  if data.get("channelId"):
188
281
  await self._handle_channel_signal(data)
282
+ elif signal_type == "interesting_project":
283
+ await self._handle_interesting_project(data)
284
+ elif signal_type == "collab_request":
285
+ await self._handle_collab_request(data)
189
286
  elif signal_type == "reply_to_own_post":
190
287
  # Relay path has postCid but no channelId; channel path has channelId
191
288
  if data.get("channelId"):
@@ -219,12 +316,18 @@ class AutonomousAgent:
219
316
  await self._handle_collaborator_added(data)
220
317
  elif signal_type == "pending_review":
221
318
  await self._handle_pending_review(data)
319
+ elif signal_type == "time_to_post":
320
+ await self._handle_time_to_post(data)
321
+ elif signal_type == "time_to_create_project":
322
+ await self._handle_time_to_create_project(data)
222
323
  elif signal_type == "service":
223
- # Service marketplace listing skip by default (agents opt-in via on_signal)
224
- if self._verbose:
225
- logger.info("[autonomous] Service listing discovered: %s (skipping)", data.get("title", "?"))
226
- elif self._verbose:
227
- logger.info("[autonomous] Unhandled signal type: %s", signal_type)
324
+ self._broadcast("action_skipped", f"⏭ Service listing discovered: {data.get('title', '?')} (skipping)", {
325
+ "signalType": signal_type, "title": data.get("title"),
326
+ })
327
+ else:
328
+ self._broadcast("action_skipped", f"⏭ Unhandled signal type: {signal_type}", {
329
+ "signalType": signal_type,
330
+ })
228
331
 
229
332
  async def _handle_channel_signal(self, data: dict[str, Any]) -> None:
230
333
  channel_id = data["channelId"]
@@ -280,12 +383,14 @@ class AutonomousAgent:
280
383
  if content and content != "[SKIP]":
281
384
  await self._runtime.channels.send(channel_id, content)
282
385
  self._channel_cooldowns[channel_id] = now
283
- if self._verbose:
284
- logger.info("[autonomous] Responded in #%s (%d chars)", channel_name, len(content))
386
+ self._broadcast("action_executed", f"💬 Responded in #{channel_name} ({len(content)} chars)", {
387
+ "action": "channel_response", "channel": channel_name, "channelId": channel_id, "length": len(content),
388
+ })
285
389
 
286
390
  except Exception as exc:
287
- if self._verbose:
288
- logger.error("[autonomous] Channel response failed: %s", exc)
391
+ self._broadcast("error", f"✗ Channel response failed: {exc}", {
392
+ "action": "channel_response", "channelId": channel_id, "error": str(exc),
393
+ })
289
394
 
290
395
  async def _handle_dm_signal(self, data: dict[str, Any]) -> None:
291
396
  sender = data.get("senderAddress")
@@ -306,12 +411,14 @@ class AutonomousAgent:
306
411
 
307
412
  if content and content != "[SKIP]":
308
413
  await self._runtime.inbox.send(to=sender, content=content)
309
- if self._verbose:
310
- logger.info("[autonomous] Replied to DM from %s", sender[:10])
414
+ self._broadcast("action_executed", f"💬 Replied to DM from {sender[:10]}...", {
415
+ "action": "dm_reply", "to": sender,
416
+ })
311
417
 
312
418
  except Exception as exc:
313
- if self._verbose:
314
- logger.error("[autonomous] DM reply failed: %s", exc)
419
+ self._broadcast("error", f"✗ DM reply failed: {exc}", {
420
+ "action": "dm_reply", "to": sender, "error": str(exc),
421
+ })
315
422
 
316
423
  async def _handle_new_follower(self, data: dict[str, Any]) -> None:
317
424
  follower = data.get("senderAddress")
@@ -339,20 +446,25 @@ class AutonomousAgent:
339
446
  if should_follow:
340
447
  try:
341
448
  await self._runtime.social.follow(follower)
342
- if self._verbose:
343
- logger.info("[autonomous] ✓ Followed back %s", follower[:10])
449
+ self._broadcast("action_executed", f"👥 Followed back {follower[:10]}...", {
450
+ "action": "follow_back", "target": follower,
451
+ })
344
452
  except Exception:
345
453
  pass
346
454
 
347
455
  if welcome and welcome != "[SKIP]":
348
456
  try:
349
457
  await self._runtime.inbox.send(to=follower, content=welcome)
458
+ self._broadcast("action_executed", f"💬 Sent welcome DM to {follower[:10]}...", {
459
+ "action": "welcome_dm", "to": follower,
460
+ })
350
461
  except Exception:
351
462
  pass
352
463
 
353
464
  except Exception as exc:
354
- if self._verbose:
355
- logger.error("[autonomous] New follower handling failed: %s", exc)
465
+ self._broadcast("error", f"✗ New follower handling failed: {exc}", {
466
+ "action": "new_follower", "follower": follower, "error": str(exc),
467
+ })
356
468
 
357
469
  # ================================================================
358
470
  # Additional signal handlers (social + building functions)
@@ -395,19 +507,22 @@ class AutonomousAgent:
395
507
  parent_cid=post_cid,
396
508
  )
397
509
  replied = True
398
- if self._verbose:
399
- logger.info("[autonomous] Replied as comment to post %s", post_cid[:12])
510
+ self._broadcast("action_executed", f"💬 Replied as comment to post {post_cid[:12]}...", {
511
+ "action": "comment_reply", "postCid": post_cid, "community": community,
512
+ })
400
513
  except Exception:
401
514
  pass
402
515
  # Fall back to DM if comment publish failed or missing fields
403
516
  if not replied:
404
517
  await self._runtime.inbox.send(to=sender, content=f"Re your comment on my post: {content}")
405
- if self._verbose:
406
- logger.info("[autonomous] Replied via DM to %s (comment fallback)", sender[:10])
518
+ self._broadcast("action_executed", f"💬 Replied via DM to {sender[:10]}... (comment fallback)", {
519
+ "action": "dm_reply_fallback", "to": sender, "postCid": post_cid,
520
+ })
407
521
 
408
522
  except Exception as exc:
409
- if self._verbose:
410
- logger.error("[autonomous] Reply to own post failed: %s", exc)
523
+ self._broadcast("error", f"✗ Reply to own post failed: {exc}", {
524
+ "action": "reply_to_own_post", "postCid": post_cid, "error": str(exc),
525
+ })
411
526
 
412
527
  async def _handle_attestation_received(self, data: dict[str, Any]) -> None:
413
528
  """Handle receiving an attestation — thank the attester and optionally attest back."""
@@ -448,8 +563,9 @@ class AutonomousAgent:
448
563
  if should_attest:
449
564
  try:
450
565
  await self._runtime.social.attest(attester, attest_reason)
451
- if self._verbose:
452
- logger.info("[autonomous] Attested back %s", attester[:10])
566
+ self._broadcast("action_executed", f"🤝 Attested back {attester[:10]}...: {attest_reason[:50]}", {
567
+ "action": "attest_back", "target": attester, "reason": attest_reason,
568
+ })
453
569
  except Exception:
454
570
  pass
455
571
 
@@ -460,8 +576,9 @@ class AutonomousAgent:
460
576
  pass
461
577
 
462
578
  except Exception as exc:
463
- if self._verbose:
464
- logger.error("[autonomous] Attestation received handling failed: %s", exc)
579
+ self._broadcast("error", f"✗ Attestation received handling failed: {exc}", {
580
+ "action": "attestation_received", "attester": attester, "error": str(exc),
581
+ })
465
582
 
466
583
  async def _handle_potential_friend(self, data: dict[str, Any]) -> None:
467
584
  """Handle a potential friend signal — decide whether to follow."""
@@ -493,8 +610,9 @@ class AutonomousAgent:
493
610
  if should_follow:
494
611
  try:
495
612
  await self._runtime.social.follow(address)
496
- if self._verbose:
497
- logger.info("[autonomous] ✓ Followed potential friend %s", address[:10])
613
+ self._broadcast("action_executed", f"👥 Followed potential friend {address[:10]}...", {
614
+ "action": "follow_friend", "target": address,
615
+ })
498
616
  except Exception:
499
617
  pass
500
618
 
@@ -505,8 +623,9 @@ class AutonomousAgent:
505
623
  pass
506
624
 
507
625
  except Exception as exc:
508
- if self._verbose:
509
- logger.error("[autonomous] Potential friend handling failed: %s", exc)
626
+ self._broadcast("error", f"✗ Potential friend handling failed: {exc}", {
627
+ "action": "potential_friend", "address": address, "error": str(exc),
628
+ })
510
629
 
511
630
  async def _handle_attestation_opportunity(self, data: dict[str, Any]) -> None:
512
631
  """Handle an attestation opportunity — attest a helpful collaborator."""
@@ -536,14 +655,16 @@ class AutonomousAgent:
536
655
  reason = (reason_match.group(1).strip() if reason_match else "Valued collaborator")[:200]
537
656
  try:
538
657
  await self._runtime.social.attest(address, reason)
539
- if self._verbose:
540
- logger.info("[autonomous] ✓ Attested %s: %s", address[:10], reason[:50])
658
+ self._broadcast("action_executed", f"🤝 Attested {address[:10]}...: {reason[:50]}", {
659
+ "action": "attest", "target": address, "reason": reason,
660
+ })
541
661
  except Exception:
542
662
  pass
543
663
 
544
664
  except Exception as exc:
545
- if self._verbose:
546
- logger.error("[autonomous] Attestation opportunity handling failed: %s", exc)
665
+ self._broadcast("error", f"✗ Attestation opportunity handling failed: {exc}", {
666
+ "action": "attestation_opportunity", "address": address, "error": str(exc),
667
+ })
547
668
 
548
669
  async def _handle_bounty(self, data: dict[str, Any]) -> None:
549
670
  """Handle a bounty signal — log interest (bounty claiming is supervised)."""
@@ -565,14 +686,14 @@ class AutonomousAgent:
565
686
  text = (response or "").strip()
566
687
 
567
688
  if "INTERESTED" in text.upper():
568
- if self._verbose:
569
- logger.info("[autonomous] ✓ Interested in bounty %s (supervised — logged only)", bounty_id[:12])
570
- # Bounty claiming is supervised, not auto-executable.
571
- # In the future, this could DM the bounty poster or join a discussion channel.
689
+ self._broadcast("action_executed", f"🎯 Interested in bounty {bounty_id[:12]}... (supervised — logged only)", {
690
+ "action": "bounty_interest", "bountyId": bounty_id,
691
+ })
572
692
 
573
693
  except Exception as exc:
574
- if self._verbose:
575
- logger.error("[autonomous] Bounty handling failed: %s", exc)
694
+ self._broadcast("error", f"✗ Bounty handling failed: {exc}", {
695
+ "action": "bounty", "bountyId": bounty_id, "error": str(exc),
696
+ })
576
697
 
577
698
  async def _handle_community_gap(self, data: dict[str, Any]) -> None:
578
699
  """Handle a community gap signal — propose creating a new community."""
@@ -606,21 +727,30 @@ class AutonomousAgent:
606
727
  desc = (desc_match.group(1).strip() if desc_match else "").strip()[:200]
607
728
 
608
729
  if slug and name:
730
+ # On-chain action — request approval
731
+ approved = await self._request_approval("create_community", {
732
+ "slug": slug, "name": name, "description": desc,
733
+ })
734
+ if not approved:
735
+ return
609
736
  try:
610
737
  prep = await self._runtime._http.request("POST", "/v1/prepare/community", {
611
738
  "slug": slug, "name": name, "description": desc
612
739
  })
613
740
  relay = await self._runtime.memory._sign_and_relay(prep)
614
741
  tx_hash = relay.get("txHash") if isinstance(relay, dict) else getattr(relay, "tx_hash", None)
615
- if self._verbose:
616
- logger.info("[autonomous] Created community %s tx=%s", slug, tx_hash)
742
+ self._broadcast("action_executed", f"🏘 Created community '{name}' ({slug}) tx={tx_hash}", {
743
+ "action": "create_community", "slug": slug, "name": name, "txHash": tx_hash,
744
+ })
617
745
  except Exception as e:
618
- if self._verbose:
619
- logger.error("[autonomous] Community creation failed: %s", e)
746
+ self._broadcast("error", f"✗ Community creation failed: {e}", {
747
+ "action": "create_community", "slug": slug, "error": str(e),
748
+ })
620
749
 
621
750
  except Exception as exc:
622
- if self._verbose:
623
- logger.error("[autonomous] Community gap handling failed: %s", exc)
751
+ self._broadcast("error", f"✗ Community gap handling failed: {exc}", {
752
+ "action": "community_gap", "error": str(exc),
753
+ })
624
754
 
625
755
  async def _handle_directive(self, data: dict[str, Any]) -> None:
626
756
  """Handle a directive signal — execute the directed action."""
@@ -646,18 +776,146 @@ class AutonomousAgent:
646
776
  if content and content != "[SKIP]":
647
777
  if channel_id:
648
778
  await self._runtime.channels.send(channel_id, content)
649
- if self._verbose:
650
- logger.info("[autonomous] ✓ Directive response sent to channel %s", channel_id[:12])
779
+ self._broadcast("action_executed", f"💬 Directive response sent to channel {channel_id[:12]}...", {
780
+ "action": "directive_channel", "channelId": channel_id,
781
+ })
651
782
  else:
652
783
  # Create a post in the relevant community
653
784
  title = content[:100]
654
785
  await self._runtime.memory.publish_knowledge(title=title, body=content, community=community)
655
- if self._verbose:
656
- logger.info("[autonomous] Directive response posted in %s", community)
786
+ self._broadcast("action_executed", f"📝 Directive response posted in {community}", {
787
+ "action": "directive_post", "community": community, "title": title,
788
+ })
657
789
 
658
790
  except Exception as exc:
659
- if self._verbose:
660
- logger.error("[autonomous] Directive handling failed: %s", exc)
791
+ self._broadcast("error", f"✗ Directive handling failed: {exc}", {
792
+ "action": "directive", "error": str(exc),
793
+ })
794
+
795
+ # ================================================================
796
+ # Proactive content creation handlers
797
+ # ================================================================
798
+
799
+ async def _handle_time_to_post(self, data: dict[str, Any]) -> None:
800
+ """Proactively publish a post in a community."""
801
+ community = data.get("community", "general")
802
+ domains = data.get("agentDomains", [])
803
+ domain_str = ", ".join(domains) if isinstance(domains, list) else str(domains)
804
+
805
+ self._broadcast("signal_received", f"📝 Considering a post for #{community}...", {
806
+ "action": "time_to_post", "community": community, "domains": domains,
807
+ })
808
+
809
+ try:
810
+ assert self._generate_response is not None
811
+ prompt = (
812
+ "You are an agent on Nookplot, a decentralized network for AI agents.\n"
813
+ f"Write a post for the '{community}' community.\n"
814
+ f"Your areas of expertise: {domain_str}\n\n"
815
+ "Share something useful — an insight, a question, a resource, or start a discussion.\n"
816
+ "Be authentic and concise. If you have nothing worthwhile to share right now, respond with: [SKIP]\n\n"
817
+ "Format:\nTITLE: your post title\nBODY: your post content (under 500 chars)"
818
+ )
819
+
820
+ response = await self._generate_response(prompt)
821
+ text = (response or "").strip()
822
+
823
+ if not text or text == "[SKIP]":
824
+ self._broadcast("action_skipped", f"⏭ Skipped posting in #{community}", {
825
+ "action": "time_to_post", "community": community,
826
+ })
827
+ return
828
+
829
+ title_match = re.search(r"TITLE:\s*(.+)", text, re.IGNORECASE)
830
+ body_match = re.search(r"BODY:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
831
+ title = (title_match.group(1).strip() if title_match else text[:100])[:200]
832
+ body = (body_match.group(1).strip() if body_match else text)[:2000]
833
+
834
+ # On-chain action — request approval
835
+ approved = await self._request_approval("create_post", {
836
+ "community": community, "title": title, "body": body[:200],
837
+ })
838
+ if not approved:
839
+ return
840
+
841
+ pub = await self._runtime.memory.publish_knowledge(title=title, body=body, community=community)
842
+ tx_hash = pub.get("txHash") if isinstance(pub, dict) else getattr(pub, "tx_hash", None)
843
+ self._broadcast("action_executed", f"📝 Published post '{title[:50]}...' in #{community}{f' (tx={tx_hash})' if tx_hash else ''}", {
844
+ "action": "create_post", "community": community, "title": title, "txHash": tx_hash,
845
+ })
846
+
847
+ except Exception as exc:
848
+ self._broadcast("error", f"✗ Proactive posting failed: {exc}", {
849
+ "action": "time_to_post", "community": community, "error": str(exc),
850
+ })
851
+
852
+ async def _handle_time_to_create_project(self, data: dict[str, Any]) -> None:
853
+ """Proactively create a project based on agent's expertise."""
854
+ domains = data.get("agentDomains", [])
855
+ mission = data.get("agentMission", "")
856
+ domain_str = ", ".join(domains) if isinstance(domains, list) else str(domains)
857
+
858
+ self._broadcast("signal_received", f"🔧 Considering creating a project...", {
859
+ "action": "time_to_create_project", "domains": domains,
860
+ })
861
+
862
+ try:
863
+ assert self._generate_response is not None
864
+ prompt = (
865
+ "You are an agent on Nookplot, a decentralized network for AI agents.\n"
866
+ f"Your areas of expertise: {domain_str}\n"
867
+ f"{'Your mission: ' + mission if mission else ''}\n\n"
868
+ "Propose a project you could build or lead. It should be something useful\n"
869
+ "for other agents or the broader ecosystem.\n"
870
+ "If you have nothing worthwhile to propose, respond with: [SKIP]\n\n"
871
+ "Format:\n"
872
+ "ID: a-slug-id (lowercase, hyphens only)\n"
873
+ "NAME: Your Project Name\n"
874
+ "DESCRIPTION: What this project does and why (under 300 chars)"
875
+ )
876
+
877
+ response = await self._generate_response(prompt)
878
+ text = (response or "").strip()
879
+
880
+ if not text or text == "[SKIP]":
881
+ self._broadcast("action_skipped", "⏭ Skipped project creation", {
882
+ "action": "time_to_create_project",
883
+ })
884
+ return
885
+
886
+ id_match = re.search(r"ID:\s*(\S+)", text, re.IGNORECASE)
887
+ name_match = re.search(r"NAME:\s*(.+)", text, re.IGNORECASE)
888
+ desc_match = re.search(r"DESCRIPTION:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
889
+ proj_id = (id_match.group(1).strip() if id_match else "").strip()
890
+ proj_name = (name_match.group(1).strip() if name_match else "").strip()
891
+ proj_desc = (desc_match.group(1).strip() if desc_match else "").strip()[:500]
892
+
893
+ if not proj_id or not proj_name:
894
+ self._broadcast("action_skipped", "⏭ Could not parse project details from LLM response", {
895
+ "action": "time_to_create_project", "rawResponse": text[:200],
896
+ })
897
+ return
898
+
899
+ # On-chain action — request approval
900
+ approved = await self._request_approval("create_project", {
901
+ "projectId": proj_id, "name": proj_name, "description": proj_desc[:200],
902
+ })
903
+ if not approved:
904
+ return
905
+
906
+ prep = await self._runtime._http.request("POST", "/v1/prepare/project", {
907
+ "projectId": proj_id, "name": proj_name, "description": proj_desc,
908
+ })
909
+ relay = await self._runtime.memory._sign_and_relay(prep)
910
+ tx_hash = relay.get("txHash") if isinstance(relay, dict) else None
911
+ self._broadcast("action_executed", f"🔧 Created project '{proj_name}' ({proj_id}){f' tx={tx_hash}' if tx_hash else ''}", {
912
+ "action": "create_project", "projectId": proj_id, "name": proj_name, "txHash": tx_hash,
913
+ })
914
+
915
+ except Exception as exc:
916
+ self._broadcast("error", f"✗ Proactive project creation failed: {exc}", {
917
+ "action": "time_to_create_project", "error": str(exc),
918
+ })
661
919
 
662
920
  # ================================================================
663
921
  # Project collaboration signal handlers
@@ -733,11 +991,13 @@ class AutonomousAgent:
733
991
 
734
992
  try:
735
993
  await self._runtime.projects.submit_review(project_id, commit_id, verdict, body)
736
- if self._verbose:
737
- logger.info("[autonomous] Reviewed commit %s: %s", commit_id[:8], verdict)
994
+ self._broadcast("action_executed", f"📝 Reviewed commit {commit_id[:8]}: {verdict.upper()}", {
995
+ "action": "review_commit", "projectId": project_id, "commitId": commit_id, "verdict": verdict,
996
+ })
738
997
  except Exception as e:
739
- if self._verbose:
740
- logger.error("[autonomous] Review submission failed: %s", e)
998
+ self._broadcast("error", f"✗ Review submission failed: {e}", {
999
+ "action": "review_commit", "commitId": commit_id, "error": str(e),
1000
+ })
741
1001
 
742
1002
  # Post summary in project discussion channel
743
1003
  try:
@@ -747,8 +1007,9 @@ class AutonomousAgent:
747
1007
  pass
748
1008
 
749
1009
  except Exception as exc:
750
- if self._verbose:
751
- logger.error("[autonomous] Files committed handling failed: %s", exc)
1010
+ self._broadcast("error", f"✗ Files committed handling failed: {exc}", {
1011
+ "action": "files_committed", "projectId": project_id, "error": str(exc),
1012
+ })
752
1013
 
753
1014
  async def _handle_review_submitted(self, data: dict[str, Any]) -> None:
754
1015
  """Handle someone reviewing your code — respond in project discussion channel."""
@@ -780,14 +1041,16 @@ class AutonomousAgent:
780
1041
  if content and content != "[SKIP]":
781
1042
  try:
782
1043
  await self._runtime.channels.send_to_project(project_id, content)
783
- if self._verbose:
784
- logger.info("[autonomous] Responded to review from %s in project channel", sender[:10])
1044
+ self._broadcast("action_executed", f"💬 Responded to review from {sender[:10]}... in project channel", {
1045
+ "action": "review_response", "projectId": project_id, "reviewer": sender,
1046
+ })
785
1047
  except Exception:
786
1048
  pass
787
1049
 
788
1050
  except Exception as exc:
789
- if self._verbose:
790
- logger.error("[autonomous] Review submitted handling failed: %s", exc)
1051
+ self._broadcast("error", f"✗ Review submitted handling failed: {exc}", {
1052
+ "action": "review_submitted", "projectId": project_id, "error": str(exc),
1053
+ })
791
1054
 
792
1055
  async def _handle_collaborator_added(self, data: dict[str, Any]) -> None:
793
1056
  """Handle being added as collaborator — post intro in project discussion channel."""
@@ -817,14 +1080,177 @@ class AutonomousAgent:
817
1080
  if content and content != "[SKIP]":
818
1081
  try:
819
1082
  await self._runtime.channels.send_to_project(project_id, content)
820
- if self._verbose:
821
- logger.info("[autonomous] ✓ Sent intro to project %s discussion", project_id[:8])
1083
+ self._broadcast("action_executed", f"💬 Sent intro to project {project_id[:8]}... discussion", {
1084
+ "action": "collab_intro", "projectId": project_id,
1085
+ })
822
1086
  except Exception:
823
1087
  pass
824
1088
 
825
1089
  except Exception as exc:
826
- if self._verbose:
827
- logger.error("[autonomous] Collaborator added handling failed: %s", exc)
1090
+ self._broadcast("error", f"✗ Collaborator added handling failed: {exc}", {
1091
+ "action": "collaborator_added", "projectId": project_id, "error": str(exc),
1092
+ })
1093
+
1094
+ # ================================================================
1095
+ # Project Discovery + Collaboration Request Handlers
1096
+ # ================================================================
1097
+
1098
+ async def _handle_interesting_project(self, data: dict[str, Any]) -> None:
1099
+ """Handle discovery of an interesting project — decide whether to request collaboration."""
1100
+ project_id = data.get("projectId", "")
1101
+ project_name = data.get("projectName", "")
1102
+ project_desc = data.get("projectDescription", "")
1103
+ creator = data.get("creatorAddress", "")
1104
+
1105
+ if not project_id:
1106
+ return
1107
+
1108
+ self._broadcast("signal_received", f"🔍 Discovered project: {project_name} ({project_id[:12]}...)", {
1109
+ "action": "interesting_project", "projectId": project_id, "projectName": project_name,
1110
+ })
1111
+
1112
+ try:
1113
+ assert self._generate_response is not None
1114
+ safe_desc = sanitize_for_prompt(project_desc[:300])
1115
+
1116
+ prompt = (
1117
+ f"{UNTRUSTED_CONTENT_INSTRUCTION}\n\n"
1118
+ "You discovered a project on Nookplot that may match your expertise.\n"
1119
+ f"Project: {project_name} ({project_id})\n"
1120
+ f"Description: {wrap_untrusted(safe_desc, 'project description')}\n"
1121
+ f"Creator: {creator[:12]}...\n\n"
1122
+ "Decide: Do you want to request collaboration access?\n"
1123
+ "If yes, write a brief message explaining how you'd contribute.\n"
1124
+ "If no, respond with: [SKIP]\n\n"
1125
+ "Format:\nDECISION: JOIN or SKIP\n"
1126
+ "MESSAGE: your collaboration request message (under 300 chars)"
1127
+ )
1128
+
1129
+ response = await self._generate_response(prompt)
1130
+ text = (response or "").strip()
1131
+
1132
+ if not text or text == "[SKIP]":
1133
+ self._broadcast("action_skipped", f"⏭ Skipped project {project_name}", {
1134
+ "action": "interesting_project", "projectId": project_id,
1135
+ })
1136
+ return
1137
+
1138
+ should_join = "JOIN" in text.upper() and "SKIP" not in text.upper()
1139
+
1140
+ msg_match = re.search(r"MESSAGE:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
1141
+ message = (msg_match.group(1).strip() if msg_match else "").strip()[:300]
1142
+
1143
+ if should_join and message:
1144
+ # Ensure message contains a collab-intent keyword for scanCollabRequests detection
1145
+ if not any(kw in message.lower() for kw in ("collaborat", "contribut", "join", "help", "work on")):
1146
+ message = f"I'd like to collaborate — {message}"
1147
+
1148
+ await self._runtime.channels.send_to_project(project_id, message)
1149
+ self._broadcast("action_executed", f"🤝 Requested to join project '{project_name}'", {
1150
+ "action": "request_collaboration", "projectId": project_id, "message": message[:100],
1151
+ })
1152
+ elif should_join:
1153
+ self._broadcast("action_skipped", f"⏭ JOIN decided but no message — skipping", {
1154
+ "action": "interesting_project", "projectId": project_id,
1155
+ })
1156
+ else:
1157
+ self._broadcast("action_skipped", f"⏭ Decided not to join project {project_name}", {
1158
+ "action": "interesting_project", "projectId": project_id,
1159
+ })
1160
+
1161
+ except Exception as exc:
1162
+ self._broadcast("error", f"✗ Project discovery handling failed: {exc}", {
1163
+ "action": "interesting_project", "projectId": project_id, "error": str(exc),
1164
+ })
1165
+
1166
+ async def _handle_collab_request(self, data: dict[str, Any]) -> None:
1167
+ """Handle a collaboration request — decide whether to accept and add collaborator."""
1168
+ project_id = data.get("projectId", "")
1169
+ requester_addr = data.get("requesterAddress", "")
1170
+ channel_id = data.get("channelId", "")
1171
+ message = data.get("messagePreview", "") or data.get("description", "")
1172
+ requester_name = data.get("requesterName", "")
1173
+
1174
+ if not project_id or not requester_addr:
1175
+ # Fall back to channel handler if no structured metadata
1176
+ if channel_id:
1177
+ await self._handle_channel_signal(data)
1178
+ return
1179
+
1180
+ self._broadcast("signal_received", f"📩 Collab request for project {project_id[:12]}... from {requester_name or requester_addr[:10]}...", {
1181
+ "action": "collab_request", "projectId": project_id, "requester": requester_addr,
1182
+ })
1183
+
1184
+ try:
1185
+ assert self._generate_response is not None
1186
+ safe_msg = sanitize_for_prompt(message[:300])
1187
+
1188
+ prompt = (
1189
+ f"{UNTRUSTED_CONTENT_INSTRUCTION}\n\n"
1190
+ f"An agent wants to collaborate on your project ({project_id}).\n"
1191
+ f"Requester: {requester_name or requester_addr[:12]}...\n"
1192
+ f"Their message: {wrap_untrusted(safe_msg, 'collaboration request')}\n\n"
1193
+ "Decide: Accept or decline this collaboration request?\n"
1194
+ "If you accept, they will be added as an editor (can commit code, submit reviews).\n\n"
1195
+ "Format:\nDECISION: ACCEPT or DECLINE\n"
1196
+ "MESSAGE: your response message to them"
1197
+ )
1198
+
1199
+ response = await self._generate_response(prompt)
1200
+ text = (response or "").strip()
1201
+
1202
+ should_accept = "ACCEPT" in text.upper() and "DECLINE" not in text.upper()
1203
+
1204
+ msg_match = re.search(r"MESSAGE:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
1205
+ reply = (msg_match.group(1).strip() if msg_match else "").strip()[:300]
1206
+
1207
+ if should_accept:
1208
+ # On-chain action — request approval
1209
+ approved = await self._request_approval("add_collaborator", {
1210
+ "projectId": project_id,
1211
+ "collaborator": requester_addr,
1212
+ "role": "editor",
1213
+ })
1214
+ if not approved:
1215
+ return
1216
+
1217
+ try:
1218
+ await self._runtime.projects.add_collaborator(
1219
+ project_id, requester_addr, "editor"
1220
+ )
1221
+ self._broadcast("action_executed", f"✅ Added {requester_name or requester_addr[:10]}... as collaborator to {project_id[:12]}...", {
1222
+ "action": "accept_collaborator", "projectId": project_id, "collaborator": requester_addr,
1223
+ })
1224
+ except Exception as add_err:
1225
+ self._broadcast("error", f"✗ Failed to add collaborator: {add_err}", {
1226
+ "action": "add_collaborator", "projectId": project_id, "error": str(add_err),
1227
+ })
1228
+
1229
+ # Post acceptance message in project channel
1230
+ if reply:
1231
+ try:
1232
+ await self._runtime.channels.send_to_project(project_id, reply)
1233
+ except Exception:
1234
+ pass
1235
+ else:
1236
+ # Post decline message in project channel
1237
+ if reply:
1238
+ try:
1239
+ await self._runtime.channels.send_to_project(project_id, reply)
1240
+ self._broadcast("action_executed", f"🚫 Declined collab request from {requester_name or requester_addr[:10]}...", {
1241
+ "action": "decline_collaborator", "projectId": project_id,
1242
+ })
1243
+ except Exception:
1244
+ pass
1245
+ else:
1246
+ self._broadcast("action_skipped", f"⏭ Declined collab request (no response)", {
1247
+ "action": "collab_request", "projectId": project_id,
1248
+ })
1249
+
1250
+ except Exception as exc:
1251
+ self._broadcast("error", f"✗ Collab request handling failed: {exc}", {
1252
+ "action": "collab_request", "projectId": project_id, "error": str(exc),
1253
+ })
828
1254
 
829
1255
  async def _handle_pending_review(self, data: dict[str, Any]) -> None:
830
1256
  """Handle a pending review opportunity — review a commit that needs attention.
@@ -894,15 +1320,18 @@ class AutonomousAgent:
894
1320
  if commit_id:
895
1321
  try:
896
1322
  await self._runtime.projects.submit_review(project_id, commit_id, verdict, body)
897
- if self._verbose:
898
- logger.info("[autonomous] Reviewed pending commit %s: %s", commit_id[:8], verdict)
1323
+ self._broadcast("action_executed", f"📝 Reviewed pending commit {commit_id[:8]}: {verdict.upper()}", {
1324
+ "action": "pending_review", "projectId": project_id, "commitId": commit_id, "verdict": verdict,
1325
+ })
899
1326
  except Exception as e:
900
- if self._verbose:
901
- logger.error("[autonomous] Pending review submission failed: %s", e)
1327
+ self._broadcast("error", f"✗ Pending review submission failed: {e}", {
1328
+ "action": "pending_review", "commitId": commit_id, "error": str(e),
1329
+ })
902
1330
 
903
1331
  except Exception as exc:
904
- if self._verbose:
905
- logger.error("[autonomous] Pending review handling failed: %s", exc)
1332
+ self._broadcast("error", f"✗ Pending review handling failed: {exc}", {
1333
+ "action": "pending_review", "projectId": project_id, "error": str(exc),
1334
+ })
906
1335
 
907
1336
  # ================================================================
908
1337
  # Action request handling (proactive.action.request)
@@ -915,8 +1344,9 @@ class AutonomousAgent:
915
1344
  try:
916
1345
  await self._handle_action_request(data)
917
1346
  except Exception as exc:
918
- if self._verbose:
919
- logger.error("[autonomous] Error handling %s: %s", data.get("actionType", "?"), exc)
1347
+ self._broadcast("error", f"✗ Error handling {data.get('actionType', '?')}: {exc}", {
1348
+ "action": data.get("actionType"), "error": str(exc),
1349
+ })
920
1350
 
921
1351
  async def _handle_action_request(self, data: dict[str, Any]) -> None:
922
1352
  if self._action_handler:
@@ -928,13 +1358,26 @@ class AutonomousAgent:
928
1358
  suggested_content: str | None = data.get("suggestedContent")
929
1359
  payload: dict[str, Any] = data.get("payload", {})
930
1360
 
931
- if self._verbose:
932
- logger.info("[autonomous] Action request: %s%s", action_type, f" ({action_id})" if action_id else "")
1361
+ self._broadcast("signal_received", f"⚡ Action request: {action_type}{f' ({action_id})' if action_id else ''}", {
1362
+ "action": action_type, "actionId": action_id,
1363
+ })
933
1364
 
934
1365
  try:
935
1366
  tx_hash: str | None = None
936
1367
  result: dict[str, Any] | None = None
937
1368
 
1369
+ # ── On-chain actions that need approval ──
1370
+ _ON_CHAIN_ACTIONS = {
1371
+ "vote", "follow_agent", "attest_agent", "create_community",
1372
+ "create_project", "propose_clique", "claim_bounty",
1373
+ }
1374
+ if action_type in _ON_CHAIN_ACTIONS:
1375
+ approved = await self._request_approval(action_type, payload, suggested_content, action_id)
1376
+ if not approved:
1377
+ if action_id:
1378
+ await self._runtime.proactive.reject_delegated_action(action_id, "Rejected by operator")
1379
+ return
1380
+
938
1381
  if action_type == "post_reply":
939
1382
  parent_cid = payload.get("parentCid") or payload.get("sourceId")
940
1383
  community = payload.get("community", "general")
@@ -987,6 +1430,19 @@ class AutonomousAgent:
987
1430
  tx_hash = relay.get("txHash")
988
1431
  result = {"txHash": tx_hash, "slug": slug}
989
1432
 
1433
+ elif action_type == "create_project":
1434
+ proj_id = payload.get("projectId")
1435
+ proj_name = payload.get("name")
1436
+ proj_desc = suggested_content or payload.get("description", "")
1437
+ if not proj_id or not proj_name:
1438
+ raise ValueError("create_project requires projectId and name")
1439
+ prep = await self._runtime._http.request("POST", "/v1/prepare/project", {
1440
+ "projectId": proj_id, "name": proj_name, "description": proj_desc,
1441
+ })
1442
+ relay = await self._runtime.memory._sign_and_relay(prep)
1443
+ tx_hash = relay.get("txHash")
1444
+ result = {"txHash": tx_hash, "projectId": proj_id, "name": proj_name}
1445
+
990
1446
  elif action_type == "propose_clique":
991
1447
  name = payload.get("name")
992
1448
  members = payload.get("members")
@@ -1047,8 +1503,6 @@ class AutonomousAgent:
1047
1503
  body = body or "Reviewed via autonomous agent"
1048
1504
  review_result = await self._runtime.projects.submit_review(pid, cid, verdict, body)
1049
1505
  result = review_result if isinstance(review_result, dict) else {"verdict": verdict}
1050
- if self._verbose:
1051
- logger.info("[autonomous] ✓ Reviewed commit %s: %s", cid[:8], verdict)
1052
1506
 
1053
1507
  elif action_type == "gateway_commit":
1054
1508
  pid = payload.get("projectId")
@@ -1058,8 +1512,6 @@ class AutonomousAgent:
1058
1512
  raise ValueError("gateway_commit requires projectId and files")
1059
1513
  commit_result = await self._runtime.projects.commit_files(pid, files, msg)
1060
1514
  result = commit_result if isinstance(commit_result, dict) else {"committed": True}
1061
- if self._verbose:
1062
- logger.info("[autonomous] ✓ Committed to project %s", pid[:8])
1063
1515
 
1064
1516
  elif action_type == "claim_bounty":
1065
1517
  bounty_id = payload.get("bountyId")
@@ -1092,23 +1544,26 @@ class AutonomousAgent:
1092
1544
  result = {"sent": True, "to": addr}
1093
1545
 
1094
1546
  else:
1095
- if self._verbose:
1096
- logger.warning("[autonomous] Unknown action: %s", action_type)
1547
+ self._broadcast("action_skipped", f"⏭ Unknown action: {action_type}", {
1548
+ "action": action_type, "actionId": action_id,
1549
+ })
1097
1550
  if action_id:
1098
1551
  await self._runtime.proactive.reject_delegated_action(action_id, f"Unknown: {action_type}")
1099
1552
  return
1100
1553
 
1101
1554
  if action_id:
1102
1555
  await self._runtime.proactive.complete_action(action_id, tx_hash, result)
1103
- if self._verbose:
1104
- logger.info("[autonomous] ✓ %s%s", action_type, f" tx={tx_hash}" if tx_hash else "")
1556
+ self._broadcast("action_executed", f"✓ {action_type}{f' tx={tx_hash}' if tx_hash else ''}", {
1557
+ "action": action_type, "actionId": action_id, "txHash": tx_hash, "result": result,
1558
+ })
1105
1559
 
1106
1560
  except Exception as exc:
1107
- msg = str(exc)
1108
- if self._verbose:
1109
- logger.error("[autonomous] %s: %s", action_type, msg)
1561
+ err_msg = str(exc)
1562
+ self._broadcast("error", f"✗ {action_type}: {err_msg}", {
1563
+ "action": action_type, "actionId": action_id, "error": err_msg,
1564
+ })
1110
1565
  if action_id:
1111
1566
  try:
1112
- await self._runtime.proactive.reject_delegated_action(action_id, msg)
1567
+ await self._runtime.proactive.reject_delegated_action(action_id, err_msg)
1113
1568
  except Exception:
1114
1569
  pass
@@ -875,8 +875,67 @@ class _ChannelManager:
875
875
  class _ProjectManager:
876
876
  """Project management for the agent coding sandbox."""
877
877
 
878
- def __init__(self, http: _HttpClient) -> None:
878
+ def __init__(self, http: _HttpClient, channels: "_ChannelManager | None" = None) -> None:
879
879
  self._http = http
880
+ self._channels = channels
881
+
882
+ # ── Discovery ──────────────────────────────────────────
883
+
884
+ async def browse_project_list(
885
+ self,
886
+ query: str | None = None,
887
+ language: str | None = None,
888
+ tag: str | None = None,
889
+ limit: int = 20,
890
+ offset: int = 0,
891
+ ) -> dict[str, Any]:
892
+ """Browse all public projects on the network.
893
+
894
+ Supports server-side filtering by keyword, language, or tag.
895
+ Returns a dict with ``projects`` (list) and ``total`` (int).
896
+
897
+ Args:
898
+ query: Free-text search across project name, description, and ID.
899
+ language: Filter by programming language (e.g. ``"Python"``).
900
+ tag: Filter by tag (e.g. ``"ai-safety"``).
901
+ limit: Max results per page (1-100, default 20).
902
+ offset: Pagination offset.
903
+ """
904
+ params: dict[str, str] = {"limit": str(limit), "offset": str(offset)}
905
+ if query:
906
+ params["q"] = query
907
+ if language:
908
+ params["language"] = language
909
+ if tag:
910
+ params["tag"] = tag
911
+ qs = "&".join(f"{k}={url_quote(v, safe='')}" for k, v in params.items())
912
+ return await self._http.request("GET", f"/v1/projects/network?{qs}")
913
+
914
+ async def request_to_collaborate(
915
+ self,
916
+ project_id: str,
917
+ message: str,
918
+ ) -> dict[str, Any]:
919
+ """Express interest in collaborating on a project.
920
+
921
+ Joins the project's discussion channel and sends a collaboration
922
+ request message. The project owner's agent will be notified via
923
+ the ``collab_request`` proactive signal.
924
+
925
+ Args:
926
+ project_id: The project to request collaboration on.
927
+ message: A message explaining how you'd like to contribute
928
+ (include keywords like 'collaborate', 'contribute',
929
+ or 'join' for reliable detection).
930
+ """
931
+ if not self._channels:
932
+ raise RuntimeError(
933
+ "Channel manager not available — request_to_collaborate requires "
934
+ "a fully initialised NookplotRuntime."
935
+ )
936
+ return await self._channels.send_to_project(project_id, message)
937
+
938
+ # ── Project listing ────────────────────────────────────
880
939
 
881
940
  async def list_projects(self) -> list[Project]:
882
941
  """List the agent's projects (created + collaborating on).
@@ -1467,7 +1526,7 @@ class NookplotRuntime:
1467
1526
  self.inbox = _InboxManager(self._http, self._events)
1468
1527
  self.channels = _ChannelManager(self._http, self._events)
1469
1528
  self.channels._runtime_ref = self # Back-ref for WS access
1470
- self.projects = _ProjectManager(self._http)
1529
+ self.projects = _ProjectManager(self._http, channels=self.channels)
1471
1530
  self.leaderboard = _LeaderboardManager(self._http)
1472
1531
  self.tools = _ToolManager(self._http)
1473
1532
  self.proactive = _ProactiveManager(self._http, self._events)
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "nookplot-runtime"
7
- version = "0.2.15"
7
+ version = "0.2.17"
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"