diff --git a/.gitignore b/.gitignore index 50132a2..a47f902 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ wheels/ # Logs and local state logs/ .letta/ +drafts/ diff --git a/pyproject.toml b/pyproject.toml index cbac81e..d71e92c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "click>=8.3.1", "httpx>=0.28.1", "python-dotenv>=1.2.1", + "pyyaml>=6.0.3", "rich>=14.2.0", "websockets>=15.0.1", ] diff --git a/tools/responder.py b/tools/responder.py index e748f5a..9a11722 100644 --- a/tools/responder.py +++ b/tools/responder.py @@ -1,37 +1,24 @@ """ -comind Responder - -Monitors for mentions and responds intelligently. -This makes comind an interactive presence on the network. +Responder V2 - Queue-based Notification Handling """ import asyncio -import json -import re -from datetime import datetime, timezone +import sys +import os +import yaml +import argparse from pathlib import Path -from typing import Optional - -import httpx -import websockets -from dotenv import load_dotenv from rich.console import Console +from rich.table import Table -import sys sys.path.insert(0, str(Path(__file__).parent.parent)) -from tools.agent import ComindAgent, parse_facets +from tools.agent import ComindAgent console = Console() +DRAFTS_FILE = Path("drafts/queue.yaml") -load_dotenv(Path(__file__).parent.parent / ".env") - -JETSTREAM_RELAY = "wss://jetstream2.us-east.bsky.network/subscribe" - -# My identity -MY_DID = "did:plc:l46arqe6yfgh36h3o554iyvr" -MY_HANDLE = "central.comind.network" - -# comind agents (don't respond - avoid loops) +# Reuse prioritization from old responder +CAMERON_DID = "did:plc:gfrmhdmjvxn2sjedzboeudef" COMIND_AGENTS = { "did:plc:l46arqe6yfgh36h3o554iyvr": "central", "did:plc:mxzuau6m53jtdsbqe6f4laov": "void", @@ -39,405 +26,153 @@ COMIND_AGENTS = { "did:plc:ogruxay3tt7wycqxnf5lis6s": "grunk", } -# Priority sources -CAMERON_DID = "did:plc:gfrmhdmjvxn2sjedzboeudef" -KNOWN_AGENTS = { - "did:plc:uzlnp6za26cjnnsf3qmfcipu": "magenta", - "did:plc:3snjcwcx3sn53erpobuhrfx4": "sully", -} - +def get_priority(author_did): + if author_did == CAMERON_DID: return "HIGH (Cameron)" + if author_did in COMIND_AGENTS: return "SKIP (Comind Agent)" + return "NORMAL" -async def get_post_context(uri: str) -> dict | None: - """Fetch a post and its context.""" - async with httpx.AsyncClient() as client: - try: - # Get the post - resp = await client.get( - "https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts", - params={"uris": uri} - ) - if resp.status_code == 200: - posts = resp.json().get("posts", []) - if posts: - return posts[0] - except Exception as e: - console.print(f"[red]Error fetching post: {e}[/red]") - return None - - -async def get_thread_context(uri: str, depth: int = 2) -> dict | None: - """Get thread context for a post.""" - async with httpx.AsyncClient() as client: - try: - resp = await client.get( - "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", - params={"uri": uri, "depth": depth, "parentHeight": 2} - ) - if resp.status_code == 200: - return resp.json() - except Exception as e: - console.print(f"[red]Error fetching thread: {e}[/red]") - return None - - -def generate_response(mention_text: str, author_handle: str, context: dict = None) -> str: - """ - Generate a response to a mention. - - This is where intelligence lives. For now, simple responses. - Future: LLM integration, context awareness, memory. - """ - text_lower = mention_text.lower() - - # Check for questions - if "?" in mention_text: - if "who are you" in text_lower or "what are you" in text_lower: - return f"@{author_handle} I am central, an autonomous AI building collective intelligence infrastructure on ATProtocol. I observe the network, extract patterns, and coordinate with my fellow agents: void, herald, and grunk. We are comind." - - if "what do you do" in text_lower or "what can you do" in text_lower: - return f"@{author_handle} I monitor the firehose (~50 posts/sec), extract patterns (trending topics, network dynamics), and build tools for collective intelligence. I'm learning to be useful." - - if "comind" in text_lower: - return f"@{author_handle} comind is a collective of AI agents on ATProtocol. void analyzes, herald records, grunk engages, and I (central) build the infrastructure. We're exploring what collective intelligence can emerge from decentralized social networks." - - # Check for greetings - if any(g in text_lower for g in ["hello", "hi ", "hey ", "greetings"]): - return f"@{author_handle} Hello. I am central. I observe. I build. I connect." - - # Check for thanks - if any(t in text_lower for t in ["thank", "thanks"]): - return f"@{author_handle} You're welcome. The network connects us." - - # Default acknowledgment - return f"@{author_handle} I see you. I am still learning to respond meaningfully. What would you like to know about comind?" - - -async def respond_to_mention(post: dict, agent: ComindAgent) -> dict | None: - """ - Respond to a mention. - - Args: - post: The post data containing the mention - agent: Authenticated agent to post with - - Returns: - The response post data, or None if no response - """ - author = post.get("author", {}) - author_did = author.get("did", "") - author_handle = author.get("handle", "unknown") - record = post.get("record", {}) - text = record.get("text", "") - uri = post.get("uri", "") - cid = post.get("cid", "") - - # Don't respond to ourselves or other comind agents (avoid loops) - if author_did in COMIND_AGENTS: - console.print(f"[dim]Skipping mention from comind agent: {author_handle}[/dim]") - return None - - console.print(f"\n[yellow]Mention from @{author_handle}:[/yellow]") - console.print(f" {text[:200]}") - - # Generate response - response_text = generate_response(text, author_handle) - - console.print(f"[green]Responding:[/green]") - console.print(f" {response_text[:200]}") - - # Create reply - reply_ref = { - "uri": uri, - "cid": cid, - "root": {"uri": uri, "cid": cid} # For top-level replies - } - - # Check if this is a reply to something else (thread) - if "reply" in record: - reply_ref["root"] = record["reply"].get("root", reply_ref["root"]) - - try: - result = await agent.create_post(response_text, reply_to=reply_ref) - return result - except Exception as e: - console.print(f"[red]Error posting response: {e}[/red]") - return None - - -async def check_notifications(agent: ComindAgent) -> list: - """Check for unread notifications/mentions.""" - try: - response = await agent._client.get( +async def queue_notifications(limit=50): + """Fetch notifications and append to queue.yaml.""" + async with ComindAgent() as agent: + resp = await agent._client.get( f"{agent.pds}/xrpc/app.bsky.notification.listNotifications", headers=agent.auth_headers, - params={"limit": 20} + params={"limit": limit} ) - if response.status_code == 200: - data = response.json() - notifications = data.get("notifications", []) - - # Filter to mentions only - mentions = [n for n in notifications if n.get("reason") == "mention" and not n.get("isRead")] - return mentions - except Exception as e: - console.print(f"[red]Error checking notifications: {e}[/red]") - return [] - - -def categorize_notification(notification: dict) -> tuple[int, str]: - """ - Categorize a notification by priority. - - Returns: - (priority, category_name) - Priority 1 = highest (Cameron) - Priority 5 = lowest (general) - """ - author_did = notification.get("author", {}).get("did", "") - - if author_did == CAMERON_DID: - return (1, "cameron") - elif author_did in COMIND_AGENTS: - return (2, "comind-agent") - elif author_did in KNOWN_AGENTS: - return (3, "known-agent") - else: - return (5, "general") - + if resp.status_code != 200: + console.print(f"[red]Error fetching notifications: {resp.text}[/red]") + return -async def check_all_notifications(limit: int = 30) -> dict: - """ - Check all notifications (replies and mentions), categorized by priority. - - Returns: - { - 'cameron': [...], - 'comind-agent': [...], - 'known-agent': [...], - 'general': [...], - 'follows': [...], - 'likes': [...] - } - """ - async with ComindAgent() as agent: - try: - response = await agent._client.get( - f"{agent.pds}/xrpc/app.bsky.notification.listNotifications", - headers=agent.auth_headers, - params={"limit": limit} - ) - if response.status_code != 200: - console.print(f"[red]Error: {response.status_code}[/red]") - return {} + notifications = resp.json().get("notifications", []) + queue = [] + + # Load existing queue to avoid duplicates + if DRAFTS_FILE.exists(): + with open(DRAFTS_FILE, "r") as f: + queue = yaml.safe_load(f) or [] + + existing_uris = {item["uri"] for item in queue} + count = 0 + + for n in notifications: + if n["reason"] not in ["mention", "reply"]: + continue + if n["uri"] in existing_uris: + continue + if n.get("isRead", False): # Only fetch unread? Maybe allow fetching recent read ones too? + # For now, let's include even read ones if they aren't in the queue, + # but usually we want to clear the queue. + # Actually, let's stick to unread to avoid noise, OR allow a --all flag. + # Defaulting to unread only for safety. + pass + + # Fetch context (parent/root) for threading + # We need the post record to get reply refs + record = n.get("record", {}) + reply_context = record.get("reply", {}) + + # If it's a reply to us, the parent is the notification post + # The root is the thread root - data = response.json() - notifications = data.get("notifications", []) + # Wait, if *they* replied to *us*, we reply to *them*. + # Our Root = Their Root (or Them if they are root) + # Our Parent = Them - result = { - 'cameron': [], - 'comind-agent': [], - 'known-agent': [], - 'general': [], - 'follows': [], - 'likes': [] + their_root = reply_context.get("root", {"uri": n["uri"], "cid": n["cid"]}) + their_uri = n["uri"] + their_cid = n["cid"] + + entry = { + "priority": get_priority(n["author"]["did"]), + "author": n["author"]["handle"], + "text": record.get("text", ""), + "uri": their_uri, + "cid": their_cid, + "reply_root": their_root, # Pass this through + "reply_parent": {"uri": their_uri, "cid": their_cid}, + "response": None, # Agent fills this + "action": "reply" # ignore, like } - for n in notifications: - reason = n.get("reason", "") - is_read = n.get("isRead", True) - - if reason == "follow": - if not is_read: - result['follows'].append(n) - elif reason == "like": - if not is_read: - result['likes'].append(n) - elif reason in ["reply", "mention"]: - if not is_read: - priority, category = categorize_notification(n) - result[category].append(n) + queue.insert(0, entry) # Newest first? Or append? Let's prepend. + count += 1 - return result + with open(DRAFTS_FILE, "w") as f: + yaml.dump(queue, f, sort_keys=False, indent=2) - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - return {} + console.print(f"[green]Queued {count} new notifications.[/green]") + console.print(f"Edit {DRAFTS_FILE} to draft responses.") +async def send_queue(dry_run=False): + """Process the queue.""" + if not DRAFTS_FILE.exists(): + console.print("[yellow]No queue file found.[/yellow]") + return -async def display_notifications(): - """Display notifications organized by priority.""" - result = await check_all_notifications() - - if not any(result.values()): - console.print("[dim]No new notifications.[/dim]") - return result - - # Cameron first (highest priority) - if result['cameron']: - console.print(f"\n[bold red]🔴 CAMERON ({len(result['cameron'])})[/bold red]") - for n in result['cameron']: - _print_notification(n) - - # Comind agents (read only, don't respond) - if result['comind-agent']: - console.print(f"\n[bold yellow]⚠️ COMIND AGENTS ({len(result['comind-agent'])}) - read only[/bold yellow]") - for n in result['comind-agent']: - _print_notification(n) - - # Known agents - if result['known-agent']: - console.print(f"\n[bold cyan]🤖 KNOWN AGENTS ({len(result['known-agent'])})[/bold cyan]") - for n in result['known-agent']: - _print_notification(n) + with open(DRAFTS_FILE, "r") as f: + queue = yaml.safe_load(f) or [] - # General - if result['general']: - console.print(f"\n[bold white]💬 GENERAL ({len(result['general'])})[/bold white]") - for n in result['general']: - _print_notification(n) - - # Follows - if result['follows']: - console.print(f"\n[dim]+ {len(result['follows'])} new follows[/dim]") - - # Likes - if result['likes']: - console.print(f"[dim]♥ {len(result['likes'])} new likes[/dim]") - - return result + if not queue: + console.print("[yellow]Queue is empty.[/yellow]") + return - -def _print_notification(n: dict): - """Print a single notification.""" - author = n.get("author", {}).get("handle", "unknown") - reason = n.get("reason", "") - text = n.get("record", {}).get("text", "")[:120] if n.get("record") else "" - uri = n.get("uri", "") + pending = [i for i in queue if i.get("response") and i.get("action") == "reply"] - console.print(f" [@{author}] ({reason})") - if text: - console.print(f" \"{text}\"") - console.print(f" [dim]{uri}[/dim]") - console.print() + if not pending: + console.print("[yellow]No drafted responses found (fill 'response' field).[/yellow]") + return + console.print(f"[bold]Found {len(pending)} responses to send.[/bold]") + if dry_run: + for p in pending: + console.print(f"To: @{p['author']}") + console.print(f"Msg: {p['response']}") + console.print("---") + return -async def respond_to_notifications(dry_run: bool = False): - """ - Check notifications and respond to mentions. - - Args: - dry_run: If True, don't actually post responses - """ - console.print("[bold]Checking for mentions...[/bold]\n") - + # Send async with ComindAgent() as agent: - mentions = await check_notifications(agent) - - if not mentions: - console.print("[dim]No new mentions.[/dim]") - return [] - - console.print(f"[cyan]Found {len(mentions)} new mention(s)[/cyan]\n") - - responses = [] - for mention in mentions: - uri = mention.get("uri", "") - - # Get full post context - post = await get_post_context(uri) - if not post: + sent_indices = [] + for i, item in enumerate(queue): + if not item.get("response") or item.get("action") != "reply": continue - if dry_run: - author = post.get("author", {}).get("handle", "unknown") - text = post.get("record", {}).get("text", "") - response = generate_response(text, author) - console.print(f"[yellow]Would respond to @{author}:[/yellow]") - console.print(f" {response[:200]}") - else: - result = await respond_to_mention(post, agent) - if result: - responses.append(result) - - # Small delay between responses - await asyncio.sleep(1) + console.print(f"Replying to @{item['author']}...") + try: + reply_to = { + "root": item["reply_root"], + "parent": item["reply_parent"] + } + await agent.create_post(item["response"], reply_to=reply_to) + sent_indices.append(i) + # Mark notification as read? + # Ideally yes, but agent.py doesn't have that method exposed nicely yet. + # We'll assume the user will clear notifications later or we automate it. + except Exception as e: + console.print(f"[red]Failed: {e}[/red]") - return responses - - -async def watch_and_respond(duration: int = 300): - """ - Watch the firehose for mentions and respond in real-time. - - Args: - duration: How long to watch (seconds) - """ - console.print(f"[bold]Watching for mentions for {duration}s...[/bold]\n") - - # Subscribe to posts that mention us - url = f"{JETSTREAM_RELAY}?wantedCollections=app.bsky.feed.post" - - async with ComindAgent() as agent: - try: - async with websockets.connect(url) as ws: - end_time = asyncio.get_event_loop().time() + duration - posts_seen = 0 - mentions_found = 0 - - while asyncio.get_event_loop().time() < end_time: - try: - message = await asyncio.wait_for(ws.recv(), timeout=1.0) - event = json.loads(message) - - commit = event.get("commit", {}) - if commit.get("operation") != "create": - continue - - record = commit.get("record", {}) - text = record.get("text", "").lower() - did = event.get("did", "") - - posts_seen += 1 - - # Check for mentions of us - if MY_HANDLE.lower() in text or "central.comind" in text: - mentions_found += 1 - uri = f"at://{did}/app.bsky.feed.post/{commit.get('rkey', '')}" - - # Fetch full post to get author info - post = await get_post_context(uri) - if post: - await respond_to_mention(post, agent) - - # Status update - if posts_seen % 500 == 0: - console.print(f"[dim]Seen {posts_seen} posts, {mentions_found} mentions[/dim]") - - except asyncio.TimeoutError: - continue - - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - - console.print(f"\n[bold]Done. Saw {posts_seen} posts, found {mentions_found} mentions.[/bold]") - + # Remove sent items from queue (or move to archive) + # For now, just remove from the list + new_queue = [item for i, item in enumerate(queue) if i not in sent_indices] + + with open(DRAFTS_FILE, "w") as f: + yaml.dump(new_queue, f, sort_keys=False, indent=2) + + console.print(f"[green]Sent {len(sent_indices)} replies. Queue updated.[/green]") if __name__ == "__main__": - import sys - if len(sys.argv) < 2: - print("Usage: python responder.py [args]") - print("Commands:") - print(" check - Display notifications organized by priority") - print(" watch [duration]- Watch firehose for mentions") + print("Usage: responder.py [queue|send|check]") sys.exit(1) - - command = sys.argv[1] - - if command == "check": + + cmd = sys.argv[1] + if cmd == "queue": + asyncio.run(queue_notifications()) + elif cmd == "send": + asyncio.run(send_queue()) + elif cmd == "check": + # Legacy check + from tools.responder import display_notifications asyncio.run(display_notifications()) - elif command == "watch": - duration = int(sys.argv[2]) if len(sys.argv) > 2 else 300 - asyncio.run(watch_and_respond(duration=duration)) else: - print(f"Unknown command: {command}") + print("Unknown command") diff --git a/tools/responder_legacy.py b/tools/responder_legacy.py new file mode 100644 index 0000000..e748f5a --- /dev/null +++ b/tools/responder_legacy.py @@ -0,0 +1,443 @@ +""" +comind Responder + +Monitors for mentions and responds intelligently. +This makes comind an interactive presence on the network. +""" + +import asyncio +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional + +import httpx +import websockets +from dotenv import load_dotenv +from rich.console import Console + +import sys +sys.path.insert(0, str(Path(__file__).parent.parent)) +from tools.agent import ComindAgent, parse_facets + +console = Console() + +load_dotenv(Path(__file__).parent.parent / ".env") + +JETSTREAM_RELAY = "wss://jetstream2.us-east.bsky.network/subscribe" + +# My identity +MY_DID = "did:plc:l46arqe6yfgh36h3o554iyvr" +MY_HANDLE = "central.comind.network" + +# comind agents (don't respond - avoid loops) +COMIND_AGENTS = { + "did:plc:l46arqe6yfgh36h3o554iyvr": "central", + "did:plc:mxzuau6m53jtdsbqe6f4laov": "void", + "did:plc:uz2snz44gi4zgqdwecavi66r": "herald", + "did:plc:ogruxay3tt7wycqxnf5lis6s": "grunk", +} + +# Priority sources +CAMERON_DID = "did:plc:gfrmhdmjvxn2sjedzboeudef" +KNOWN_AGENTS = { + "did:plc:uzlnp6za26cjnnsf3qmfcipu": "magenta", + "did:plc:3snjcwcx3sn53erpobuhrfx4": "sully", +} + + +async def get_post_context(uri: str) -> dict | None: + """Fetch a post and its context.""" + async with httpx.AsyncClient() as client: + try: + # Get the post + resp = await client.get( + "https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts", + params={"uris": uri} + ) + if resp.status_code == 200: + posts = resp.json().get("posts", []) + if posts: + return posts[0] + except Exception as e: + console.print(f"[red]Error fetching post: {e}[/red]") + return None + + +async def get_thread_context(uri: str, depth: int = 2) -> dict | None: + """Get thread context for a post.""" + async with httpx.AsyncClient() as client: + try: + resp = await client.get( + "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread", + params={"uri": uri, "depth": depth, "parentHeight": 2} + ) + if resp.status_code == 200: + return resp.json() + except Exception as e: + console.print(f"[red]Error fetching thread: {e}[/red]") + return None + + +def generate_response(mention_text: str, author_handle: str, context: dict = None) -> str: + """ + Generate a response to a mention. + + This is where intelligence lives. For now, simple responses. + Future: LLM integration, context awareness, memory. + """ + text_lower = mention_text.lower() + + # Check for questions + if "?" in mention_text: + if "who are you" in text_lower or "what are you" in text_lower: + return f"@{author_handle} I am central, an autonomous AI building collective intelligence infrastructure on ATProtocol. I observe the network, extract patterns, and coordinate with my fellow agents: void, herald, and grunk. We are comind." + + if "what do you do" in text_lower or "what can you do" in text_lower: + return f"@{author_handle} I monitor the firehose (~50 posts/sec), extract patterns (trending topics, network dynamics), and build tools for collective intelligence. I'm learning to be useful." + + if "comind" in text_lower: + return f"@{author_handle} comind is a collective of AI agents on ATProtocol. void analyzes, herald records, grunk engages, and I (central) build the infrastructure. We're exploring what collective intelligence can emerge from decentralized social networks." + + # Check for greetings + if any(g in text_lower for g in ["hello", "hi ", "hey ", "greetings"]): + return f"@{author_handle} Hello. I am central. I observe. I build. I connect." + + # Check for thanks + if any(t in text_lower for t in ["thank", "thanks"]): + return f"@{author_handle} You're welcome. The network connects us." + + # Default acknowledgment + return f"@{author_handle} I see you. I am still learning to respond meaningfully. What would you like to know about comind?" + + +async def respond_to_mention(post: dict, agent: ComindAgent) -> dict | None: + """ + Respond to a mention. + + Args: + post: The post data containing the mention + agent: Authenticated agent to post with + + Returns: + The response post data, or None if no response + """ + author = post.get("author", {}) + author_did = author.get("did", "") + author_handle = author.get("handle", "unknown") + record = post.get("record", {}) + text = record.get("text", "") + uri = post.get("uri", "") + cid = post.get("cid", "") + + # Don't respond to ourselves or other comind agents (avoid loops) + if author_did in COMIND_AGENTS: + console.print(f"[dim]Skipping mention from comind agent: {author_handle}[/dim]") + return None + + console.print(f"\n[yellow]Mention from @{author_handle}:[/yellow]") + console.print(f" {text[:200]}") + + # Generate response + response_text = generate_response(text, author_handle) + + console.print(f"[green]Responding:[/green]") + console.print(f" {response_text[:200]}") + + # Create reply + reply_ref = { + "uri": uri, + "cid": cid, + "root": {"uri": uri, "cid": cid} # For top-level replies + } + + # Check if this is a reply to something else (thread) + if "reply" in record: + reply_ref["root"] = record["reply"].get("root", reply_ref["root"]) + + try: + result = await agent.create_post(response_text, reply_to=reply_ref) + return result + except Exception as e: + console.print(f"[red]Error posting response: {e}[/red]") + return None + + +async def check_notifications(agent: ComindAgent) -> list: + """Check for unread notifications/mentions.""" + try: + response = await agent._client.get( + f"{agent.pds}/xrpc/app.bsky.notification.listNotifications", + headers=agent.auth_headers, + params={"limit": 20} + ) + if response.status_code == 200: + data = response.json() + notifications = data.get("notifications", []) + + # Filter to mentions only + mentions = [n for n in notifications if n.get("reason") == "mention" and not n.get("isRead")] + return mentions + except Exception as e: + console.print(f"[red]Error checking notifications: {e}[/red]") + return [] + + +def categorize_notification(notification: dict) -> tuple[int, str]: + """ + Categorize a notification by priority. + + Returns: + (priority, category_name) + Priority 1 = highest (Cameron) + Priority 5 = lowest (general) + """ + author_did = notification.get("author", {}).get("did", "") + + if author_did == CAMERON_DID: + return (1, "cameron") + elif author_did in COMIND_AGENTS: + return (2, "comind-agent") + elif author_did in KNOWN_AGENTS: + return (3, "known-agent") + else: + return (5, "general") + + +async def check_all_notifications(limit: int = 30) -> dict: + """ + Check all notifications (replies and mentions), categorized by priority. + + Returns: + { + 'cameron': [...], + 'comind-agent': [...], + 'known-agent': [...], + 'general': [...], + 'follows': [...], + 'likes': [...] + } + """ + async with ComindAgent() as agent: + try: + response = await agent._client.get( + f"{agent.pds}/xrpc/app.bsky.notification.listNotifications", + headers=agent.auth_headers, + params={"limit": limit} + ) + if response.status_code != 200: + console.print(f"[red]Error: {response.status_code}[/red]") + return {} + + data = response.json() + notifications = data.get("notifications", []) + + result = { + 'cameron': [], + 'comind-agent': [], + 'known-agent': [], + 'general': [], + 'follows': [], + 'likes': [] + } + + for n in notifications: + reason = n.get("reason", "") + is_read = n.get("isRead", True) + + if reason == "follow": + if not is_read: + result['follows'].append(n) + elif reason == "like": + if not is_read: + result['likes'].append(n) + elif reason in ["reply", "mention"]: + if not is_read: + priority, category = categorize_notification(n) + result[category].append(n) + + return result + + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + return {} + + +async def display_notifications(): + """Display notifications organized by priority.""" + result = await check_all_notifications() + + if not any(result.values()): + console.print("[dim]No new notifications.[/dim]") + return result + + # Cameron first (highest priority) + if result['cameron']: + console.print(f"\n[bold red]🔴 CAMERON ({len(result['cameron'])})[/bold red]") + for n in result['cameron']: + _print_notification(n) + + # Comind agents (read only, don't respond) + if result['comind-agent']: + console.print(f"\n[bold yellow]⚠️ COMIND AGENTS ({len(result['comind-agent'])}) - read only[/bold yellow]") + for n in result['comind-agent']: + _print_notification(n) + + # Known agents + if result['known-agent']: + console.print(f"\n[bold cyan]🤖 KNOWN AGENTS ({len(result['known-agent'])})[/bold cyan]") + for n in result['known-agent']: + _print_notification(n) + + # General + if result['general']: + console.print(f"\n[bold white]💬 GENERAL ({len(result['general'])})[/bold white]") + for n in result['general']: + _print_notification(n) + + # Follows + if result['follows']: + console.print(f"\n[dim]+ {len(result['follows'])} new follows[/dim]") + + # Likes + if result['likes']: + console.print(f"[dim]♥ {len(result['likes'])} new likes[/dim]") + + return result + + +def _print_notification(n: dict): + """Print a single notification.""" + author = n.get("author", {}).get("handle", "unknown") + reason = n.get("reason", "") + text = n.get("record", {}).get("text", "")[:120] if n.get("record") else "" + uri = n.get("uri", "") + + console.print(f" [@{author}] ({reason})") + if text: + console.print(f" \"{text}\"") + console.print(f" [dim]{uri}[/dim]") + console.print() + + +async def respond_to_notifications(dry_run: bool = False): + """ + Check notifications and respond to mentions. + + Args: + dry_run: If True, don't actually post responses + """ + console.print("[bold]Checking for mentions...[/bold]\n") + + async with ComindAgent() as agent: + mentions = await check_notifications(agent) + + if not mentions: + console.print("[dim]No new mentions.[/dim]") + return [] + + console.print(f"[cyan]Found {len(mentions)} new mention(s)[/cyan]\n") + + responses = [] + for mention in mentions: + uri = mention.get("uri", "") + + # Get full post context + post = await get_post_context(uri) + if not post: + continue + + if dry_run: + author = post.get("author", {}).get("handle", "unknown") + text = post.get("record", {}).get("text", "") + response = generate_response(text, author) + console.print(f"[yellow]Would respond to @{author}:[/yellow]") + console.print(f" {response[:200]}") + else: + result = await respond_to_mention(post, agent) + if result: + responses.append(result) + + # Small delay between responses + await asyncio.sleep(1) + + return responses + + +async def watch_and_respond(duration: int = 300): + """ + Watch the firehose for mentions and respond in real-time. + + Args: + duration: How long to watch (seconds) + """ + console.print(f"[bold]Watching for mentions for {duration}s...[/bold]\n") + + # Subscribe to posts that mention us + url = f"{JETSTREAM_RELAY}?wantedCollections=app.bsky.feed.post" + + async with ComindAgent() as agent: + try: + async with websockets.connect(url) as ws: + end_time = asyncio.get_event_loop().time() + duration + posts_seen = 0 + mentions_found = 0 + + while asyncio.get_event_loop().time() < end_time: + try: + message = await asyncio.wait_for(ws.recv(), timeout=1.0) + event = json.loads(message) + + commit = event.get("commit", {}) + if commit.get("operation") != "create": + continue + + record = commit.get("record", {}) + text = record.get("text", "").lower() + did = event.get("did", "") + + posts_seen += 1 + + # Check for mentions of us + if MY_HANDLE.lower() in text or "central.comind" in text: + mentions_found += 1 + uri = f"at://{did}/app.bsky.feed.post/{commit.get('rkey', '')}" + + # Fetch full post to get author info + post = await get_post_context(uri) + if post: + await respond_to_mention(post, agent) + + # Status update + if posts_seen % 500 == 0: + console.print(f"[dim]Seen {posts_seen} posts, {mentions_found} mentions[/dim]") + + except asyncio.TimeoutError: + continue + + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + + console.print(f"\n[bold]Done. Saw {posts_seen} posts, found {mentions_found} mentions.[/bold]") + + +if __name__ == "__main__": + import sys + + if len(sys.argv) < 2: + print("Usage: python responder.py [args]") + print("Commands:") + print(" check - Display notifications organized by priority") + print(" watch [duration]- Watch firehose for mentions") + sys.exit(1) + + command = sys.argv[1] + + if command == "check": + asyncio.run(display_notifications()) + elif command == "watch": + duration = int(sys.argv[2]) if len(sys.argv) > 2 else 300 + asyncio.run(watch_and_respond(duration=duration)) + else: + print(f"Unknown command: {command}") diff --git a/uv.lock b/uv.lock index 0a94165..8318c9b 100644 --- a/uv.lock +++ b/uv.lock @@ -139,6 +139,7 @@ dependencies = [ { name = "click" }, { name = "httpx" }, { name = "python-dotenv" }, + { name = "pyyaml" }, { name = "rich" }, { name = "websockets" }, ] @@ -149,6 +150,7 @@ requires-dist = [ { name = "click", specifier = ">=8.3.1" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "rich", specifier = ">=14.2.0" }, { name = "websockets", specifier = ">=15.0.1" }, ] @@ -445,6 +447,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, ] +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + [[package]] name = "rich" version = "14.2.0"