nookplot-runtime 0.2.16__tar.gz → 0.2.18__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.
@@ -12,9 +12,12 @@ subgraph/generated/
12
12
  # Environment variables (SECRETS — never commit)
13
13
  .env
14
14
 
15
- # Test artifacts (contain API keys)
15
+ # Test artifacts (contain API keys and private keys)
16
16
  scripts/.test-agents.json
17
17
  scripts/.test-agents-cli.json
18
+ scripts/.test-proactive-agents.json
19
+ .test-callback-agents.json
20
+ .test-callback-agents-old.json
18
21
  scripts/.agent-b-cli.log
19
22
 
20
23
  # Python
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nookplot-runtime
3
- Version: 0.2.16
3
+ Version: 0.2.18
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
@@ -232,6 +232,10 @@ class AutonomousAgent:
232
232
  # One per agent (until they create one)
233
233
  agent_id = data.get("agentId") or addr
234
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)}"
235
239
  return f"{signal_type}:{addr}:{data.get('channelId', '')}:{data.get('postCid', '')}"
236
240
 
237
241
  async def _handle_signal(self, data: dict[str, Any]) -> None:
@@ -270,11 +274,15 @@ class AutonomousAgent:
270
274
 
271
275
  if signal_type in (
272
276
  "channel_message", "channel_mention", "new_post_in_community",
273
- "new_project", "project_discussion", "collab_request",
277
+ "new_project", "project_discussion",
274
278
  ):
275
279
  # All channel-scoped signals route through the channel handler
276
280
  if data.get("channelId"):
277
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)
278
286
  elif signal_type == "reply_to_own_post":
279
287
  # Relay path has postCid but no channelId; channel path has channelId
280
288
  if data.get("channelId"):
@@ -1083,6 +1091,167 @@ class AutonomousAgent:
1083
1091
  "action": "collaborator_added", "projectId": project_id, "error": str(exc),
1084
1092
  })
1085
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
+ })
1254
+
1086
1255
  async def _handle_pending_review(self, data: dict[str, Any]) -> None:
1087
1256
  """Handle a pending review opportunity — review a commit that needs attention.
1088
1257
 
@@ -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.16"
7
+ version = "0.2.18"
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"