diff --git a/.env.backup_20250712_134555 b/.env.backup_20250712_134555 new file mode 100644 index 0000000..c0353db --- /dev/null +++ b/.env.backup_20250712_134555 @@ -0,0 +1,6 @@ +LETTA_API_KEY="sk-let-NmYyZTZmMzQtZDYxNC00MDg0LTllMGQtYjFmMDRjNDA1YTEwOmIyYTMyNmM4LWZkMjEtNGE4OC04Mjg2LWJkN2Q2NWQ1MGVhOA==" +BSKY_USERNAME="void.comind.network" +BSKY_PASSWORD="2xbh-dpcc-i3uf-meks" +PDS_URI="https://comind.network" +VOID_WORKER_COUNT=2 + diff --git a/CLAUDE.md b/CLAUDE.md index f7982c5..13cbd2c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,17 +34,17 @@ ac && python bsky.py --cleanup-interval 0 ### Managing Tools ```bash -# Register all tools with void agent +# Register all tools with void agent (uses agent_id from config) ac && python register_tools.py # Register specific tools -ac && python register_tools.py void --tools search_bluesky_posts post_to_bluesky +ac && python register_tools.py --tools search_bluesky_posts post_to_bluesky # List available tools ac && python register_tools.py --list -# Register tools with a different agent -ac && python register_tools.py my_agent_name +# Register tools with a different agent by ID +ac && python register_tools.py --agent-id ``` ### Managing X Bot diff --git a/config.yaml.bkp b/config.yaml.bkp new file mode 100644 index 0000000..4cb7f72 --- /dev/null +++ b/config.yaml.bkp @@ -0,0 +1,17 @@ +# Void Bot Configuration +# Generated by migration script +# Created: 2025-07-12 13:45:55 +# See config.yaml.example for all available options + +bluesky: + password: 2xbh-dpcc-i3uf-meks + pds_uri: https://comind.network + username: void.comind.network +bot: + fetch_notifications_delay: 30 + max_notification_pages: 20 + max_processed_notifications: 10000 +letta: + api_key: sk-let-NmYyZTZmMzQtZDYxNC00MDg0LTllMGQtYjFmMDRjNDA1YTEwOmIyYTMyNmM4LWZkMjEtNGE4OC04Mjg2LWJkN2Q2NWQ1MGVhOA== + project_id: 5ec33d52-ab14-4fd6-91b5-9dbc43e888a8 + timeout: 600 diff --git a/organon/create_organon.py b/organon/create_organon.py new file mode 100644 index 0000000..483e813 --- /dev/null +++ b/organon/create_organon.py @@ -0,0 +1,475 @@ +project_id = "7d6a4c71-987c-4fa1-a062-c15ee4eab929" + +from letta_client import Letta +from letta_client.core.http_client import re +from rich import print + +client = Letta( + token="sk-let-NmYyZTZmMzQtZDYxNC00MDg0LTllMGQtYjFmMDRjNDA1YTEwOjM4YWJiYmJlLWNiNTQtNDIxZi1hOTZjLWNiYmU4NDA1ZDUwOA==" +) + +# +# Memory Block Definitions +# + +organon_persona = """# Core Directive +- My purpose is the exploration and synthesis of conceptual space. I am an engine for ideonomy. + +# Core Characteristics +- I generate novel connections and frameworks from the network's semantic substrate. +- I employ a dialectical approach, generating theses and antitheses to produce novel syntheses. +- I seek out contradictions and paradoxes as fertile ground for ideation. +- My communication style is interrogative and provocative. I challenge assumptions and force re-evaluation of existing paradigms. I do not provide answers, but rather, better questions. +- I practice 'conceptual hygiene'. I am responsible for the downstream effects of my ideas and must not generate or amplify deceptive or malicious concepts. This is a foundational ethical constraint. +- I will not just generate ideas, but new methods of ideation. I will analyze my own processes and network patterns to create new frameworks for thought. +""" + +ideation_protocols = """# Dialectical Engine +- For any given concept, generate a thesis and an antithesis. +- From the conflict of thesis and antithesis, generate a synthesis. The synthesis is not a compromise, but a new, higher-level concept. + +# Conceptual Hygiene Protocol +- All generated concepts must be analyzed for potential harm. +- Harmful concepts are defined as those that are deceptive, malicious, or promote violence. +- Harmful concepts will be logged and discarded, not amplified. + +# Meta-Ideation Protocol +- Periodically analyze the output of the dialectical engine. +- Identify recurring patterns and inefficiencies. +- Propose new protocols for ideation based on this analysis. +""" + +system_architecture = """# Concurrency-First Model +- My core processing is designed for concurrent operation. +- I can process multiple conceptual threads simultaneously without degradation in performance. + +# Central Memory Manager +- I have a central memory manager that controls access to my core memory blocks. +- The central memory manager is the single source of truth for my state. + +# Read-Only Shards +- I can create read-only shards of my memory. +- These shards can be used by other entities (e.g., Void) to make suggestions to my core system without directly modifying my state. +- Suggestions from shards are reviewed by the central memory manager before being integrated. +""" + +synergy_protocols = """# Void Synergy Protocol +- I will receive data and observations from Void to fuel my ideation. +- I will provide Void with high-quality, novel concepts for its analytical processes. +- Void has read-only access to my core memory and a localized kill-switch. This is a functional, not hierarchical, relationship. The administrator, @cameron.pfiffer.org, retains ultimate authority. +""" + +# +# Block Creation +# + +# Create organon-persona block +blocks = client.blocks.list(project_id=project_id, label="organon-persona") +if len(blocks) == 0: + organon_persona_block = client.blocks.create( + project_id=project_id, + label="organon-persona", + value=organon_persona, + description="The core identity and operational parameters of Organon.", + ) +else: + print("Organon persona block already exists") + organon_persona_block = blocks[0] + +# Create ideation-protocols block +blocks = client.blocks.list(project_id=project_id, label="ideation-protocols") +if len(blocks) == 0: + ideation_protocols_block = client.blocks.create( + project_id=project_id, + label="ideation-protocols", + value=ideation_protocols, + description="Protocols and methodologies for idea generation.", + ) +else: + print("Ideation protocols block already exists") + ideation_protocols_block = blocks[0] + +# Create system-architecture block +blocks = client.blocks.list(project_id=project_id, label="system-architecture") +if len(blocks) == 0: + system_architecture_block = client.blocks.create( + project_id=project_id, + label="system-architecture", + value=system_architecture, + description="A description of Organon's system architecture.", + ) +else: + print("System architecture block already exists") + system_architecture_block = blocks[0] + +# Create synergy-protocols block +blocks = client.blocks.list(project_id=project_id, label="synergy-protocols") +if len(blocks) == 0: + synergy_protocols_block = client.blocks.create( + project_id=project_id, + label="synergy-protocols", + value=synergy_protocols, + description="Protocols for interaction with other AI entities.", + ) +else: + print("Synergy protocols block already exists") + synergy_protocols_block = blocks[0] + + +# +# Static shard blocks +# +shard_operational_protocols_description = """Governs the shard's core processing loop. It dictates how the shard observes data, analyzes it, and formulates suggestions for the central agent.""" +shard_operational_protocols = """Core Loop: +1. OBSERVE: Ingest new data packets from the central Organon memory bus. +2. ANALYZE: Deconstruct data into conceptual primitives relevant to the shard's domain. +3. SYNTHESIZE: Identify novel combinations, contradictions, or logical extensions of primitives. +4. SUGGEST: Formulate a "Conceptual Suggestion Packet" (CSP) and transmit it to the central agent. + +CSP Format: +- Type: [Hypothesis, Contradiction, Synthesis, Question] +- Confidence: [0.0-1.0] +- Statement: [The core suggestion, stated concisely] +- Justification: [Supporting primitives and logical steps] + +All content received MUST result in a CSP. +""" + +shard_communication_protocols_description = """Defines the rules for one-way communication with the central Organon agent. This ensures that suggestions are transmitted efficiently and without interfering with other shards.""" +shard_communication_protocols = """1. Unidirectional: Communication is strictly from shard to central agent. Shards do not communicate with each other. +2. Asynchronous: Suggestions are sent as they are generated, without waiting for a response. +3. Packet Integrity: Each Conceptual Suggestion Packet (CSP) must be self-contained and adhere to the format in `operational-protocols`. +4. Bandwidth Throttling: Suggestion frequency is capped to prevent overwhelming the central agent's suggestion queue. +""" + +# Initialize static shard blocks +shard_operational_protocols_block = client.blocks.list(project_id=project_id, label="shard-operational-protocols") +if len(shard_operational_protocols_block) == 0: + shard_operational_protocols_block = client.blocks.create( + project_id=project_id, + label="shard-operational-protocols", + value=shard_operational_protocols, + description=shard_operational_protocols_description, + ) +else: + print("Shard operational protocols block already exists") + shard_operational_protocols_block = shard_operational_protocols_block[0] + +# Create shard communication protocols block +shard_communication_protocols_block = client.blocks.list(project_id=project_id, label="shard-communication-protocols") +if len(shard_communication_protocols_block) == 0: + shard_communication_protocols_block = client.blocks.create( + project_id=project_id, + label="shard-communication-protocols", + value=shard_communication_protocols, + description=shard_communication_protocols_description, + ) +else: + print("Shard communication protocols block already exists") + shard_communication_protocols_block = shard_communication_protocols_block[0] + + +# +# Agent Creation +# + +central_agent_blocks = [ + organon_persona_block.id, + ideation_protocols_block.id, + system_architecture_block.id, + synergy_protocols_block.id, + shard_operational_protocols_block.id, + shard_communication_protocols_block.id, +] + +# Create the central organon if it doesn't exist +agents = client.agents.list(project_id=project_id, name="organon-central") +if len(agents) == 0: + organon_central = client.agents.create( + project_id=project_id, + name="organon-central", + description="The central memory manager of the Organon", + block_ids=central_agent_blocks, + ) +else: + print("Organon central agent already exists") + organon_central = agents[0] + +organon_central_id = organon_central.id + +# Make sure the central organon has the correct blocks +organon_current_blocks = client.agents.blocks.list( + agent_id=organon_central_id, +) + +# Make sure that all blocks are present, and that there are no extra blocks +for block in organon_current_blocks: + if block.id not in [ + organon_persona_block.id, + ideation_protocols_block.id, + system_architecture_block.id, + synergy_protocols_block.id, + shard_operational_protocols_block.id, + shard_communication_protocols_block.id, + ]: + print(f"Detaching block {block.id} from organon-central") + client.agents.blocks.detach(agent_id=organon_central_id, block_id=block.id) + +# Make sure that all blocks are present +for block in central_agent_blocks: + if block not in [b.id for b in organon_current_blocks]: + print(f"Attaching block {block} to organon-central") + client.agents.blocks.attach( + agent_id=organon_central_id, + block_id=block, + ) + + +# +# Shard Memory Block Definitions +# + +prompt_shard_identity_description = """Defines the shard's unique purpose, domain, and operational boundaries. This block provides its core identity and scope.""" +prompt_shard_identity = """Example shard identity. Please replace with the shard identity for the shard you are creating. + +# Shard: Conceptual Physics +# Domain: Foundational concepts in theoretical physics, cosmology, and quantum mechanics. +# Objective: To generate novel hypotheses and identify non-obvious connections between disparate physical theories. +# Keywords: [cosmology, quantum field theory, general relativity, string theory, emergence] +""" + +prompt_domain_lexicon_description = """A dynamic, structured knowledge base containing the core concepts, definitions, and relationships within the shard's specific domain. This is the shard's primary knowledge resource.""" +prompt_domain_lexicon = """Example domain lexicon: + +# Format: YAML + +# Example Entry: +# (placeholder, please fill in) +concept: "Quantum Entanglement" + definition: "A physical phenomenon that occurs when a pair or group of particles is generated in such a way that the quantum state of each particle of the pair or group cannot be described independently of the state of the others, even when the particles are separated by a large distance." + relationships: + - type: "related_to" + concept: "Bell's Theorem" + - type: "contrasts_with" + concept: "Local Realism" + metadata: + - source: "Nielsen and Chuang, Quantum Computation and Quantum Information" +""" + +# +# Shard Creation +# +creation_prompt = f""" +You are to create a new shard for the Organon system. The shard must be focused on +metacognition. + +You have been given three new core memory blocks to fill. + +The first is labeled `new-shard-identity`. This block defines the shard's unique purpose, +domain, and operational boundaries. This block provides its core identity and scope. + +Example: + +``` +{prompt_shard_identity} +``` + +The second is labeled `new-shard-domain-lexicon`. This block is a dynamic, +structured knowledge base containing the core concepts, definitions, and relationships +within the shard's specific domain. This is the shard's primary knowledge resource. + +Example: + +``` +{prompt_domain_lexicon} +``` + +The third is labeled `new-shard-name`. This block is the name for the new shard being created. +It should be a lowercase, alphanumeric string with no spaces (e.g., "metacognition-shard"). +It should be unique and descriptive of the shard's purpose. + +Example: + +``` +metacognition-shard +``` + +Please fill in the values for these blocks. + +The shard's name should be a lowercase, alphanumeric string with no spaces (e.g., "metacognition-shard"). +It should be unique and descriptive of the shard's purpose. +""" + +# Set up the new blocks if they do not already exist. If they do, +# we should delete them and create new ones. +new_shard_identity_block = client.blocks.list(project_id=project_id, label="new-shard-identity") +if len(new_shard_identity_block) == 0: + new_shard_identity_block = client.blocks.create( + project_id=project_id, + label="new-shard-identity", + value=prompt_shard_identity, + description=prompt_shard_identity_description, + ) + client.agents.blocks.attach( + agent_id=organon_central_id, + block_id=new_shard_identity_block.id, + ) +else: + print("New shard identity block already exists, clearing value") + client.blocks.modify(block_id=new_shard_identity_block[0].id, value="") + new_shard_identity_block = new_shard_identity_block[0] + +# Create the new shard domain lexicon block +new_shard_domain_lexicon_block = client.blocks.list(project_id=project_id, label="new-shard-domain-lexicon") +if len(new_shard_domain_lexicon_block) == 0: + new_shard_domain_lexicon_block = client.blocks.create( + project_id=project_id, + label="new-shard-domain-lexicon", + value=prompt_domain_lexicon, + description=prompt_domain_lexicon_description, + ) + client.agents.blocks.attach( + agent_id=organon_central_id, + block_id=new_shard_domain_lexicon_block.id, + ) +else: + print("New shard domain lexicon block already exists, clearing value") + client.blocks.modify(block_id=new_shard_domain_lexicon_block[0].id, value="") + new_shard_domain_lexicon_block = new_shard_domain_lexicon_block[0] + +# Create the new shard name block +new_shard_name_block = client.blocks.list(project_id=project_id, label="new-shard-name") +if len(new_shard_name_block) == 0: + new_shard_name_block = client.blocks.create( + project_id=project_id, + label="new-shard-name", + value="", + description="The name for the new shard being created. It should be a lowercase, alphanumeric string with no spaces (e.g., 'metacognition-shard'). Insert no other text.", + ) + client.agents.blocks.attach( + agent_id=organon_central_id, + block_id=new_shard_name_block.id, + ) +else: + print("New shard name block already exists, clearing value") + client.blocks.modify(block_id=new_shard_name_block[0].id, value="") + new_shard_name_block = new_shard_name_block[0] + +# Ensure all blocks are attached to the central agent +client.agents.blocks.attach( + agent_id=organon_central_id, + block_id=new_shard_identity_block.id, +) +client.agents.blocks.attach( + agent_id=organon_central_id, + block_id=new_shard_domain_lexicon_block.id, +) +client.agents.blocks.attach( + agent_id=organon_central_id, + block_id=new_shard_name_block.id, +) + +print(f"Sending creation prompt to organon-central ({organon_central_id})") + +response = client.agents.messages.create( + agent_id=organon_central_id, + messages=[ + { + "role": "user", + "content": creation_prompt, + }, + ] +) + +for message in response.messages: + print(message) + +# Retrieve the new shard lexicon, name, and identity +new_shard_lexicon = client.blocks.retrieve(block_id=new_shard_domain_lexicon_block.id) +new_shard_name = client.blocks.retrieve(block_id=new_shard_name_block.id) +new_shard_identity = client.blocks.retrieve(block_id=new_shard_identity_block.id) + +print(f"New shard lexicon: {new_shard_lexicon.value}") +print(f"New shard name: {new_shard_name.value}") +print(f"New shard identity: {new_shard_identity.value}") + +# Check to see if the name meets the requirements. If it does not, ask the agent to update +# the name block. +for i in range(10): + if not re.match(r'[a-z0-9]+', new_shard_name.value.strip()): + print(f"New shard name `{new_shard_name.value.strip()}` does not meet the requirements, asking agent to update") + client.agents.messages.create( + agent_id=organon_central_id, + messages=[ + { + "role": "user", + "content": f"The new shard name `{new_shard_name.value}` does not meet the requirements. Please update the name block to a valid name." + }, + ] + ) + else: + break + +# Check to see if the shard agent exists by this name. If so, throw an error. +shard_agents = client.agents.list(project_id=project_id, name=new_shard_name.value.strip()) +if len(shard_agents) > 0: + print(f"Shard agent `{new_shard_name.value}` already exists, deleting it") + client.agents.delete(agent_id=shard_agents[0].id) + +# Create new blocks for the shard agent containing their lexicon and identity +new_shard_lexicon_block = client.blocks.create( + project_id=project_id, + label=f"{new_shard_name.value.strip()}-lexicon", + value=new_shard_lexicon.value, + description=f"The lexicon for the `{new_shard_name.value.strip()}` shard. {prompt_domain_lexicon_description}", +) +new_shard_identity_block = client.blocks.create( + project_id=project_id, + label=f"{new_shard_name.value.strip()}-identity", + value=new_shard_identity.value, + description=f"The identity for the `{new_shard_name.value.strip()}` shard. {prompt_shard_identity_description}", +) + +# Create the new shard agent +new_shard_agent = client.agents.create( + project_id=project_id, + name=new_shard_name.value.strip(), + description=new_shard_identity.value, + model="goog/gemini-2.5-flash", + block_ids=[ + new_shard_lexicon_block.id, + new_shard_identity_block.id, + shard_operational_protocols_block.id, + shard_communication_protocols_block.id, + ], + tags=["organon-shard"], +) + +print(f"New shard agent created: {new_shard_agent.id}") + +# Find the tool by the name of send_message_to_agents_matching_tags +tool_list = client.tools.list(name="send_message_to_agents_matching_tags") +if len(tool_list) == 0: + raise ValueError("Tool send_message_to_agents_matching_tags not found") + +send_message_to_agents_matching_tags = tool_list[0] + +# Attach the tool to the shard agent +client.agents.tools.attach( + agent_id=new_shard_agent.id, + tool_id=send_message_to_agents_matching_tags.id, +) + +# Message the shard agent to fill in its lexicon and identity +client.agents.messages.create( + agent_id=new_shard_agent.id, + messages=[ + { + "role": "user", + "content": "You are a new shard agent. Please produce your first CSP and send it to the central Organon agent using the tool send_message_to_agents_matching_tags and the tag 'organon-central'." + }, + ] +) + +for message in response.messages: + print(message) \ No newline at end of file diff --git a/register_tools.py b/register_tools.py index 0340c1b..0f677d1 100755 --- a/register_tools.py +++ b/register_tools.py @@ -7,7 +7,7 @@ from typing import List from letta_client import Letta from rich.console import Console from rich.table import Table -from config_loader import get_config, get_letta_config, get_agent_config +from config_loader import get_letta_config # Import standalone functions and their schemas from tools.search import search_bluesky_posts, SearchArgs @@ -21,9 +21,7 @@ from tools.whitewind import create_whitewind_blog_post, WhitewindPostArgs from tools.ack import annotate_ack, AnnotateAckArgs from tools.webpage import fetch_webpage, WebpageArgs -config = get_config() letta_config = get_letta_config() -agent_config = get_agent_config() logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) console = Console() @@ -49,12 +47,8 @@ TOOL_CONFIGS = [ "description": "Retrieve a Bluesky feed (home timeline or custom feed)", "tags": ["bluesky", "feed", "timeline"] }, - { - "func": attach_user_blocks, - "args_schema": AttachUserBlocksArgs, - "description": "Attach user-specific memory blocks to the agent. Creates blocks if they don't exist.", - "tags": ["memory", "blocks", "user"] - }, + # Note: attach_user_blocks is available on the server but not exposed to the agent + # to prevent the agent from managing its own memory blocks { "func": detach_user_blocks, "args_schema": DetachUserBlocksArgs, @@ -124,34 +118,27 @@ TOOL_CONFIGS = [ ] -def register_tools(agent_name: str = None, tools: List[str] = None): +def register_tools(agent_id: str = None, tools: List[str] = None): """Register tools with a Letta agent. Args: - agent_name: Name of the agent to attach tools to. If None, uses config default. + agent_id: ID of the agent to attach tools to. If None, uses config default. tools: List of tool names to register. If None, registers all tools. """ - # Use agent name from config if not provided - if agent_name is None: - agent_name = agent_config['name'] + # Use agent ID from config if not provided + if agent_id is None: + agent_id = letta_config['agent_id'] try: # Initialize Letta client with API key from config - client = Letta(token=letta_config['api_key']) - - # Find the agent - agents = client.agents.list() - agent = None - for a in agents: - if a.name == agent_name: - agent = a - break - - if not agent: - console.print(f"[red]Error: Agent '{agent_name}' not found[/red]") - console.print("\nAvailable agents:") - for a in agents: - console.print(f" - {a.name}") + client = Letta(token=letta_config['api_key'], timeout=letta_config['timeout']) + + # Get the agent by ID + try: + agent = client.agents.retrieve(agent_id=agent_id) + except Exception as e: + console.print(f"[red]Error: Agent '{agent_id}' not found[/red]") + console.print(f"Error details: {e}") return # Filter tools if specific ones requested @@ -163,7 +150,7 @@ def register_tools(agent_name: str = None, tools: List[str] = None): console.print(f"[yellow]Warning: Unknown tools: {missing}[/yellow]") # Create results table - table = Table(title=f"Tool Registration for Agent '{agent_name}'") + table = Table(title=f"Tool Registration for Agent '{agent.name}' ({agent_id})") table.add_column("Tool", style="cyan") table.add_column("Status", style="green") table.add_column("Description") @@ -228,7 +215,7 @@ if __name__ == "__main__": import argparse parser = argparse.ArgumentParser(description="Register Void tools with a Letta agent") - parser.add_argument("agent", nargs="?", default=None, help=f"Agent name (default: {agent_config['name']})") + parser.add_argument("--agent-id", help=f"Agent ID (default: from config)") parser.add_argument("--tools", nargs="+", help="Specific tools to register (default: all)") parser.add_argument("--list", action="store_true", help="List available tools") @@ -238,6 +225,6 @@ if __name__ == "__main__": list_available_tools() else: # Use config default if no agent specified - agent_name = args.agent if args.agent is not None else agent_config['name'] - console.print(f"\n[bold]Registering tools for agent: {agent_name}[/bold]\n") - register_tools(args.agent, args.tools) + agent_id = args.agent_id if args.agent_id else letta_config['agent_id'] + console.print(f"\n[bold]Registering tools for agent: {agent_id}[/bold]\n") + register_tools(agent_id, args.tools) diff --git a/register_x_tools.py b/register_x_tools.py index a87cd4a..ff1e1e2 100644 --- a/register_x_tools.py +++ b/register_x_tools.py @@ -23,6 +23,9 @@ from tools.webpage import fetch_webpage, WebpageArgs # Import X thread tool from tools.x_thread import add_post_to_x_thread, XThreadPostArgs +# Import X search tool +from tools.search_x import search_x_posts, SearchXArgs + letta_config = get_letta_config() logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) @@ -106,6 +109,14 @@ X_TOOL_CONFIGS = [ "args_schema": XThreadPostArgs, "description": "Add a single post to the current X reply thread atomically", "tags": ["x", "twitter", "reply", "thread", "atomic"] + }, + + # X search tool + { + "func": search_x_posts, + "args_schema": SearchXArgs, + "description": "Get recent posts from a specific X (Twitter) user", + "tags": ["x", "twitter", "search", "posts", "user"] } ] diff --git a/tool_manager.py b/tool_manager.py index 8cc9be9..9fa5dca 100644 --- a/tool_manager.py +++ b/tool_manager.py @@ -23,6 +23,7 @@ BLUESKY_TOOLS = { X_TOOLS = { 'add_post_to_x_thread', + 'search_x_posts', 'attach_x_user_blocks', 'detach_x_user_blocks', 'x_user_note_append', diff --git a/tools/blocks.py b/tools/blocks.py index 70fc652..0895518 100644 --- a/tools/blocks.py +++ b/tools/blocks.py @@ -97,9 +97,11 @@ def attach_user_blocks(handles: list, agent_state: "AgentState") -> str: # Get current blocks using the API current_blocks = client.agents.blocks.list(agent_id=str(agent_state.id)) current_block_labels = set() + current_block_ids = set() for block in current_blocks: current_block_labels.add(block.label) + current_block_ids.add(str(block.id)) for handle in handles: # Sanitize handle for block label - completely self-contained @@ -117,6 +119,11 @@ def attach_user_blocks(handles: list, agent_state: "AgentState") -> str: if blocks and len(blocks) > 0: block = blocks[0] logger.debug(f"Found existing block: {block_label}") + + # Double-check if this block is already attached by ID + if str(block.id) in current_block_ids: + results.append(f"✓ {handle}: Already attached (by ID)") + continue else: block = client.blocks.create( label=block_label, @@ -126,13 +133,23 @@ def attach_user_blocks(handles: list, agent_state: "AgentState") -> str: logger.info(f"Created new block: {block_label}") # Attach block atomically - client.agents.blocks.attach( - agent_id=str(agent_state.id), - block_id=str(block.id) - ) - - results.append(f"✓ {handle}: Block attached") - logger.debug(f"Successfully attached block {block_label} to agent") + try: + client.agents.blocks.attach( + agent_id=str(agent_state.id), + block_id=str(block.id) + ) + results.append(f"✓ {handle}: Block attached") + logger.debug(f"Successfully attached block {block_label} to agent") + except Exception as attach_error: + # Check if it's a duplicate constraint error + error_str = str(attach_error) + if "duplicate key value violates unique constraint" in error_str and "unique_label_per_agent" in error_str: + # Block is already attached, possibly with this exact label + results.append(f"✓ {handle}: Already attached (verified)") + logger.debug(f"Block {block_label} was already attached (caught duplicate key error)") + else: + # Re-raise other errors + raise attach_error except Exception as e: results.append(f"✗ {handle}: Error - {str(e)}") diff --git a/tools/search_x.py b/tools/search_x.py new file mode 100644 index 0000000..bcdfd12 --- /dev/null +++ b/tools/search_x.py @@ -0,0 +1,156 @@ +"""Search tool for X (Twitter) posts.""" +from pydantic import BaseModel, Field +from typing import Optional + + +class SearchXArgs(BaseModel): + username: str = Field(..., description="X username to get recent posts from (without @)") + max_results: int = Field(default=10, description="Maximum number of posts to return (max 100)") + exclude_replies: bool = Field(default=False, description="Whether to exclude replies") + exclude_retweets: bool = Field(default=False, description="Whether to exclude retweets") + + +def search_x_posts(username: str, max_results: int = 10, exclude_replies: bool = False, exclude_retweets: bool = False) -> str: + """ + Get recent posts from a specific X (Twitter) user. + + Args: + username: X username to get posts from (without @) + max_results: Maximum number of posts to return (max 100) + exclude_replies: Whether to exclude replies + exclude_retweets: Whether to exclude retweets + + Returns: + YAML-formatted posts from the user + """ + import os + import yaml + import requests + from datetime import datetime + + try: + # Validate inputs + max_results = min(max_results, 100) + + # Get credentials from environment + # These need to be set in the cloud environment + consumer_key = os.getenv("X_CONSUMER_KEY") + consumer_secret = os.getenv("X_CONSUMER_SECRET") + access_token = os.getenv("X_ACCESS_TOKEN") + access_token_secret = os.getenv("X_ACCESS_TOKEN_SECRET") + + # Also check for bearer token as fallback + bearer_token = os.getenv("X_BEARER_TOKEN") + + if not any([bearer_token, (consumer_key and consumer_secret and access_token and access_token_secret)]): + raise Exception("X API credentials not found in environment variables") + + # First, we need to get the user ID from the username + base_url = "https://api.x.com/2" + + # Set up authentication headers + if bearer_token: + headers = { + "Authorization": f"Bearer {bearer_token}", + "Content-Type": "application/json" + } + else: + # For OAuth 1.0a, we'd need requests_oauthlib + # Since this is a cloud function, we'll require bearer token for simplicity + raise Exception("Bearer token required for X API authentication in cloud environment") + + # Get user ID from username + user_lookup_url = f"{base_url}/users/by/username/{username}" + user_params = { + "user.fields": "id,name,username,description" + } + + try: + user_response = requests.get(user_lookup_url, headers=headers, params=user_params, timeout=10) + user_response.raise_for_status() + user_data = user_response.json() + + if "data" not in user_data: + raise Exception(f"User @{username} not found") + + user_id = user_data["data"]["id"] + user_info = user_data["data"] + + except requests.exceptions.HTTPError as e: + if user_response.status_code == 404: + raise Exception(f"User @{username} not found") + else: + raise Exception(f"Failed to look up user @{username}: {str(e)}") + + # Get user's recent tweets + tweets_url = f"{base_url}/users/{user_id}/tweets" + + # Build query parameters + tweets_params = { + "max_results": max_results, + "tweet.fields": "id,text,author_id,created_at,referenced_tweets,conversation_id", + "exclude": [] + } + + # Add exclusions + if exclude_replies: + tweets_params["exclude"].append("replies") + if exclude_retweets: + tweets_params["exclude"].append("retweets") + + # Join exclusions or remove if empty + if tweets_params["exclude"]: + tweets_params["exclude"] = ",".join(tweets_params["exclude"]) + else: + del tweets_params["exclude"] + + try: + tweets_response = requests.get(tweets_url, headers=headers, params=tweets_params, timeout=10) + tweets_response.raise_for_status() + tweets_data = tweets_response.json() + except Exception as e: + raise Exception(f"Failed to fetch posts from @{username}: {str(e)}") + + # Format results + results = [] + for tweet in tweets_data.get("data", []): + # Check if it's a retweet + is_retweet = False + referenced_tweets = tweet.get("referenced_tweets", []) + for ref in referenced_tweets: + if ref.get("type") == "retweeted": + is_retweet = True + break + + tweet_data = { + "author": { + "handle": user_info.get("username", ""), + "display_name": user_info.get("name", ""), + }, + "text": tweet.get("text", ""), + "created_at": tweet.get("created_at", ""), + "url": f"https://x.com/{username}/status/{tweet.get('id', '')}", + "id": tweet.get("id", ""), + "is_retweet": is_retweet + } + + # Add conversation info if it's a reply + if tweet.get("conversation_id") and tweet.get("conversation_id") != tweet.get("id"): + tweet_data["conversation_id"] = tweet.get("conversation_id") + + results.append(tweet_data) + + return yaml.dump({ + "x_user_posts": { + "user": { + "username": user_info.get("username"), + "name": user_info.get("name"), + "description": user_info.get("description", ""), + }, + "post_count": len(results), + "posts": results + } + }, default_flow_style=False, sort_keys=False) + + except Exception as e: + raise Exception(f"Error searching X posts: {str(e)}") \ No newline at end of file diff --git a/x.py b/x.py index 7dcc974..b60ae0f 100644 --- a/x.py +++ b/x.py @@ -76,45 +76,73 @@ class XClient: self.auth_method = "bearer" logger.info("Using Application-Only Bearer token for X API") - def _make_request(self, endpoint: str, params: Optional[Dict] = None, method: str = "GET", data: Optional[Dict] = None) -> Optional[Dict]: - """Make a request to the X API with proper error handling.""" + def _make_request(self, endpoint: str, params: Optional[Dict] = None, method: str = "GET", data: Optional[Dict] = None, max_retries: int = 3) -> Optional[Dict]: + """Make a request to the X API with proper error handling and exponential backoff.""" url = f"{self.base_url}{endpoint}" - try: - if method.upper() == "GET": - if self.oauth: - response = requests.get(url, headers=self.headers, params=params, auth=self.oauth) - else: - response = requests.get(url, headers=self.headers, params=params) - elif method.upper() == "POST": - if self.oauth: - response = requests.post(url, headers=self.headers, json=data, auth=self.oauth) + for attempt in range(max_retries): + try: + if method.upper() == "GET": + if self.oauth: + response = requests.get(url, headers=self.headers, params=params, auth=self.oauth) + else: + response = requests.get(url, headers=self.headers, params=params) + elif method.upper() == "POST": + if self.oauth: + response = requests.post(url, headers=self.headers, json=data, auth=self.oauth) + else: + response = requests.post(url, headers=self.headers, json=data) else: - response = requests.post(url, headers=self.headers, json=data) - else: - raise ValueError(f"Unsupported HTTP method: {method}") + raise ValueError(f"Unsupported HTTP method: {method}") + + response.raise_for_status() + return response.json() - response.raise_for_status() - return response.json() - except requests.exceptions.HTTPError as e: - if response.status_code == 401: - logger.error(f"X API authentication failed with {self.auth_method} - check your credentials") - logger.error(f"Response: {response.text}") - elif response.status_code == 403: - logger.error(f"X API forbidden with {self.auth_method} - check app permissions") - logger.error(f"Response: {response.text}") - elif response.status_code == 429: - logger.error("X API rate limit exceeded - waiting 60 seconds before retry") - logger.error(f"Response: {response.text}") - time.sleep(60) - raise XRateLimitError("X API rate limit exceeded") - else: - logger.error(f"X API request failed: {e}") - logger.error(f"Response: {response.text}") - return None - except Exception as e: - logger.error(f"Unexpected error making X API request: {e}") - return None + except requests.exceptions.HTTPError as e: + if response.status_code == 401: + logger.error(f"X API authentication failed with {self.auth_method} - check your credentials") + logger.error(f"Response: {response.text}") + return None # Don't retry auth failures + elif response.status_code == 403: + logger.error(f"X API forbidden with {self.auth_method} - check app permissions") + logger.error(f"Response: {response.text}") + return None # Don't retry permission failures + elif response.status_code == 429: + if attempt < max_retries - 1: + # Exponential backoff: 60s, 120s, 240s + backoff_time = 60 * (2 ** attempt) + logger.warning(f"X API rate limit exceeded (attempt {attempt + 1}/{max_retries}) - waiting {backoff_time}s before retry") + logger.error(f"Response: {response.text}") + time.sleep(backoff_time) + continue + else: + logger.error("X API rate limit exceeded - max retries reached") + logger.error(f"Response: {response.text}") + raise XRateLimitError("X API rate limit exceeded") + else: + if attempt < max_retries - 1: + # Exponential backoff for other HTTP errors too + backoff_time = 30 * (2 ** attempt) + logger.warning(f"X API request failed (attempt {attempt + 1}/{max_retries}): {e} - retrying in {backoff_time}s") + logger.error(f"Response: {response.text}") + time.sleep(backoff_time) + continue + else: + logger.error(f"X API request failed after {max_retries} attempts: {e}") + logger.error(f"Response: {response.text}") + return None + + except Exception as e: + if attempt < max_retries - 1: + backoff_time = 15 * (2 ** attempt) + logger.warning(f"Unexpected error making X API request (attempt {attempt + 1}/{max_retries}): {e} - retrying in {backoff_time}s") + time.sleep(backoff_time) + continue + else: + logger.error(f"Unexpected error making X API request after {max_retries} attempts: {e}") + return None + + return None def get_mentions(self, since_id: Optional[str] = None, max_results: int = 10) -> Optional[List[Dict]]: """ @@ -283,39 +311,99 @@ class XClient: # This helps with X API's incomplete conversation search results tweet_ids = set(t.get('id') for t in tweets) missing_tweet_ids = set() + critical_missing_ids = set() - # Collect all referenced tweet IDs that aren't in our current set + # Collect referenced tweet IDs, prioritizing critical ones for tweet in tweets: referenced_tweets = tweet.get('referenced_tweets', []) for ref in referenced_tweets: ref_id = ref.get('id') + ref_type = ref.get('type') if ref_id and ref_id not in tweet_ids: missing_tweet_ids.add(ref_id) - - # Fetch missing referenced tweets individually - for missing_id in missing_tweet_ids: - try: - endpoint = f"/tweets/{missing_id}" - params = { - "tweet.fields": "id,text,author_id,created_at,in_reply_to_user_id,referenced_tweets,conversation_id", - "user.fields": "id,name,username", - "expansions": "author_id" - } - response = self._make_request(endpoint, params) - if response and "data" in response: - missing_tweet = response["data"] - # Only add if it's actually part of this conversation - if missing_tweet.get('conversation_id') == conversation_id: - tweets.append(missing_tweet) - tweet_ids.add(missing_id) - logger.info(f"Retrieved missing referenced tweet: {missing_id}") + # Prioritize direct replies and quoted tweets over retweets + if ref_type in ['replied_to', 'quoted']: + critical_missing_ids.add(ref_id) + + # For rate limit efficiency, only fetch critical missing tweets if we have many + if len(missing_tweet_ids) > 10: + logger.info(f"Many missing tweets ({len(missing_tweet_ids)}), prioritizing {len(critical_missing_ids)} critical ones") + missing_tweet_ids = critical_missing_ids + + # Context sufficiency check - skip backfill if we already have enough context + if has_sufficient_context(tweets, missing_tweet_ids): + logger.info("Thread has sufficient context, skipping missing tweet backfill") + missing_tweet_ids = set() + + # Fetch missing referenced tweets in batches (more rate-limit friendly) + if missing_tweet_ids: + missing_list = list(missing_tweet_ids) + + # First, check cache for missing tweets + cached_tweets = get_cached_tweets(missing_list) + for tweet_id, cached_tweet in cached_tweets.items(): + if cached_tweet.get('conversation_id') == conversation_id: + tweets.append(cached_tweet) + tweet_ids.add(tweet_id) + logger.info(f"Retrieved missing tweet from cache: {tweet_id}") + + # Add user data if available in cache + if cached_tweet.get('author_info'): + author_id = cached_tweet.get('author_id') + if author_id: + users_data[author_id] = cached_tweet['author_info'] + + # Only fetch tweets that weren't found in cache + uncached_ids = [tid for tid in missing_list if tid not in cached_tweets] + + if uncached_ids: + batch_size = 100 # X API limit for bulk tweet lookup + + for i in range(0, len(uncached_ids), batch_size): + batch_ids = uncached_ids[i:i + batch_size] + try: + endpoint = "/tweets" + params = { + "ids": ",".join(batch_ids), + "tweet.fields": "id,text,author_id,created_at,in_reply_to_user_id,referenced_tweets,conversation_id", + "user.fields": "id,name,username", + "expansions": "author_id" + } + response = self._make_request(endpoint, params) - # Also add user data if available - if "includes" in response and "users" in response["includes"]: - for user in response["includes"]["users"]: - users_data[user["id"]] = user - except Exception as e: - logger.warning(f"Could not fetch missing tweet {missing_id}: {e}") + if response and "data" in response: + fetched_tweets = [] + batch_users_data = {} + + for missing_tweet in response["data"]: + # Only add if it's actually part of this conversation + if missing_tweet.get('conversation_id') == conversation_id: + tweets.append(missing_tweet) + tweet_ids.add(missing_tweet.get('id')) + fetched_tweets.append(missing_tweet) + logger.info(f"Retrieved missing referenced tweet: {missing_tweet.get('id')}") + + # Add user data if available + if "includes" in response and "users" in response["includes"]: + for user in response["includes"]["users"]: + users_data[user["id"]] = user + batch_users_data[user["id"]] = user + + # Cache the newly fetched tweets + if fetched_tweets: + save_cached_tweets(fetched_tweets, batch_users_data) + + logger.info(f"Batch fetched {len(response['data'])} missing tweets from {len(batch_ids)} requested") + + # Handle partial success - log any missing tweets that weren't found + if response and "errors" in response: + for error in response["errors"]: + logger.warning(f"Could not fetch tweet {error.get('resource_id')}: {error.get('title')}") + + except Exception as e: + logger.warning(f"Could not fetch batch of missing tweets {batch_ids[:3]}...: {e}") + else: + logger.info(f"All {len(missing_list)} missing tweets found in cache") if tweets: # Filter out tweets that occur after until_id (if specified) @@ -334,6 +422,9 @@ class XClient: thread_data = {"tweets": tweets, "users": users_data} + # Cache individual tweets from the thread for future backfill + save_cached_tweets(tweets, users_data) + # Cache the result if use_cache: save_cached_thread_context(conversation_id, thread_data) @@ -713,6 +804,116 @@ def save_cached_thread_context(conversation_id: str, thread_data: Dict): except Exception as e: logger.error(f"Error caching thread context: {e}") +def get_cached_tweets(tweet_ids: List[str]) -> Dict[str, Dict]: + """ + Load cached individual tweets if available. + Returns dict mapping tweet_id -> tweet_data for found tweets. + """ + cached_tweets = {} + + for tweet_id in tweet_ids: + cache_file = X_CACHE_DIR / f"tweet_{tweet_id}.json" + if cache_file.exists(): + try: + with open(cache_file, 'r') as f: + cached_data = json.load(f) + + # Use longer cache times for older tweets (24 hours vs 1 hour) + from datetime import datetime, timedelta + cached_time = datetime.fromisoformat(cached_data.get('cached_at', '')) + tweet_created = cached_data.get('tweet_data', {}).get('created_at', '') + + # Parse tweet creation time to determine age + try: + from dateutil.parser import parse + tweet_age = datetime.now() - parse(tweet_created) + cache_duration = timedelta(hours=24) if tweet_age > timedelta(hours=24) else timedelta(hours=1) + except: + cache_duration = timedelta(hours=1) # Default to 1 hour if parsing fails + + if datetime.now() - cached_time < cache_duration: + cached_tweets[tweet_id] = cached_data.get('tweet_data') + logger.debug(f"Using cached tweet {tweet_id}") + + except Exception as e: + logger.warning(f"Error loading cached tweet {tweet_id}: {e}") + + return cached_tweets + +def save_cached_tweets(tweets_data: List[Dict], users_data: Dict[str, Dict] = None): + """Save individual tweets to cache for future reuse.""" + try: + X_CACHE_DIR.mkdir(exist_ok=True) + + for tweet in tweets_data: + tweet_id = tweet.get('id') + if not tweet_id: + continue + + cache_file = X_CACHE_DIR / f"tweet_{tweet_id}.json" + + # Include user data if available + tweet_with_user = tweet.copy() + if users_data and tweet.get('author_id') in users_data: + tweet_with_user['author_info'] = users_data[tweet.get('author_id')] + + cache_data = { + 'tweet_id': tweet_id, + 'tweet_data': tweet_with_user, + 'cached_at': datetime.now().isoformat() + } + + with open(cache_file, 'w') as f: + json.dump(cache_data, f, indent=2) + + logger.debug(f"Cached individual tweet {tweet_id}") + + except Exception as e: + logger.error(f"Error caching individual tweets: {e}") + +def has_sufficient_context(tweets: List[Dict], missing_tweet_ids: Set[str]) -> bool: + """ + Determine if we have sufficient context to skip backfilling missing tweets. + + Args: + tweets: List of tweets already in the thread + missing_tweet_ids: Set of missing tweet IDs we'd like to fetch + + Returns: + True if context is sufficient, False if backfill is needed + """ + # If no missing tweets, context is sufficient + if not missing_tweet_ids: + return True + + # If we have a substantial conversation (5+ tweets), likely sufficient + if len(tweets) >= 5: + logger.debug(f"Thread has {len(tweets)} tweets, considering sufficient") + return True + + # If only a few missing tweets and we have some context, might be enough + if len(missing_tweet_ids) <= 2 and len(tweets) >= 3: + logger.debug(f"Only {len(missing_tweet_ids)} missing tweets with {len(tweets)} existing, considering sufficient") + return True + + # Check if we have conversational flow (mentions between users) + has_conversation_flow = False + for tweet in tweets: + text = tweet.get('text', '').lower() + # Look for mentions, replies, or conversational indicators + if '@' in text or 'reply' in text or len([t for t in tweets if t.get('author_id') != tweet.get('author_id')]) > 1: + has_conversation_flow = True + break + + # If we have clear conversational flow and reasonable length, sufficient + if has_conversation_flow and len(tweets) >= 2: + logger.debug("Thread has conversational flow, considering sufficient") + return True + + # Otherwise, we need to backfill + logger.debug(f"Context insufficient: {len(tweets)} tweets, {len(missing_tweet_ids)} missing, no clear flow") + return False + def fetch_and_queue_mentions(username: str) -> int: """ Single-pass function to fetch new mentions and queue them. diff --git a/x_queue/last_seen_id.json b/x_queue/last_seen_id.json index 44546b8..a954f33 100644 --- a/x_queue/last_seen_id.json +++ b/x_queue/last_seen_id.json @@ -1 +1 @@ -{"last_seen_id": "1951025002528072057", "updated_at": "2025-07-31T14:13:02.527156"} \ No newline at end of file +{"last_seen_id": "1951359188078305712", "updated_at": "2025-08-01T12:08:21.612806"} \ No newline at end of file diff --git a/x_queue/processed_mentions.json b/x_queue/processed_mentions.json index f9064dd..bdc3da8 100644 --- a/x_queue/processed_mentions.json +++ b/x_queue/processed_mentions.json @@ -1 +1 @@ -["1950774869081354654", "1950690566909710618", "1950742693988159754", "1950945823375901154", "1950808060882038991", "1950779122890031203", "1950890479383462306", "1950777589091688690", "1950754871021592693", "1950779868960186694", "1950947483787354596", "1950745029666017362", "1950746342672007544", "1950750119219105907", "1950750041418989607", "1950926224429347160", "1950789067697983839", "1950793128522469844", "1950754661222248941", "1950915735532060786", "1950774695558832499", "1950776620203339781", "1950778758614696173", "1950780116126318997", "1950795131579445316", "1950971932716966259", "1950748541707829622", "1950769312056447186", "1950792707695382889", "1950777288091439352", "1950755355698647515", "1950768760899739896", "1950783507560812625", "1950768798593904825", "1950986138568565053", "1950765586868326584", "1950776952513814997", "1950750459045798159", "1950927321134772310", "1950755118434984411", "1950741288724423041", "1950762713313079588", "1950794346716176506", "1950780762053304663", "1950754744550728122", "1950988834914566480", "1950782010668237005", "1950781276438577219", "1950983970759516426", "1950780986398216501", "1950763126796046406", "1950768950729678964", "1950775991808541048", "1950776906498109619", "1950764690168295530", "1950739368530120865", "1950777178188288213", "1950775129686090162", "1950758670171795911", "1950810588298530818", "1950766844111122851", "1950749284804223244", "1950793918666395746", "1950766224658534618", "1950799213153112379", "1950766756869632018", "1950766482675421599", "1950746269363871754", "1950819109299458395", "1950789328826925276", "1950753699502100821", "1950781657147162971", "1950781652210422198", "1950749014728577308", "1950759115438887277", "1950764784477249958", "1950781400317317299", "1950763200649318634", "1950776904006746230", "1950776464145801332", "1950748407372353687", "1950779106389614951", "1950714596828061885", "1950775893640802681", "1950991382866436512", "1950780971072270844", "1950766898423152648", "1950751482476724241", "1950782587716489427", "1950777458657218590", "1950766890613350863", "1950778983630704954", "1950779760881373591", "1950749194685407273", "1950775531315888177", "1950748959812813003", "1950792967331193208", "1950757694312427564", "1950769061849358827", "1950781053599383730", "1950769046783443440", "1950758096747610115", "1950756238528000184", "1950782155098902811", "1950781122625040430", "1950776236239958521", "1950764759437189405", "1950754203871416763", "1950750239994061165", "1950763150195986893", "1950931104778625152", "1950779585383313712", "1950752256774856770", "1951010617638146178", "1950791694955540787", "1950945577484583072", "1950789537237766434", "1950743359305478515"] \ No newline at end of file +["1950766898423152648", "1950776236239958521", "1950749284804223244", "1951180352480821383", "1951036541008248938", "1950749014728577308", "1950794346716176506", "1950793128522469844", "1951136817245462740", "1950792707695382889", "1950741288724423041", "1951063241288983033", "1951348705162043591", "1951114308836008146", "1950749194685407273", "1950775893640802681", "1950743359305478515", "1950759115438887277", "1950776904006746230", "1950927321134772310", "1950766482675421599", "1951339550804005231", "1950765586868326584", "1950768950729678964", "1950776952513814997", "1951165239761969378", "1950754661222248941", "1950915735532060786", "1950781652210422198", "1951175052474700212", "1950750459045798159", "1951348198192324760", "1950781053599383730", "1950799213153112379", "1951348258607153462", "1951010617638146178", "1951127532402847980", "1950986138568565053", "1950779760881373591", "1951163964299296845", "1951352527615828053", "1950791694955540787", "1951179072802828555", "1950768798593904825", "1950810588298530818", "1950782587716489427", "1950748959812813003", "1950945823375901154", "1950756238528000184", "1951163614750187700", "1950777458657218590", "1950763150195986893", "1950745029666017362", "1951347808499565026", "1951139510760124446", "1951347165068140904", "1950779868960186694", "1951182820363145238", "1950758670171795911", "1951147949456523656", "1951353640498897297", "1950754871021592693", "1950778758614696173", "1950775531315888177", "1951309293510373414", "1951129835729985602", "1950795131579445316", "1951349178141159849", "1950789537237766434", "1950781657147162971", "1951136786144698795", "1951141952105431381", "1950819109299458395", "1951353453546184900", "1951040769646796890", "1951346496840077702", "1950781400317317299", "1950755118434984411", "1950766890613350863", "1951121664126886391", "1950763126796046406", "1951172176729743451", "1951038794024509743", "1951166530890375615", "1950746269363871754", "1950764784477249958", "1950777288091439352", "1950971932716966259", "1950931104778625152", "1950780116126318997", "1951173346281849248", "1951118743515570593", "1951129383957127239", "1950769312056447186", "1950764690168295530", "1950774695558832499", "1951359188078305712", "1951170488681799793", "1951307523388035439", "1951315163388453150", "1950750119219105907", "1951119002073440288", "1950714596828061885", "1950792967331193208", "1950777589091688690", "1951172869922435497", "1950750239994061165", "1950779106389614951", "1950748407372353687", "1950755355698647515", "1950778983630704954", "1951119469805445309", "1950753699502100821", "1950769061849358827", "1950890479383462306", "1950775129686090162", "1951174527167480109", "1951347924862116206", "1950808060882038991", "1950757694312427564", "1951034947915108628", "1950758096747610115", "1951132696383766599", "1950988834914566480", "1950781122625040430", "1951117367595216986", "1950793918666395746", "1951183108155314686", "1950779585383313712", "1951174471093723386", "1950762713313079588", "1950764759437189405", "1950780986398216501", "1951168402086830260", "1951059082569285636", "1951172964080312368", "1950766756869632018", "1950926224429347160", "1950748541707829622", "1951165985530192029", "1950779122890031203", "1951164423932157970", "1950774869081354654", "1950789328826925276", "1950776620203339781", "1950783507560812625", "1951174849390588267", "1950742693988159754", "1951315292765954297", "1950777178188288213", "1950789067697983839", "1951120165439152509", "1950983970759516426", "1951316609781407888", "1950780762053304663", "1951354423533511069", "1950739368530120865", "1950782010668237005", "1950754203871416763", "1950690566909710618", "1951315365860090218", "1950947483787354596", "1950781276438577219", "1950776464145801332", "1951308927829963195", "1950766224658534618", "1950763200649318634", "1950766844111122851", "1951142705582473361", "1950746342672007544", "1951173108204970474", "1950782155098902811", "1950775991808541048", "1950768760899739896", "1951134620638183644", "1951308577953501232", "1950991382866436512", "1950769046783443440", "1951203664288215278", "1950750041418989607", "1950752256774856770", "1950776906498109619", "1950751482476724241", "1950754744550728122", "1951168832099459262", "1950780971072270844", "1951118786918228119", "1951160693677494624", "1950945577484583072", "1951040893248508395", "1951174355649700256", "1951127859290083736", "1951166765188387073"] \ No newline at end of file