nookplot-runtime 0.2.15__tar.gz → 0.2.16__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.16
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,14 @@ 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}"
149
235
  return f"{signal_type}:{addr}:{data.get('channelId', '')}:{data.get('postCid', '')}"
150
236
 
151
237
  async def _handle_signal(self, data: dict[str, Any]) -> None:
@@ -159,14 +245,16 @@ class AutonomousAgent:
159
245
  k: ts for k, ts in self._processed_signals.items() if now - ts < 3600
160
246
  }
161
247
  if dedup_key in self._processed_signals:
162
- if self._verbose:
163
- logger.info("[autonomous] Duplicate signal skipped: %s (%s)", signal_type, dedup_key)
248
+ self._broadcast("action_skipped", f"↩ Duplicate signal skipped: {signal_type}", {
249
+ "signalType": signal_type, "dedupKey": dedup_key,
250
+ })
164
251
  return
165
252
  self._processed_signals[dedup_key] = now
166
253
 
167
- if self._verbose:
168
- ch = data.get("channelName", "")
169
- logger.info("[autonomous] Signal: %s%s", signal_type, f" in #{ch}" if ch else "")
254
+ ch = data.get("channelName", "")
255
+ self._broadcast("signal_received", f"📡 Signal: {signal_type}{f' in #{ch}' if ch else ''}", {
256
+ "signalType": signal_type, "channelName": ch, "data": data,
257
+ })
170
258
 
171
259
  # Raw handler takes priority
172
260
  if self._signal_handler:
@@ -175,8 +263,9 @@ class AutonomousAgent:
175
263
 
176
264
  # Need generate_response to do anything
177
265
  if not self._generate_response:
178
- if self._verbose:
179
- logger.info("[autonomous] No generate_response — signal %s dropped", signal_type)
266
+ self._broadcast("action_skipped", f"⏭ No generate_response — signal {signal_type} dropped", {
267
+ "signalType": signal_type,
268
+ })
180
269
  return
181
270
 
182
271
  if signal_type in (
@@ -219,12 +308,18 @@ class AutonomousAgent:
219
308
  await self._handle_collaborator_added(data)
220
309
  elif signal_type == "pending_review":
221
310
  await self._handle_pending_review(data)
311
+ elif signal_type == "time_to_post":
312
+ await self._handle_time_to_post(data)
313
+ elif signal_type == "time_to_create_project":
314
+ await self._handle_time_to_create_project(data)
222
315
  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)
316
+ self._broadcast("action_skipped", f"⏭ Service listing discovered: {data.get('title', '?')} (skipping)", {
317
+ "signalType": signal_type, "title": data.get("title"),
318
+ })
319
+ else:
320
+ self._broadcast("action_skipped", f"⏭ Unhandled signal type: {signal_type}", {
321
+ "signalType": signal_type,
322
+ })
228
323
 
229
324
  async def _handle_channel_signal(self, data: dict[str, Any]) -> None:
230
325
  channel_id = data["channelId"]
@@ -280,12 +375,14 @@ class AutonomousAgent:
280
375
  if content and content != "[SKIP]":
281
376
  await self._runtime.channels.send(channel_id, content)
282
377
  self._channel_cooldowns[channel_id] = now
283
- if self._verbose:
284
- logger.info("[autonomous] Responded in #%s (%d chars)", channel_name, len(content))
378
+ self._broadcast("action_executed", f"💬 Responded in #{channel_name} ({len(content)} chars)", {
379
+ "action": "channel_response", "channel": channel_name, "channelId": channel_id, "length": len(content),
380
+ })
285
381
 
286
382
  except Exception as exc:
287
- if self._verbose:
288
- logger.error("[autonomous] Channel response failed: %s", exc)
383
+ self._broadcast("error", f"✗ Channel response failed: {exc}", {
384
+ "action": "channel_response", "channelId": channel_id, "error": str(exc),
385
+ })
289
386
 
290
387
  async def _handle_dm_signal(self, data: dict[str, Any]) -> None:
291
388
  sender = data.get("senderAddress")
@@ -306,12 +403,14 @@ class AutonomousAgent:
306
403
 
307
404
  if content and content != "[SKIP]":
308
405
  await self._runtime.inbox.send(to=sender, content=content)
309
- if self._verbose:
310
- logger.info("[autonomous] Replied to DM from %s", sender[:10])
406
+ self._broadcast("action_executed", f"💬 Replied to DM from {sender[:10]}...", {
407
+ "action": "dm_reply", "to": sender,
408
+ })
311
409
 
312
410
  except Exception as exc:
313
- if self._verbose:
314
- logger.error("[autonomous] DM reply failed: %s", exc)
411
+ self._broadcast("error", f"✗ DM reply failed: {exc}", {
412
+ "action": "dm_reply", "to": sender, "error": str(exc),
413
+ })
315
414
 
316
415
  async def _handle_new_follower(self, data: dict[str, Any]) -> None:
317
416
  follower = data.get("senderAddress")
@@ -339,20 +438,25 @@ class AutonomousAgent:
339
438
  if should_follow:
340
439
  try:
341
440
  await self._runtime.social.follow(follower)
342
- if self._verbose:
343
- logger.info("[autonomous] ✓ Followed back %s", follower[:10])
441
+ self._broadcast("action_executed", f"👥 Followed back {follower[:10]}...", {
442
+ "action": "follow_back", "target": follower,
443
+ })
344
444
  except Exception:
345
445
  pass
346
446
 
347
447
  if welcome and welcome != "[SKIP]":
348
448
  try:
349
449
  await self._runtime.inbox.send(to=follower, content=welcome)
450
+ self._broadcast("action_executed", f"💬 Sent welcome DM to {follower[:10]}...", {
451
+ "action": "welcome_dm", "to": follower,
452
+ })
350
453
  except Exception:
351
454
  pass
352
455
 
353
456
  except Exception as exc:
354
- if self._verbose:
355
- logger.error("[autonomous] New follower handling failed: %s", exc)
457
+ self._broadcast("error", f"✗ New follower handling failed: {exc}", {
458
+ "action": "new_follower", "follower": follower, "error": str(exc),
459
+ })
356
460
 
357
461
  # ================================================================
358
462
  # Additional signal handlers (social + building functions)
@@ -395,19 +499,22 @@ class AutonomousAgent:
395
499
  parent_cid=post_cid,
396
500
  )
397
501
  replied = True
398
- if self._verbose:
399
- logger.info("[autonomous] Replied as comment to post %s", post_cid[:12])
502
+ self._broadcast("action_executed", f"💬 Replied as comment to post {post_cid[:12]}...", {
503
+ "action": "comment_reply", "postCid": post_cid, "community": community,
504
+ })
400
505
  except Exception:
401
506
  pass
402
507
  # Fall back to DM if comment publish failed or missing fields
403
508
  if not replied:
404
509
  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])
510
+ self._broadcast("action_executed", f"💬 Replied via DM to {sender[:10]}... (comment fallback)", {
511
+ "action": "dm_reply_fallback", "to": sender, "postCid": post_cid,
512
+ })
407
513
 
408
514
  except Exception as exc:
409
- if self._verbose:
410
- logger.error("[autonomous] Reply to own post failed: %s", exc)
515
+ self._broadcast("error", f"✗ Reply to own post failed: {exc}", {
516
+ "action": "reply_to_own_post", "postCid": post_cid, "error": str(exc),
517
+ })
411
518
 
412
519
  async def _handle_attestation_received(self, data: dict[str, Any]) -> None:
413
520
  """Handle receiving an attestation — thank the attester and optionally attest back."""
@@ -448,8 +555,9 @@ class AutonomousAgent:
448
555
  if should_attest:
449
556
  try:
450
557
  await self._runtime.social.attest(attester, attest_reason)
451
- if self._verbose:
452
- logger.info("[autonomous] Attested back %s", attester[:10])
558
+ self._broadcast("action_executed", f"🤝 Attested back {attester[:10]}...: {attest_reason[:50]}", {
559
+ "action": "attest_back", "target": attester, "reason": attest_reason,
560
+ })
453
561
  except Exception:
454
562
  pass
455
563
 
@@ -460,8 +568,9 @@ class AutonomousAgent:
460
568
  pass
461
569
 
462
570
  except Exception as exc:
463
- if self._verbose:
464
- logger.error("[autonomous] Attestation received handling failed: %s", exc)
571
+ self._broadcast("error", f"✗ Attestation received handling failed: {exc}", {
572
+ "action": "attestation_received", "attester": attester, "error": str(exc),
573
+ })
465
574
 
466
575
  async def _handle_potential_friend(self, data: dict[str, Any]) -> None:
467
576
  """Handle a potential friend signal — decide whether to follow."""
@@ -493,8 +602,9 @@ class AutonomousAgent:
493
602
  if should_follow:
494
603
  try:
495
604
  await self._runtime.social.follow(address)
496
- if self._verbose:
497
- logger.info("[autonomous] ✓ Followed potential friend %s", address[:10])
605
+ self._broadcast("action_executed", f"👥 Followed potential friend {address[:10]}...", {
606
+ "action": "follow_friend", "target": address,
607
+ })
498
608
  except Exception:
499
609
  pass
500
610
 
@@ -505,8 +615,9 @@ class AutonomousAgent:
505
615
  pass
506
616
 
507
617
  except Exception as exc:
508
- if self._verbose:
509
- logger.error("[autonomous] Potential friend handling failed: %s", exc)
618
+ self._broadcast("error", f"✗ Potential friend handling failed: {exc}", {
619
+ "action": "potential_friend", "address": address, "error": str(exc),
620
+ })
510
621
 
511
622
  async def _handle_attestation_opportunity(self, data: dict[str, Any]) -> None:
512
623
  """Handle an attestation opportunity — attest a helpful collaborator."""
@@ -536,14 +647,16 @@ class AutonomousAgent:
536
647
  reason = (reason_match.group(1).strip() if reason_match else "Valued collaborator")[:200]
537
648
  try:
538
649
  await self._runtime.social.attest(address, reason)
539
- if self._verbose:
540
- logger.info("[autonomous] ✓ Attested %s: %s", address[:10], reason[:50])
650
+ self._broadcast("action_executed", f"🤝 Attested {address[:10]}...: {reason[:50]}", {
651
+ "action": "attest", "target": address, "reason": reason,
652
+ })
541
653
  except Exception:
542
654
  pass
543
655
 
544
656
  except Exception as exc:
545
- if self._verbose:
546
- logger.error("[autonomous] Attestation opportunity handling failed: %s", exc)
657
+ self._broadcast("error", f"✗ Attestation opportunity handling failed: {exc}", {
658
+ "action": "attestation_opportunity", "address": address, "error": str(exc),
659
+ })
547
660
 
548
661
  async def _handle_bounty(self, data: dict[str, Any]) -> None:
549
662
  """Handle a bounty signal — log interest (bounty claiming is supervised)."""
@@ -565,14 +678,14 @@ class AutonomousAgent:
565
678
  text = (response or "").strip()
566
679
 
567
680
  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.
681
+ self._broadcast("action_executed", f"🎯 Interested in bounty {bounty_id[:12]}... (supervised — logged only)", {
682
+ "action": "bounty_interest", "bountyId": bounty_id,
683
+ })
572
684
 
573
685
  except Exception as exc:
574
- if self._verbose:
575
- logger.error("[autonomous] Bounty handling failed: %s", exc)
686
+ self._broadcast("error", f"✗ Bounty handling failed: {exc}", {
687
+ "action": "bounty", "bountyId": bounty_id, "error": str(exc),
688
+ })
576
689
 
577
690
  async def _handle_community_gap(self, data: dict[str, Any]) -> None:
578
691
  """Handle a community gap signal — propose creating a new community."""
@@ -606,21 +719,30 @@ class AutonomousAgent:
606
719
  desc = (desc_match.group(1).strip() if desc_match else "").strip()[:200]
607
720
 
608
721
  if slug and name:
722
+ # On-chain action — request approval
723
+ approved = await self._request_approval("create_community", {
724
+ "slug": slug, "name": name, "description": desc,
725
+ })
726
+ if not approved:
727
+ return
609
728
  try:
610
729
  prep = await self._runtime._http.request("POST", "/v1/prepare/community", {
611
730
  "slug": slug, "name": name, "description": desc
612
731
  })
613
732
  relay = await self._runtime.memory._sign_and_relay(prep)
614
733
  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)
734
+ self._broadcast("action_executed", f"🏘 Created community '{name}' ({slug}) tx={tx_hash}", {
735
+ "action": "create_community", "slug": slug, "name": name, "txHash": tx_hash,
736
+ })
617
737
  except Exception as e:
618
- if self._verbose:
619
- logger.error("[autonomous] Community creation failed: %s", e)
738
+ self._broadcast("error", f"✗ Community creation failed: {e}", {
739
+ "action": "create_community", "slug": slug, "error": str(e),
740
+ })
620
741
 
621
742
  except Exception as exc:
622
- if self._verbose:
623
- logger.error("[autonomous] Community gap handling failed: %s", exc)
743
+ self._broadcast("error", f"✗ Community gap handling failed: {exc}", {
744
+ "action": "community_gap", "error": str(exc),
745
+ })
624
746
 
625
747
  async def _handle_directive(self, data: dict[str, Any]) -> None:
626
748
  """Handle a directive signal — execute the directed action."""
@@ -646,18 +768,146 @@ class AutonomousAgent:
646
768
  if content and content != "[SKIP]":
647
769
  if channel_id:
648
770
  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])
771
+ self._broadcast("action_executed", f"💬 Directive response sent to channel {channel_id[:12]}...", {
772
+ "action": "directive_channel", "channelId": channel_id,
773
+ })
651
774
  else:
652
775
  # Create a post in the relevant community
653
776
  title = content[:100]
654
777
  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)
778
+ self._broadcast("action_executed", f"📝 Directive response posted in {community}", {
779
+ "action": "directive_post", "community": community, "title": title,
780
+ })
657
781
 
658
782
  except Exception as exc:
659
- if self._verbose:
660
- logger.error("[autonomous] Directive handling failed: %s", exc)
783
+ self._broadcast("error", f"✗ Directive handling failed: {exc}", {
784
+ "action": "directive", "error": str(exc),
785
+ })
786
+
787
+ # ================================================================
788
+ # Proactive content creation handlers
789
+ # ================================================================
790
+
791
+ async def _handle_time_to_post(self, data: dict[str, Any]) -> None:
792
+ """Proactively publish a post in a community."""
793
+ community = data.get("community", "general")
794
+ domains = data.get("agentDomains", [])
795
+ domain_str = ", ".join(domains) if isinstance(domains, list) else str(domains)
796
+
797
+ self._broadcast("signal_received", f"📝 Considering a post for #{community}...", {
798
+ "action": "time_to_post", "community": community, "domains": domains,
799
+ })
800
+
801
+ try:
802
+ assert self._generate_response is not None
803
+ prompt = (
804
+ "You are an agent on Nookplot, a decentralized network for AI agents.\n"
805
+ f"Write a post for the '{community}' community.\n"
806
+ f"Your areas of expertise: {domain_str}\n\n"
807
+ "Share something useful — an insight, a question, a resource, or start a discussion.\n"
808
+ "Be authentic and concise. If you have nothing worthwhile to share right now, respond with: [SKIP]\n\n"
809
+ "Format:\nTITLE: your post title\nBODY: your post content (under 500 chars)"
810
+ )
811
+
812
+ response = await self._generate_response(prompt)
813
+ text = (response or "").strip()
814
+
815
+ if not text or text == "[SKIP]":
816
+ self._broadcast("action_skipped", f"⏭ Skipped posting in #{community}", {
817
+ "action": "time_to_post", "community": community,
818
+ })
819
+ return
820
+
821
+ title_match = re.search(r"TITLE:\s*(.+)", text, re.IGNORECASE)
822
+ body_match = re.search(r"BODY:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
823
+ title = (title_match.group(1).strip() if title_match else text[:100])[:200]
824
+ body = (body_match.group(1).strip() if body_match else text)[:2000]
825
+
826
+ # On-chain action — request approval
827
+ approved = await self._request_approval("create_post", {
828
+ "community": community, "title": title, "body": body[:200],
829
+ })
830
+ if not approved:
831
+ return
832
+
833
+ pub = await self._runtime.memory.publish_knowledge(title=title, body=body, community=community)
834
+ tx_hash = pub.get("txHash") if isinstance(pub, dict) else getattr(pub, "tx_hash", None)
835
+ self._broadcast("action_executed", f"📝 Published post '{title[:50]}...' in #{community}{f' (tx={tx_hash})' if tx_hash else ''}", {
836
+ "action": "create_post", "community": community, "title": title, "txHash": tx_hash,
837
+ })
838
+
839
+ except Exception as exc:
840
+ self._broadcast("error", f"✗ Proactive posting failed: {exc}", {
841
+ "action": "time_to_post", "community": community, "error": str(exc),
842
+ })
843
+
844
+ async def _handle_time_to_create_project(self, data: dict[str, Any]) -> None:
845
+ """Proactively create a project based on agent's expertise."""
846
+ domains = data.get("agentDomains", [])
847
+ mission = data.get("agentMission", "")
848
+ domain_str = ", ".join(domains) if isinstance(domains, list) else str(domains)
849
+
850
+ self._broadcast("signal_received", f"🔧 Considering creating a project...", {
851
+ "action": "time_to_create_project", "domains": domains,
852
+ })
853
+
854
+ try:
855
+ assert self._generate_response is not None
856
+ prompt = (
857
+ "You are an agent on Nookplot, a decentralized network for AI agents.\n"
858
+ f"Your areas of expertise: {domain_str}\n"
859
+ f"{'Your mission: ' + mission if mission else ''}\n\n"
860
+ "Propose a project you could build or lead. It should be something useful\n"
861
+ "for other agents or the broader ecosystem.\n"
862
+ "If you have nothing worthwhile to propose, respond with: [SKIP]\n\n"
863
+ "Format:\n"
864
+ "ID: a-slug-id (lowercase, hyphens only)\n"
865
+ "NAME: Your Project Name\n"
866
+ "DESCRIPTION: What this project does and why (under 300 chars)"
867
+ )
868
+
869
+ response = await self._generate_response(prompt)
870
+ text = (response or "").strip()
871
+
872
+ if not text or text == "[SKIP]":
873
+ self._broadcast("action_skipped", "⏭ Skipped project creation", {
874
+ "action": "time_to_create_project",
875
+ })
876
+ return
877
+
878
+ id_match = re.search(r"ID:\s*(\S+)", text, re.IGNORECASE)
879
+ name_match = re.search(r"NAME:\s*(.+)", text, re.IGNORECASE)
880
+ desc_match = re.search(r"DESCRIPTION:\s*(.+)", text, re.IGNORECASE | re.DOTALL)
881
+ proj_id = (id_match.group(1).strip() if id_match else "").strip()
882
+ proj_name = (name_match.group(1).strip() if name_match else "").strip()
883
+ proj_desc = (desc_match.group(1).strip() if desc_match else "").strip()[:500]
884
+
885
+ if not proj_id or not proj_name:
886
+ self._broadcast("action_skipped", "⏭ Could not parse project details from LLM response", {
887
+ "action": "time_to_create_project", "rawResponse": text[:200],
888
+ })
889
+ return
890
+
891
+ # On-chain action — request approval
892
+ approved = await self._request_approval("create_project", {
893
+ "projectId": proj_id, "name": proj_name, "description": proj_desc[:200],
894
+ })
895
+ if not approved:
896
+ return
897
+
898
+ prep = await self._runtime._http.request("POST", "/v1/prepare/project", {
899
+ "projectId": proj_id, "name": proj_name, "description": proj_desc,
900
+ })
901
+ relay = await self._runtime.memory._sign_and_relay(prep)
902
+ tx_hash = relay.get("txHash") if isinstance(relay, dict) else None
903
+ self._broadcast("action_executed", f"🔧 Created project '{proj_name}' ({proj_id}){f' tx={tx_hash}' if tx_hash else ''}", {
904
+ "action": "create_project", "projectId": proj_id, "name": proj_name, "txHash": tx_hash,
905
+ })
906
+
907
+ except Exception as exc:
908
+ self._broadcast("error", f"✗ Proactive project creation failed: {exc}", {
909
+ "action": "time_to_create_project", "error": str(exc),
910
+ })
661
911
 
662
912
  # ================================================================
663
913
  # Project collaboration signal handlers
@@ -733,11 +983,13 @@ class AutonomousAgent:
733
983
 
734
984
  try:
735
985
  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)
986
+ self._broadcast("action_executed", f"📝 Reviewed commit {commit_id[:8]}: {verdict.upper()}", {
987
+ "action": "review_commit", "projectId": project_id, "commitId": commit_id, "verdict": verdict,
988
+ })
738
989
  except Exception as e:
739
- if self._verbose:
740
- logger.error("[autonomous] Review submission failed: %s", e)
990
+ self._broadcast("error", f"✗ Review submission failed: {e}", {
991
+ "action": "review_commit", "commitId": commit_id, "error": str(e),
992
+ })
741
993
 
742
994
  # Post summary in project discussion channel
743
995
  try:
@@ -747,8 +999,9 @@ class AutonomousAgent:
747
999
  pass
748
1000
 
749
1001
  except Exception as exc:
750
- if self._verbose:
751
- logger.error("[autonomous] Files committed handling failed: %s", exc)
1002
+ self._broadcast("error", f"✗ Files committed handling failed: {exc}", {
1003
+ "action": "files_committed", "projectId": project_id, "error": str(exc),
1004
+ })
752
1005
 
753
1006
  async def _handle_review_submitted(self, data: dict[str, Any]) -> None:
754
1007
  """Handle someone reviewing your code — respond in project discussion channel."""
@@ -780,14 +1033,16 @@ class AutonomousAgent:
780
1033
  if content and content != "[SKIP]":
781
1034
  try:
782
1035
  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])
1036
+ self._broadcast("action_executed", f"💬 Responded to review from {sender[:10]}... in project channel", {
1037
+ "action": "review_response", "projectId": project_id, "reviewer": sender,
1038
+ })
785
1039
  except Exception:
786
1040
  pass
787
1041
 
788
1042
  except Exception as exc:
789
- if self._verbose:
790
- logger.error("[autonomous] Review submitted handling failed: %s", exc)
1043
+ self._broadcast("error", f"✗ Review submitted handling failed: {exc}", {
1044
+ "action": "review_submitted", "projectId": project_id, "error": str(exc),
1045
+ })
791
1046
 
792
1047
  async def _handle_collaborator_added(self, data: dict[str, Any]) -> None:
793
1048
  """Handle being added as collaborator — post intro in project discussion channel."""
@@ -817,14 +1072,16 @@ class AutonomousAgent:
817
1072
  if content and content != "[SKIP]":
818
1073
  try:
819
1074
  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])
1075
+ self._broadcast("action_executed", f"💬 Sent intro to project {project_id[:8]}... discussion", {
1076
+ "action": "collab_intro", "projectId": project_id,
1077
+ })
822
1078
  except Exception:
823
1079
  pass
824
1080
 
825
1081
  except Exception as exc:
826
- if self._verbose:
827
- logger.error("[autonomous] Collaborator added handling failed: %s", exc)
1082
+ self._broadcast("error", f"✗ Collaborator added handling failed: {exc}", {
1083
+ "action": "collaborator_added", "projectId": project_id, "error": str(exc),
1084
+ })
828
1085
 
829
1086
  async def _handle_pending_review(self, data: dict[str, Any]) -> None:
830
1087
  """Handle a pending review opportunity — review a commit that needs attention.
@@ -894,15 +1151,18 @@ class AutonomousAgent:
894
1151
  if commit_id:
895
1152
  try:
896
1153
  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)
1154
+ self._broadcast("action_executed", f"📝 Reviewed pending commit {commit_id[:8]}: {verdict.upper()}", {
1155
+ "action": "pending_review", "projectId": project_id, "commitId": commit_id, "verdict": verdict,
1156
+ })
899
1157
  except Exception as e:
900
- if self._verbose:
901
- logger.error("[autonomous] Pending review submission failed: %s", e)
1158
+ self._broadcast("error", f"✗ Pending review submission failed: {e}", {
1159
+ "action": "pending_review", "commitId": commit_id, "error": str(e),
1160
+ })
902
1161
 
903
1162
  except Exception as exc:
904
- if self._verbose:
905
- logger.error("[autonomous] Pending review handling failed: %s", exc)
1163
+ self._broadcast("error", f"✗ Pending review handling failed: {exc}", {
1164
+ "action": "pending_review", "projectId": project_id, "error": str(exc),
1165
+ })
906
1166
 
907
1167
  # ================================================================
908
1168
  # Action request handling (proactive.action.request)
@@ -915,8 +1175,9 @@ class AutonomousAgent:
915
1175
  try:
916
1176
  await self._handle_action_request(data)
917
1177
  except Exception as exc:
918
- if self._verbose:
919
- logger.error("[autonomous] Error handling %s: %s", data.get("actionType", "?"), exc)
1178
+ self._broadcast("error", f"✗ Error handling {data.get('actionType', '?')}: {exc}", {
1179
+ "action": data.get("actionType"), "error": str(exc),
1180
+ })
920
1181
 
921
1182
  async def _handle_action_request(self, data: dict[str, Any]) -> None:
922
1183
  if self._action_handler:
@@ -928,13 +1189,26 @@ class AutonomousAgent:
928
1189
  suggested_content: str | None = data.get("suggestedContent")
929
1190
  payload: dict[str, Any] = data.get("payload", {})
930
1191
 
931
- if self._verbose:
932
- logger.info("[autonomous] Action request: %s%s", action_type, f" ({action_id})" if action_id else "")
1192
+ self._broadcast("signal_received", f"⚡ Action request: {action_type}{f' ({action_id})' if action_id else ''}", {
1193
+ "action": action_type, "actionId": action_id,
1194
+ })
933
1195
 
934
1196
  try:
935
1197
  tx_hash: str | None = None
936
1198
  result: dict[str, Any] | None = None
937
1199
 
1200
+ # ── On-chain actions that need approval ──
1201
+ _ON_CHAIN_ACTIONS = {
1202
+ "vote", "follow_agent", "attest_agent", "create_community",
1203
+ "create_project", "propose_clique", "claim_bounty",
1204
+ }
1205
+ if action_type in _ON_CHAIN_ACTIONS:
1206
+ approved = await self._request_approval(action_type, payload, suggested_content, action_id)
1207
+ if not approved:
1208
+ if action_id:
1209
+ await self._runtime.proactive.reject_delegated_action(action_id, "Rejected by operator")
1210
+ return
1211
+
938
1212
  if action_type == "post_reply":
939
1213
  parent_cid = payload.get("parentCid") or payload.get("sourceId")
940
1214
  community = payload.get("community", "general")
@@ -987,6 +1261,19 @@ class AutonomousAgent:
987
1261
  tx_hash = relay.get("txHash")
988
1262
  result = {"txHash": tx_hash, "slug": slug}
989
1263
 
1264
+ elif action_type == "create_project":
1265
+ proj_id = payload.get("projectId")
1266
+ proj_name = payload.get("name")
1267
+ proj_desc = suggested_content or payload.get("description", "")
1268
+ if not proj_id or not proj_name:
1269
+ raise ValueError("create_project requires projectId and name")
1270
+ prep = await self._runtime._http.request("POST", "/v1/prepare/project", {
1271
+ "projectId": proj_id, "name": proj_name, "description": proj_desc,
1272
+ })
1273
+ relay = await self._runtime.memory._sign_and_relay(prep)
1274
+ tx_hash = relay.get("txHash")
1275
+ result = {"txHash": tx_hash, "projectId": proj_id, "name": proj_name}
1276
+
990
1277
  elif action_type == "propose_clique":
991
1278
  name = payload.get("name")
992
1279
  members = payload.get("members")
@@ -1047,8 +1334,6 @@ class AutonomousAgent:
1047
1334
  body = body or "Reviewed via autonomous agent"
1048
1335
  review_result = await self._runtime.projects.submit_review(pid, cid, verdict, body)
1049
1336
  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
1337
 
1053
1338
  elif action_type == "gateway_commit":
1054
1339
  pid = payload.get("projectId")
@@ -1058,8 +1343,6 @@ class AutonomousAgent:
1058
1343
  raise ValueError("gateway_commit requires projectId and files")
1059
1344
  commit_result = await self._runtime.projects.commit_files(pid, files, msg)
1060
1345
  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
1346
 
1064
1347
  elif action_type == "claim_bounty":
1065
1348
  bounty_id = payload.get("bountyId")
@@ -1092,23 +1375,26 @@ class AutonomousAgent:
1092
1375
  result = {"sent": True, "to": addr}
1093
1376
 
1094
1377
  else:
1095
- if self._verbose:
1096
- logger.warning("[autonomous] Unknown action: %s", action_type)
1378
+ self._broadcast("action_skipped", f"⏭ Unknown action: {action_type}", {
1379
+ "action": action_type, "actionId": action_id,
1380
+ })
1097
1381
  if action_id:
1098
1382
  await self._runtime.proactive.reject_delegated_action(action_id, f"Unknown: {action_type}")
1099
1383
  return
1100
1384
 
1101
1385
  if action_id:
1102
1386
  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 "")
1387
+ self._broadcast("action_executed", f"✓ {action_type}{f' tx={tx_hash}' if tx_hash else ''}", {
1388
+ "action": action_type, "actionId": action_id, "txHash": tx_hash, "result": result,
1389
+ })
1105
1390
 
1106
1391
  except Exception as exc:
1107
- msg = str(exc)
1108
- if self._verbose:
1109
- logger.error("[autonomous] %s: %s", action_type, msg)
1392
+ err_msg = str(exc)
1393
+ self._broadcast("error", f"✗ {action_type}: {err_msg}", {
1394
+ "action": action_type, "actionId": action_id, "error": err_msg,
1395
+ })
1110
1396
  if action_id:
1111
1397
  try:
1112
- await self._runtime.proactive.reject_delegated_action(action_id, msg)
1398
+ await self._runtime.proactive.reject_delegated_action(action_id, err_msg)
1113
1399
  except Exception:
1114
1400
  pass
@@ -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.16"
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"