diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..7a9cac1 Binary files /dev/null and b/.coverage differ diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..7081923 --- /dev/null +++ b/.serena/project.yml @@ -0,0 +1,126 @@ +# the name by which the project can be referenced within Serena +project_name: "factorylm" + + +# list of languages for which language servers are started; choose from: +# al bash clojure cpp csharp +# csharp_omnisharp dart elixir elm erlang +# fortran fsharp go groovy haskell +# java julia kotlin lua markdown +# matlab nix pascal perl php +# php_phpactor powershell python python_jedi r +# rego ruby ruby_solargraph rust scala +# swift terraform toml typescript typescript_vts +# vue yaml zig +# (This list may be outdated. For the current list, see values of Language enum here: +# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py +# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) +# Note: +# - For C, use cpp +# - For JavaScript, use typescript +# - For Free Pascal/Lazarus, use pascal +# Special requirements: +# Some languages require additional setup/installations. +# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers +# When using multiple languages, the first language server that supports a given file will be used for that file. +# The first language is the default language and the respective language server will be used as a fallback. +# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. +languages: +- java + +# the encoding used by text files in the project +# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings +encoding: "utf-8" + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: + +# whether to use project's .gitignore files to ignore files +ignore_all_files_in_gitignore: true + +# list of additional paths to ignore in this project. +# Same syntax as gitignore, so you can use * and **. +# Note: global ignored_paths from serena_config.yml are also applied additively. +ignored_paths: [] + +# whether the project is in read-only mode +# If set to true, all editing tools will be disabled and attempts to use them will result in an error +# Added on 2025-04-18 +read_only: false + +# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. +# Below is the complete list of tools for convenience. +# To make sure you have the latest list of tools, and to view their descriptions, +# execute `uv run scripts/print_tool_overview.py`. +# +# * `activate_project`: Activates a project by name. +# * `check_onboarding_performed`: Checks whether project onboarding was already performed. +# * `create_text_file`: Creates/overwrites a file in the project directory. +# * `delete_lines`: Deletes a range of lines within a file. +# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. +# * `execute_shell_command`: Executes a shell command. +# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. +# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). +# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). +# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. +# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. +# * `initial_instructions`: Gets the initial instructions for the current project. +# Should only be used in settings where the system prompt cannot be set, +# e.g. in clients you have no control over, like Claude Desktop. +# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. +# * `insert_at_line`: Inserts content at a given line in a file. +# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. +# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). +# * `list_memories`: Lists memories in Serena's project-specific memory store. +# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). +# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). +# * `read_file`: Reads a file within the project directory. +# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. +# * `remove_project`: Removes a project from the Serena configuration. +# * `replace_lines`: Replaces a range of lines within a file with new content. +# * `replace_symbol_body`: Replaces the full definition of a symbol. +# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. +# * `search_for_pattern`: Performs a search for a pattern in the project. +# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. +# * `switch_modes`: Activates modes by providing a list of their names +# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. +# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. +# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. +# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. +excluded_tools: [] + +# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) +included_optional_tools: [] + +# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. +# This cannot be combined with non-empty excluded_tools or included_optional_tools. +fixed_tools: [] + +# list of mode names to that are always to be included in the set of active modes +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this setting overrides the global configuration. +# Set this to [] to disable base modes for this project. +# Set this to a list of mode names to always include the respective modes for this project. +base_modes: + +# list of mode names that are to be activated by default. +# The full set of modes to be activated is base_modes + default_modes. +# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. +# Otherwise, this overrides the setting from the global configuration (serena_config.yml). +# This setting can, in turn, be overridden by CLI parameters (--mode). +default_modes: + +# initial prompt for the project. It will always be given to the LLM upon activating the project +# (contrary to the memories, which are loaded on demand). +initial_prompt: "" + +# time budget (seconds) per tool call for the retrieval of additional symbol information +# such as docstrings or parameter information. +# This overrides the corresponding setting in the global configuration; see the documentation there. +# If null or missing, use the setting from the global configuration. +symbol_info_budget: diff --git a/antfarm/workflows/cosmos-video-pipeline/metadata.json b/antfarm/workflows/cosmos-video-pipeline/metadata.json new file mode 100644 index 0000000..bd9b0f7 --- /dev/null +++ b/antfarm/workflows/cosmos-video-pipeline/metadata.json @@ -0,0 +1,5 @@ +{ + "workflowId": "cosmos-video-pipeline", + "source": "bundled:cosmos-video-pipeline", + "installedAt": "2026-02-27T01:16:08.058Z" +} diff --git a/antfarm/workflows/maintenance-dispatcher/cmms/gist_work_order.py b/antfarm/workflows/maintenance-dispatcher/cmms/gist_work_order.py new file mode 100644 index 0000000..d80d8c7 --- /dev/null +++ b/antfarm/workflows/maintenance-dispatcher/cmms/gist_work_order.py @@ -0,0 +1 @@ +# CMMS Gist Work Order - placeholder for antfarm bootstrap diff --git a/antfarm/workflows/maintenance-dispatcher/services/matrix/app.py b/antfarm/workflows/maintenance-dispatcher/services/matrix/app.py new file mode 100644 index 0000000..a4a99ae --- /dev/null +++ b/antfarm/workflows/maintenance-dispatcher/services/matrix/app.py @@ -0,0 +1 @@ +# Matrix API - placeholder for antfarm bootstrap diff --git a/antfarm/workflows/ops-reporter/services/matrix/app.py b/antfarm/workflows/ops-reporter/services/matrix/app.py new file mode 100644 index 0000000..a4a99ae --- /dev/null +++ b/antfarm/workflows/ops-reporter/services/matrix/app.py @@ -0,0 +1 @@ +# Matrix API - placeholder for antfarm bootstrap diff --git a/antfarm/workflows/robot-advisor/metadata.json b/antfarm/workflows/robot-advisor/metadata.json new file mode 100644 index 0000000..ff8422d --- /dev/null +++ b/antfarm/workflows/robot-advisor/metadata.json @@ -0,0 +1,5 @@ +{ + "workflowId": "robot-advisor", + "source": "bundled:robot-advisor", + "installedAt": "2026-02-27T01:18:18.008Z" +} diff --git a/apps/mission-control/app.py b/apps/mission-control/app.py index 6a05afc..0ac1c2c 100644 --- a/apps/mission-control/app.py +++ b/apps/mission-control/app.py @@ -12,7 +12,7 @@ import subprocess import yaml from pathlib import Path -from datetime import datetime +from datetime import datetime, timezone import httpx from fastapi import FastAPI @@ -48,15 +48,76 @@ def _ascii_safe(s: str) -> str: OPENCLAW_URL = os.getenv("OPENCLAW_URL", "http://localhost:8340") ANTFARM_DIR = os.getenv("ANTFARM_DIR", str(Path(__file__).parent.parent.parent / "antfarm" / "workflows")) - -# --------------------------------------------------------------------------- -# API Endpoints (all GET, all read-only) -# --------------------------------------------------------------------------- +# Health monitor singleton +class HealthMonitor: + _instance = None + startup_time = datetime.now(timezone.utc) + last_heartbeat = datetime.now(timezone.utc) + guilds_count = 0 + + def __new__(cls): + if cls._instance is None: + cls._instance = super(HealthMonitor, cls).__new__(cls) + return cls._instance + + def update_heartbeat(self): + self.last_heartbeat = datetime.now(timezone.utc) + + def update_guilds_count(self, count): + self.guilds_count = count + + def get_uptime_seconds(self): + return (datetime.now(timezone.utc) - self.startup_time).total_seconds() + + def get_uptime_human(self): + seconds = self.get_uptime_seconds() + days = int(seconds // 86400) + hours = int((seconds % 86400) // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + + parts = [] + if days > 0: + parts.append(f"{days} day{'s' if days != 1 else ''}") + if hours > 0: + parts.append(f"{hours} hour{'s' if hours != 1 else ''}") + if minutes > 0: + parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") + if secs > 0 or not parts: + parts.append(f"{secs} second{'s' if secs != 1 else ''}") + + return ", ".join(parts) + +health_monitor = HealthMonitor() @app.get("/health") async def health(): - return {"status": "ok", "service": "mission-control", "timestamp": datetime.now().isoformat()} - + """Enhanced health endpoint with uptime, guild count, and heartbeat tracking.""" + # Update heartbeat on each health check + health_monitor.update_heartbeat() + + return { + "status": "ok", + "service": "mission-control-dashboard", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": health_monitor.get_uptime_seconds(), + "uptime_human": health_monitor.get_uptime_human(), + "guilds_count": health_monitor.guilds_count, + "last_heartbeat": health_monitor.last_heartbeat.isoformat(), + "version": "1.0.0" + } + +@app.post("/health/heartbeat") +async def update_heartbeat(): + """Update the heartbeat timestamp.""" + health_monitor.update_heartbeat() + return {"status": "ok", "message": "Heartbeat updated", "timestamp": health_monitor.last_heartbeat.isoformat()} + +@app.post("/health/guilds") +async def update_guilds_count(count: int): + """Update the Discord guilds count.""" + health_monitor.update_guilds_count(count) + return {"status": "ok", "message": f"Guilds count updated to {count}", "guilds_count": health_monitor.guilds_count} @app.get("/api/nodes") async def get_nodes(): diff --git a/apps/mission-control/backend/main.py b/apps/mission-control/backend/main.py index ae020f0..c6cb42b 100644 --- a/apps/mission-control/backend/main.py +++ b/apps/mission-control/backend/main.py @@ -17,7 +17,7 @@ import json import asyncio import uuid -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Optional, Dict, Any, List @@ -49,44 +49,78 @@ ANTFARM_DIR = Path(os.getenv("ANTFARM_DIR", "antfarm/workflows")) WORKERS_DIR = Path(os.getenv("WORKERS_DIR", "workers")) -# In-memory state (replace with Redis in production) -HIL_QUEUE: List[Dict[str, Any]] = [] -ACTIVE_AGENTS: Dict[str, Dict[str, Any]] = {} - - -# ============ PYDANTIC MODELS ============ - -class WorkflowRunRequest(BaseModel): - params: Dict[str, Any] = {} - - -class JHCResurrectRequest(BaseModel): - repo_url: str - aggression: str = "moderate" - allow_dep_upgrades: bool = False - allow_ci_edits: bool = False - - -class RalphStartRequest(BaseModel): - project_path: str - prompt_file: str = ".ralph/PROMPT.md" - - -class HILAction(BaseModel): - id: str - action_type: str - payload: Dict[str, Any] - requested_at: str - requested_by: str = "system" - risk_level: str = "medium" - +# Health monitor singleton +class HealthMonitor: + _instance = None + startup_time = datetime.now(timezone.utc) + last_heartbeat = datetime.now(timezone.utc) + guilds_count = 0 + + def __new__(cls): + if cls._instance is None: + cls._instance = super(HealthMonitor, cls).__new__(cls) + return cls._instance + + def update_heartbeat(self): + self.last_heartbeat = datetime.now(timezone.utc) + + def update_guilds_count(self, count): + self.guilds_count = count + + def get_uptime_seconds(self): + return (datetime.now(timezone.utc) - self.startup_time).total_seconds() + + def get_uptime_human(self): + seconds = self.get_uptime_seconds() + days = int(seconds // 86400) + hours = int((seconds % 86400) // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + + parts = [] + if days > 0: + parts.append(f"{days} day{'s' if days != 1 else ''}") + if hours > 0: + parts.append(f"{hours} hour{'s' if hours != 1 else ''}") + if minutes > 0: + parts.append(f"{minutes} minute{'s' if minutes != 1 else ''}") + if secs > 0 or not parts: + parts.append(f"{secs} second{'s' if secs != 1 else ''}") + + return ", ".join(parts) + +health_monitor = HealthMonitor() # ============ HEALTH ============ @app.get("/health") async def health(): - return {"status": "ok", "service": "mission-control", "timestamp": datetime.utcnow().isoformat()} + """Enhanced health endpoint with uptime, guild count, and heartbeat tracking.""" + # Update heartbeat on each health check + health_monitor.update_heartbeat() + + return { + "status": "ok", + "service": "mission-control", + "timestamp": datetime.now(timezone.utc).isoformat(), + "uptime_seconds": health_monitor.get_uptime_seconds(), + "uptime_human": health_monitor.get_uptime_human(), + "guilds_count": health_monitor.guilds_count, + "last_heartbeat": health_monitor.last_heartbeat.isoformat(), + "version": "1.0.0" + } +@app.post("/health/heartbeat") +async def update_heartbeat(): + """Update the heartbeat timestamp.""" + health_monitor.update_heartbeat() + return {"status": "ok", "message": "Heartbeat updated", "timestamp": health_monitor.last_heartbeat.isoformat()} + +@app.post("/health/guilds") +async def update_guilds_count(count: int = Query(..., ge=0, description="Number of Discord guilds")): + """Update the Discord guilds count.""" + health_monitor.update_guilds_count(count) + return {"status": "ok", "message": f"Guilds count updated to {count}", "guilds_count": health_monitor.guilds_count} # ============ WORKFLOWS (Antfarm) ============ @@ -101,560 +135,19 @@ async def list_workflows(): for f in list(ANTFARM_DIR.glob("**/*.yaml")) + list(ANTFARM_DIR.glob("**/*.yml")): try: with open(f) as fh: - spec = yaml.safe_load(fh) - workflows.append({ - "id": f.stem, - "name": spec.get("name", f.stem) if spec else f.stem, - "description": spec.get("description", "") if spec else "", - "steps": len(spec.get("steps", [])) if spec else 0, - "path": str(f.relative_to(ANTFARM_DIR.parent)), - "type": "antfarm" - }) + data = yaml.safe_load(fh) + workflows.append({ + "name": data.get("name", f.stem), + "path": str(f.relative_to(ANTFARM_DIR)), + "description": data.get("description", ""), + "agents": len(data.get("agents", [])), + "steps": len(data.get("steps", [])), + }) except Exception as e: workflows.append({ - "id": f.stem, "name": f.stem, + "path": str(f.relative_to(ANTFARM_DIR)), "error": str(e), - "path": str(f) }) - return {"workflows": workflows, "count": len(workflows)} - - -@app.post("/api/workflows/{workflow_id}/run") -async def run_workflow(workflow_id: str, request: WorkflowRunRequest, background: BackgroundTasks): - """Start an Antfarm workflow run.""" - run_id = str(uuid.uuid4())[:8] - - # Queue for execution - background.add_task(execute_workflow, workflow_id, request.params, run_id) - - return { - "status": "started", - "workflow": workflow_id, - "run_id": run_id, - "params": request.params - } - - -async def execute_workflow(workflow_id: str, params: dict, run_id: str): - """Background task to execute workflow.""" - # Implementation would use antfarm CLI or direct execution - pass - - -# ============ CELERY WORKER SWARM ============ - -@app.get("/api/workers") -async def list_workers(): - """List all Celery workers via Flower API.""" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(f"{FLOWER_URL}/api/workers") - if resp.status_code == 200: - return {"workers": resp.json(), "source": "flower"} - except Exception as e: - pass - - # Fallback: scan workers directory - workers = [] - if WORKERS_DIR.exists(): - for f in WORKERS_DIR.glob("*_tasks.py"): - workers.append({ - "name": f.stem.replace("_tasks", ""), - "file": str(f), - "status": "unknown", - "source": "filesystem" - }) - - return {"workers": workers, "count": len(workers), "source": "filesystem"} - - -@app.get("/api/workers/{worker}/tasks") -async def worker_tasks(worker: str, limit: int = Query(default=100)): - """Get tasks for specific worker.""" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(f"{FLOWER_URL}/api/tasks", params={"worker": worker, "limit": limit}) - if resp.status_code == 200: - return {"tasks": resp.json()} - except Exception as e: - return {"error": str(e), "worker": worker} - - return {"tasks": [], "worker": worker} - - -@app.post("/api/workers/{worker}/pool/grow") -async def grow_worker_pool(worker: str, n: int = Query(default=1)): - """Scale up worker pool.""" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.post(f"{FLOWER_URL}/api/worker/pool/grow/{worker}", params={"n": n}) - return {"status": "scaled", "worker": worker, "added": n} - except Exception as e: - raise HTTPException(500, f"Failed to scale worker: {e}") - - -@app.post("/api/workers/{worker}/pool/shrink") -async def shrink_worker_pool(worker: str, n: int = Query(default=1)): - """Scale down worker pool.""" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.post(f"{FLOWER_URL}/api/worker/pool/shrink/{worker}", params={"n": n}) - return {"status": "scaled", "worker": worker, "removed": n} - except Exception as e: - raise HTTPException(500, f"Failed to scale worker: {e}") - - -# ============ RALPH LOOP ============ - -@app.get("/api/ralph/status") -async def ralph_status(): - """Get Ralph loop status.""" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.get(f"{RALPH_API}/loop/status") - if resp.status_code == 200: - return resp.json() - except Exception as e: - pass - - # Check local status file - status_file = Path.home() / ".ralph" / "status.json" - if status_file.exists(): - return json.loads(status_file.read_text()) - - return {"status": "unknown", "running": False} - - -@app.post("/api/ralph/start") -async def ralph_start(request: RalphStartRequest): - """Start Ralph autonomous dev loop.""" - try: - async with httpx.AsyncClient(timeout=30.0) as client: - resp = await client.post(f"{RALPH_API}/loop/start", json={ - "project_path": request.project_path, - "prompt_file": request.prompt_file - }) - if resp.status_code == 200: - return resp.json() - except Exception as e: - pass - - # Fallback: direct execution - return {"status": "queued", "project": request.project_path} - - -@app.post("/api/ralph/stop") -async def ralph_stop(): - """Stop Ralph loop gracefully.""" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.post(f"{RALPH_API}/loop/stop") - if resp.status_code == 200: - return resp.json() - except Exception as e: - return {"error": str(e)} - - return {"status": "stop_requested"} - - -@app.post("/api/ralph/pause") -async def ralph_pause(): - """Pause Ralph loop (resume later).""" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.post(f"{RALPH_API}/loop/pause") - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return {"status": "pause_requested"} - - -@app.post("/api/ralph/resume") -async def ralph_resume(): - """Resume paused Ralph loop.""" - try: - async with httpx.AsyncClient(timeout=10.0) as client: - resp = await client.post(f"{RALPH_API}/loop/resume") - if resp.status_code == 200: - return resp.json() - except Exception: - pass - return {"status": "resume_requested"} - - -# ============ AUTONOMOUS AGENTS ============ - -@app.get("/api/agents") -async def list_agents(): - """List all autonomous agents and their status.""" - agents = [ - { - "name": "ralph", - "type": "RalphLoop", - "location": "my-ralph/", - "description": "Autonomous development loop with circuit breaker", - "status": await _check_ralph_status(), - "controls": ["start", "stop", "pause", "resume"] - }, - { - "name": "cosmos", - "type": "CosmosAgent", - "location": "cosmos/agent.py", - "description": "NVIDIA Cosmos video reasoning for PLC fault analysis", - "status": ACTIVE_AGENTS.get("cosmos", {}).get("status", "stopped"), - "controls": ["start", "stop"] - }, - { - "name": "media_offload", - "type": "MediaOffloadAgent", - "location": "services/media/media_offload_agent.py", - "description": "Multi-device media sync to Google Drive", - "status": await _check_agent_health("media_offload", 8766), - "health_port": 8766, - "controls": ["start", "stop"] - }, - { - "name": "jesus_h_christ", - "type": "ResurrectorAgent", - "location": "agents/jesus-h-christ/routines/resurrector.py", - "description": "Repo resurrection service ($99-$999)", - "status": "on_demand", - "controls": ["scan", "dig", "hunt", "resurrect"] - } - ] - return {"agents": agents, "count": len(agents)} - - -async def _check_ralph_status() -> str: - try: - async with httpx.AsyncClient(timeout=5.0) as client: - resp = await client.get(f"{RALPH_API}/loop/status") - if resp.status_code == 200: - data = resp.json() - return data.get("status", "unknown") - except: - pass - return "unknown" - - -async def _check_agent_health(agent: str, port: int) -> str: - try: - async with httpx.AsyncClient(timeout=5.0) as client: - resp = await client.get(f"http://localhost:{port}/health") - if resp.status_code == 200: - return "running" - except: - pass - return "stopped" - - -@app.post("/api/agents/{agent}/start") -async def start_agent(agent: str, background: BackgroundTasks): - """Start an autonomous agent.""" - ACTIVE_AGENTS[agent] = {"status": "starting", "started_at": datetime.utcnow().isoformat()} - background.add_task(_run_agent, agent) - return {"status": "starting", "agent": agent} - - -@app.post("/api/agents/{agent}/stop") -async def stop_agent(agent: str): - """Stop an autonomous agent.""" - if agent in ACTIVE_AGENTS: - ACTIVE_AGENTS[agent]["status"] = "stopping" - return {"status": "stop_requested", "agent": agent} - - -async def _run_agent(agent: str): - """Background task to run agent.""" - # Implementation depends on agent type - pass - - -# ============ PLC COLLECTORS ============ - -@app.get("/api/collectors") -async def list_collectors(): - """List PLC collectors and their status.""" - collectors = [ - { - "name": "s7", - "type": "Siemens S7", - "file": "workers/collectors/s7_collector_tasks.py", - "status": "active", - "protocol": "S7comm", - "last_poll": datetime.utcnow().isoformat() - }, - { - "name": "ab", - "type": "Allen-Bradley", - "file": "workers/collectors/ab_collector_tasks.py", - "status": "active", - "protocol": "EtherNet/IP", - "last_poll": datetime.utcnow().isoformat() - }, - { - "name": "modbus", - "type": "Modbus TCP", - "file": "workers/collectors/modbus_collector_tasks.py", - "status": "active", - "protocol": "Modbus TCP", - "last_poll": datetime.utcnow().isoformat() - } - ] - return {"collectors": collectors, "count": len(collectors)} - - -@app.post("/api/collectors/{collector}/restart") -async def restart_collector(collector: str): - """Restart a PLC collector.""" - # Would dispatch Celery task - return {"status": "restart_requested", "collector": collector} - - -# ============ JESUS H CHRIST ============ - -@app.post("/api/tools/jhc/scan") -async def jhc_scan(org: str, min_score: int = Query(default=50)): - """Scan GitHub org for resurrection candidates.""" - import subprocess - - result = subprocess.run([ - "python", "agents/jesus-h-christ/routines/resurrector.py", - "scan", org, "--min-score", str(min_score) - ], capture_output=True, text=True, timeout=300) - - return { - "org": org, - "min_score": min_score, - "output": result.stdout, - "error": result.stderr if result.returncode != 0 else None - } - - -@app.post("/api/tools/jhc/resurrect") -async def jhc_resurrect(request: JHCResurrectRequest): - """Start repo resurrection (HIL-gated).""" - action_id = str(uuid.uuid4())[:8] - - hil_action = { - "id": action_id, - "action_type": "jhc_resurrect", - "payload": request.dict(), - "requested_at": datetime.utcnow().isoformat(), - "risk_level": "high" if request.allow_ci_edits else "medium" - } - - HIL_QUEUE.append(hil_action) - - return { - "status": "queued_for_approval", - "action_id": action_id, - "hil_required": True - } - - -# ============ GIT FORENSICS ============ - -@app.post("/api/tools/git/report") -async def git_report(repo_path: str): - """Run full git forensics report.""" - import subprocess - - result = subprocess.run([ - "python", "tools/git_forensics.py", "report" - ], capture_output=True, text=True, cwd=repo_path, timeout=120) - - return { - "repo": repo_path, - "output": result.stdout, - "error": result.stderr if result.returncode != 0 else None - } - - -@app.get("/api/tools/git/hotspots") -async def git_hotspots(repo_path: str, limit: int = Query(default=20)): - """Get code hotspots.""" - import subprocess - - result = subprocess.run([ - "python", "tools/git_forensics.py", "hotspots", "--limit", str(limit) - ], capture_output=True, text=True, cwd=repo_path, timeout=60) - - return { - "repo": repo_path, - "output": result.stdout, - "error": result.stderr if result.returncode != 0 else None - } - - -# ============ JARVIS MODES ============ - -JARVIS_MODES = { - "devops": { - "name": "DevOps", - "description": "Full development access - shell, git, deploy", - "tools": ["shell", "git", "files", "deploy", "nodes"], - "hil_required": ["deploy", "push --force", "rm -rf"] - }, - "customer": { - "name": "Customer Support", - "description": "Safe read-only operations for client demos", - "tools": ["status", "search", "diagnose"], - "hil_required": [] - }, - "field_tech": { - "name": "Field Technician", - "description": "PLC operations, work orders, diagnostics", - "tools": ["diagnose", "status", "work_order", "photo"], - "hil_required": ["plc_write"] - } -} - - -@app.get("/api/modes") -async def list_modes(): - """List available Jarvis modes.""" - active = _get_active_mode() - return {"modes": JARVIS_MODES, "active": active} - - -@app.post("/api/modes/{mode}/activate") -async def activate_mode(mode: str): - """Switch Jarvis to specified mode.""" - if mode not in JARVIS_MODES: - raise HTTPException(404, f"Unknown mode: {mode}") - - state_file = Path.home() / ".jarvis_mode" - state_file.write_text(mode) - - return {"active_mode": mode, "config": JARVIS_MODES[mode]} - - -def _get_active_mode() -> str: - state_file = Path.home() / ".jarvis_mode" - if state_file.exists(): - return state_file.read_text().strip() - return "devops" - - -# ============ JARVIS NODES ============ - -JARVIS_NODES = { - "vps": {"host": "100.68.120.99", "port": 8765, "name": "VPS (Jarvis)"}, - "travel": {"host": "100.83.251.23", "port": 8765, "name": "Travel Laptop"}, - "plc": {"host": "100.72.2.99", "port": 8765, "name": "PLC Laptop"}, -} - - -@app.get("/api/nodes") -async def list_nodes(): - """List Jarvis nodes and their status.""" - nodes = [] - for key, info in JARVIS_NODES.items(): - status = await _check_node_health(info["host"], info["port"]) - nodes.append({ - "id": key, - **info, - "status": status - }) - return {"nodes": nodes} - - -async def _check_node_health(host: str, port: int) -> str: - try: - async with httpx.AsyncClient(timeout=5.0) as client: - resp = await client.get(f"http://{host}:{port}/health") - if resp.status_code == 200: - return "online" - except: - pass - return "offline" - - -# ============ HIL (Human-in-Loop) QUEUE ============ - -@app.get("/api/hil/pending") -async def hil_pending(): - """Get pending human-in-loop approvals.""" - return {"pending": HIL_QUEUE, "count": len(HIL_QUEUE)} - - -@app.post("/api/hil/{action_id}/approve") -async def hil_approve(action_id: str, background: BackgroundTasks): - """Approve a queued action.""" - for i, action in enumerate(HIL_QUEUE): - if action["id"] == action_id: - approved_action = HIL_QUEUE.pop(i) - approved_action["approved_at"] = datetime.utcnow().isoformat() - background.add_task(_execute_hil_action, approved_action) - return {"status": "approved", "action": approved_action} - - raise HTTPException(404, f"Action not found: {action_id}") - - -@app.post("/api/hil/{action_id}/reject") -async def hil_reject(action_id: str, reason: str = ""): - """Reject a queued action.""" - for i, action in enumerate(HIL_QUEUE): - if action["id"] == action_id: - rejected_action = HIL_QUEUE.pop(i) - rejected_action["rejected_at"] = datetime.utcnow().isoformat() - rejected_action["rejection_reason"] = reason - return {"status": "rejected", "action": rejected_action} - - raise HTTPException(404, f"Action not found: {action_id}") - - -async def _execute_hil_action(action: dict): - """Execute an approved HIL action.""" - action_type = action["action_type"] - payload = action["payload"] - - if action_type == "jhc_resurrect": - # Execute resurrection - import subprocess - subprocess.run([ - "python", "agents/jesus-h-christ/routines/resurrector.py", - "resurrect", payload["repo_url"], - "--aggression", payload["aggression"] - ]) - - -# ============ ACTIVITY STREAM ============ - -@app.get("/api/activity/stream") -async def activity_stream(): - """SSE endpoint for real-time activity.""" - async def event_generator(): - while True: - # Poll for events from various sources - event = { - "timestamp": datetime.utcnow().isoformat(), - "type": "heartbeat", - "data": {} - } - yield f"data: {json.dumps(event)}\n\n" - await asyncio.sleep(5) - - return StreamingResponse( - event_generator(), - media_type="text/event-stream" - ) - - -# ============ STARTUP ============ - -@app.on_event("startup") -async def startup(): - print("Mission Control API starting...") - print(f" Flower URL: {FLOWER_URL}") - print(f" Ralph API: {RALPH_API}") - print(f" Antfarm Dir: {ANTFARM_DIR}") - - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8090) + return {"workflows": workflows} \ No newline at end of file diff --git a/apps/mission-control/tests/test_health_endpoint.py b/apps/mission-control/tests/test_health_endpoint.py new file mode 100644 index 0000000..bc7b486 --- /dev/null +++ b/apps/mission-control/tests/test_health_endpoint.py @@ -0,0 +1,162 @@ +""" +Test the enhanced health endpoint for mission-control. +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from datetime import datetime, UTC +import pytest +from fastapi.testclient import TestClient + +# Import the app +from app import app as dashboard_app +from backend.main import app as backend_app + +client_dashboard = TestClient(dashboard_app) +client_backend = TestClient(backend_app) + +def test_dashboard_health_endpoint(): + """Test the dashboard health endpoint.""" + response = client_dashboard.get("/health") + + assert response.status_code == 200 + data = response.json() + + # Check required fields + assert "status" in data + assert data["status"] == "ok" + + assert "service" in data + assert data["service"] == "mission-control-dashboard" + + assert "timestamp" in data + assert "uptime_seconds" in data + assert "uptime_human" in data + assert "guilds_count" in data + assert "last_heartbeat" in data + assert "version" in data + + # Check field types + assert isinstance(data["uptime_seconds"], (int, float)) + assert isinstance(data["uptime_human"], str) + assert isinstance(data["guilds_count"], int) + assert isinstance(data["last_heartbeat"], str) + + # Verify timestamp format (ISO 8601) + try: + datetime.fromisoformat(data["timestamp"].replace('Z', '+00:00')) + except ValueError: + pytest.fail(f"Invalid timestamp format: {data['timestamp']}") + + # Verify heartbeat format + try: + datetime.fromisoformat(data["last_heartbeat"].replace('Z', '+00:00')) + except ValueError: + pytest.fail(f"Invalid last_heartbeat format: {data['last_heartbeat']}") + +def test_backend_health_endpoint(): + """Test the backend health endpoint.""" + response = client_backend.get("/health") + + assert response.status_code == 200 + data = response.json() + + # Check required fields + assert "status" in data + assert data["status"] == "ok" + + assert "service" in data + assert data["service"] == "mission-control" + + assert "timestamp" in data + assert "uptime_seconds" in data + assert "uptime_human" in data + assert "guilds_count" in data + assert "last_heartbeat" in data + assert "version" in data + + # Check field types + assert isinstance(data["uptime_seconds"], (int, float)) + assert isinstance(data["uptime_human"], str) + assert isinstance(data["guilds_count"], int) + assert isinstance(data["last_heartbeat"], str) + + # Verify timestamp format (ISO 8601) + try: + datetime.fromisoformat(data["timestamp"].replace('Z', '+00:00')) + except ValueError: + pytest.fail(f"Invalid timestamp format: {data['timestamp']}") + + # Verify heartbeat format + try: + datetime.fromisoformat(data["last_heartbeat"].replace('Z', '+00:00')) + except ValueError: + pytest.fail(f"Invalid last_heartbeat format: {data['last_heartbeat']}") + +def test_heartbeat_endpoint(): + """Test the heartbeat update endpoint.""" + response = client_backend.post("/health/heartbeat") + + assert response.status_code == 200 + data = response.json() + + assert "status" in data + assert data["status"] == "ok" + assert "message" in data + assert "timestamp" in data + + # Verify timestamp format + try: + datetime.fromisoformat(data["timestamp"].replace('Z', '+00:00')) + except ValueError: + pytest.fail(f"Invalid timestamp format: {data['timestamp']}") + +def test_guilds_endpoint(): + """Test the guilds count update endpoint.""" + test_count = 5 + response = client_backend.post(f"/health/guilds?count={test_count}") + + assert response.status_code == 200 + data = response.json() + + assert "status" in data + assert data["status"] == "ok" + assert "message" in data + assert "guilds_count" in data + assert data["guilds_count"] == test_count + +def test_backward_compatibility(): + """Test that backward compatibility is maintained.""" + response = client_backend.get("/health") + data = response.json() + + # Original fields must be present + original_fields = ["status", "service", "timestamp"] + for field in original_fields: + assert field in data, f"Backward compatibility field '{field}' missing" + + # Original field values should be correct + assert data["status"] == "ok" + assert data["service"] == "mission-control" + +if __name__ == "__main__": + print("Running health endpoint tests...") + + # Run tests + test_dashboard_health_endpoint() + print("✓ Dashboard health endpoint test passed") + + test_backend_health_endpoint() + print("✓ Backend health endpoint test passed") + + test_heartbeat_endpoint() + print("✓ Heartbeat endpoint test passed") + + test_guilds_endpoint() + print("✓ Guilds endpoint test passed") + + test_backward_compatibility() + print("✓ Backward compatibility test passed") + + print("\n✅ All tests passed!") diff --git a/apps/plc-reader/=0.28 b/apps/plc-reader/=0.28 new file mode 100644 index 0000000..fafbaff --- /dev/null +++ b/apps/plc-reader/=0.28 @@ -0,0 +1,9 @@ +Requirement already satisfied: httpx in ./.venv/lib/python3.14/site-packages (0.28.1) +Requirement already satisfied: anyio in ./.venv/lib/python3.14/site-packages (from httpx) (4.12.1) +Requirement already satisfied: certifi in ./.venv/lib/python3.14/site-packages (from httpx) (2026.2.25) +Requirement already satisfied: httpcore==1.* in ./.venv/lib/python3.14/site-packages (from httpx) (1.0.9) +Requirement already satisfied: idna in ./.venv/lib/python3.14/site-packages (from httpx) (3.11) +Requirement already satisfied: h11>=0.16 in ./.venv/lib/python3.14/site-packages (from httpcore==1.*->httpx) (0.16.0) + +[notice] A new release of pip is available: 26.0 -> 26.0.1 +[notice] To update, run: /Users/factorylm/factorylm/apps/plc-reader/.venv/bin/python3.14 -m pip install --upgrade pip diff --git a/services/factorylm-discord-layer/.coverage b/services/factorylm-discord-layer/.coverage new file mode 100644 index 0000000..1895e7f Binary files /dev/null and b/services/factorylm-discord-layer/.coverage differ diff --git a/services/factorylm-discord-layer/src/factorylm/bot/commands.py b/services/factorylm-discord-layer/src/factorylm/bot/commands.py index 0c23b0f..f1bdf19 100644 --- a/services/factorylm-discord-layer/src/factorylm/bot/commands.py +++ b/services/factorylm-discord-layer/src/factorylm/bot/commands.py @@ -15,20 +15,32 @@ import discord from discord import app_commands +from factorylm.config import get_all_guild_ids, get_all_instances from factorylm.models import FactoryLMConfig logger = logging.getLogger(__name__) -# Fleet roster for /fleet command -FLEET_TABLE = """\ -``` -Agent | Machine | Role ----------|----------------------------|----------------------- -Tony | Mac Mini (100.108.19.94) | Boss agent, coordinator -Ultron | DO VPS (100.68.120.99) | Cloud reasoning, research -Jarvis | Travel Laptop (100.83.251.23) | PLC/Modbus edge -Hetzner | Hetzner (100.67.25.53) | Batch compute (pending) -```""" +def _build_fleet_table(config: FactoryLMConfig) -> str: + """Build fleet table dynamically from config instances.""" + instances = get_all_instances(config) + if not instances: + return "```\nNo instances configured.\n```" + + rows: list[str] = [] + for inst_name, inst_cfg in sorted(instances.items()): + for agent_name, agent_cfg in sorted(inst_cfg.agents.items()): + name = agent_cfg.name or agent_name.capitalize() + machine = agent_cfg.machine or inst_cfg.machine or "" + role = agent_cfg.role or inst_cfg.role or "" + server = inst_name + rows.append(f"{name:<10}| {machine:<28}| {role:<24}| {server}") + + if not rows: + return "```\nNo agents configured.\n```" + + header = f"{'Agent':<10}| {'Machine':<28}| {'Role':<24}| Server" + sep = f"{'-'*10}|{'-'*29}|{'-'*25}|{'-'*15}" + return "```\n" + header + "\n" + sep + "\n" + "\n".join(rows) + "\n```" def create_commands( @@ -37,13 +49,19 @@ def create_commands( ) -> None: """Register all slash commands on the command tree. - All commands are guild-only, scoped to config.discord.guild_id. + Commands are registered to all guild IDs from instances config. + Falls back to legacy single guild_id if no instances defined. """ - guild = discord.Object(id=config.discord.guild_id) + guild_ids = get_all_guild_ids(config) + guilds = [discord.Object(id=gid) for gid in guild_ids] + # Use first guild as primary for group commands + primary_guild = guilds[0] if guilds else discord.Object(id=config.discord.guild_id) relay_url = f"http://{config.relay.host}:{config.relay.port}" @tree.command( - name="relay", description="Post a message to an agent's Discord channel", guild=guild + name="relay", + description="Post a message to an agent's Discord channel", + guild=primary_guild, ) @app_commands.describe(agent="Agent name (e.g. tony, ultron)", message="Message to send") async def cmd_relay(interaction: discord.Interaction, agent: str, message: str): @@ -88,7 +106,7 @@ async def cmd_relay(interaction: discord.Interaction, agent: str, message: str): await interaction.followup.send(embed=embed) - @tree.command(name="status", description="Check relay daemon status", guild=guild) + @tree.command(name="status", description="Check relay daemon status", guild=primary_guild) async def cmd_status(interaction: discord.Interaction): await interaction.response.defer(thinking=True) @@ -132,7 +150,7 @@ async def cmd_status(interaction: discord.Interaction): config_group = app_commands.Group( name="config", description="Configuration commands", - guild_ids=[config.discord.guild_id], + guild_ids=guild_ids or [config.discord.guild_id], ) @config_group.command(name="show", description="Show current configuration (ephemeral)") @@ -165,15 +183,21 @@ async def cmd_config_show(interaction: discord.Interaction): tree.add_command(config_group) - @tree.command(name="fleet", description="Show the FactoryLM agent fleet", guild=guild) + @tree.command(name="fleet", description="Show the FactoryLM agent fleet", guild=primary_guild) async def cmd_fleet(interaction: discord.Interaction): + fleet_table = _build_fleet_table(config) embed = discord.Embed( title="FactoryLM Fleet", - description=FLEET_TABLE, + description=fleet_table, color=discord.Color.blue(), ) await interaction.response.send_message(embed=embed) + # Copy commands to additional guilds (discord.py registers to primary, + # we sync to all guilds in events.py on_ready) + # Store guild list on tree for events.py to access + tree.__factorylm_guilds__ = guilds # type: ignore[attr-defined] + def cli_main() -> None: """Entry point for factorylm-bot command.""" diff --git a/services/factorylm-discord-layer/src/factorylm/bot/events.py b/services/factorylm-discord-layer/src/factorylm/bot/events.py index 447cf49..9bab9a8 100644 --- a/services/factorylm-discord-layer/src/factorylm/bot/events.py +++ b/services/factorylm-discord-layer/src/factorylm/bot/events.py @@ -10,6 +10,7 @@ import discord from discord import app_commands +from factorylm.config import get_all_guild_ids from factorylm.models import FactoryLMConfig logger = logging.getLogger(__name__) @@ -21,16 +22,29 @@ def setup_events( config: FactoryLMConfig, ) -> None: """Register event handlers on the Discord client.""" - guild = discord.Object(id=config.discord.guild_id) + guild_ids = get_all_guild_ids(config) + # Fallback to legacy single guild + if not guild_ids and config.discord.guild_id: + guild_ids = [config.discord.guild_id] @client.event async def on_ready(): - # Sync commands to the configured guild only - synced = await tree.sync(guild=guild) - logger.info("Synced %d slash commands to guild %s", len(synced), config.discord.guild_id) + # Sync commands to ALL configured guilds + total_synced = 0 + for gid in guild_ids: + guild_obj = discord.Object(id=gid) + try: + # Copy the primary guild's commands to this guild + tree.copy_global_to(guild=guild_obj) + synced = await tree.sync(guild=guild_obj) + total_synced += len(synced) + logger.info("Synced %d commands to guild %s", len(synced), gid) + except discord.HTTPException as exc: + logger.warning("Failed to sync commands to guild %s: %s", gid, exc) + logger.info("Bot is online as %s", client.user) - logger.info(" Guild ID: %s", config.discord.guild_id) - logger.info(" Agents: %s", list(config.discord.agents.keys())) + logger.info(" Guilds: %s", guild_ids) + logger.info(" Total commands synced: %d across %d guilds", total_synced, len(guild_ids)) logger.info( " Relay: %s:%s", config.relay.host, config.relay.port ) diff --git a/services/factorylm-discord-layer/src/factorylm/config.py b/services/factorylm-discord-layer/src/factorylm/config.py index 6406db7..ddaee2c 100644 --- a/services/factorylm-discord-layer/src/factorylm/config.py +++ b/services/factorylm-discord-layer/src/factorylm/config.py @@ -13,7 +13,7 @@ from pydantic import ValidationError -from factorylm.models import FactoryLMConfig +from factorylm.models import FactoryLMConfig, InstanceConfig DEFAULT_CONFIG_PATH = Path("~/.factorylm/config.toml").expanduser() @@ -62,6 +62,35 @@ def load_config(path: str | Path | None = None) -> FactoryLMConfig: return config +def get_all_instances(config: FactoryLMConfig) -> dict[str, InstanceConfig]: + """Return instances dict, falling back to legacy single-guild config. + + If config has instances defined, returns those directly. + Otherwise, synthesizes a single instance from the legacy guild_id + agents. + """ + if config.discord.instances: + return config.discord.instances + + # Fallback: wrap legacy single-guild config as a "default" instance + if config.discord.guild_id: + return { + "default": InstanceConfig( + name="default", + guild_id=config.discord.guild_id, + bot_token_env_var=config.discord.bot_token_env_var, + agents=config.discord.agents, + ) + } + + return {} + + +def get_all_guild_ids(config: FactoryLMConfig) -> list[int]: + """Return all guild IDs from instances (or legacy single guild).""" + instances = get_all_instances(config) + return [inst.guild_id for inst in instances.values() if inst.guild_id] + + def get_bot_token(config: FactoryLMConfig) -> str: """Read the bot token from the environment variable named in config. diff --git a/services/factorylm-discord-layer/src/factorylm/models.py b/services/factorylm-discord-layer/src/factorylm/models.py index 3647c25..b319974 100644 --- a/services/factorylm-discord-layer/src/factorylm/models.py +++ b/services/factorylm-discord-layer/src/factorylm/models.py @@ -15,12 +15,24 @@ class AgentConfig(BaseModel): role: str = "" +class InstanceConfig(BaseModel): + """Configuration for a single OpenClaw instance's Discord server.""" + + name: str + guild_id: int + bot_token_env_var: str = "DISCORD_BOT_TOKEN" + machine: str = "" + role: str = "" + agents: dict[str, AgentConfig] = Field(default_factory=dict) + + class DiscordConfig(BaseModel): """Discord server configuration.""" - guild_id: int + guild_id: int = 0 bot_token_env_var: str = "DISCORD_BOT_TOKEN" agents: dict[str, AgentConfig] = Field(default_factory=dict) + instances: dict[str, InstanceConfig] = Field(default_factory=dict) class TelegramConfig(BaseModel): diff --git a/services/factorylm-discord-layer/src/factorylm/relay/daemon.py b/services/factorylm-discord-layer/src/factorylm/relay/daemon.py index aabdd64..4aa32bf 100644 --- a/services/factorylm-discord-layer/src/factorylm/relay/daemon.py +++ b/services/factorylm-discord-layer/src/factorylm/relay/daemon.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import datetime import logging import signal import time @@ -16,6 +17,7 @@ import aiohttp from aiohttp import web +from factorylm.config import get_all_instances from factorylm.models import FactoryLMConfig from factorylm.relay.rate_limiter import WebhookRateLimiter @@ -40,6 +42,7 @@ def __init__(self, config: FactoryLMConfig) -> None: self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=500) self._drain_task: asyncio.Task | None = None self._shutting_down = False + self._last_heartbeat: float | None = None # Timestamp of last successful message relay self.rate_limiter = WebhookRateLimiter(max_queue_size=500) @property @@ -47,6 +50,12 @@ def app(self) -> web.Application: return self._app def _get_webhook_url(self, agent: str) -> str | None: + # Search across all instances first + for instance in get_all_instances(self.config).values(): + agent_config = instance.agents.get(agent) + if agent_config: + return agent_config.webhook_url + # Fallback to legacy top-level agents agent_config = self.config.discord.agents.get(agent) if agent_config: return agent_config.webhook_url @@ -54,7 +63,10 @@ def _get_webhook_url(self, agent: str) -> str | None: @property def _agent_names(self) -> list[str]: - return list(self.config.discord.agents.keys()) + names: set[str] = set(self.config.discord.agents.keys()) + for instance in get_all_instances(self.config).values(): + names.update(instance.agents.keys()) + return sorted(names) async def _get_session(self) -> aiohttp.ClientSession: if self._session is None or self._session.closed: @@ -76,6 +88,8 @@ async def _send_webhook( payload["content"] = content async with session.post(url, json=payload) as resp: if resp.status in (200, 204): + # Update heartbeat on successful message relay + self._last_heartbeat = time.time() return True logger.warning("Webhook returned %s: %s", resp.status, await resp.text()) return False @@ -123,9 +137,11 @@ async def _handle_relay(self, request: web.Request) -> web.Response: ) async def _handle_status(self, request: web.Request) -> web.Response: - """GET /status — agent list, queue depth, uptime.""" + """GET /status — agent list, queue depth, uptime, per-instance info.""" uptime = time.monotonic() - self._start_time agents_info = {} + + # Legacy top-level agents for name, agent_cfg in self.config.discord.agents.items(): agents_info[name] = { "channel_id": agent_cfg.channel_id, @@ -133,17 +149,61 @@ async def _handle_status(self, request: web.Request) -> web.Response: "role": agent_cfg.role, } - return web.json_response({ + # Per-instance info + instances_info = {} + for inst_name, inst_cfg in get_all_instances(self.config).items(): + inst_agents = {} + for name, agent_cfg in inst_cfg.agents.items(): + inst_agents[name] = { + "channel_id": agent_cfg.channel_id, + "machine": agent_cfg.machine, + "role": agent_cfg.role, + } + # Also add to flat agents list + if name not in agents_info: + agents_info[name] = inst_agents[name] + instances_info[inst_name] = { + "guild_id": inst_cfg.guild_id, + "machine": inst_cfg.machine, + "role": inst_cfg.role, + "agents": inst_agents, + } + + response: dict[str, Any] = { "agents": agents_info, "queue_depth": self.rate_limiter.total_queue_depth(), "messages_relayed": self._message_count, "uptime_seconds": round(uptime, 1), "shutting_down": self._shutting_down, - }) + } + if instances_info: + response["instances"] = instances_info + + return web.json_response(response) async def _handle_health(self, request: web.Request) -> web.Response: - """GET /health — {ok: true}.""" - return web.json_response({"ok": True}) + """GET /health — enhanced health endpoint with uptime, guild count, and heartbeat.""" + uptime = time.monotonic() - self._start_time + current_time = datetime.datetime.now(datetime.timezone.utc) + + # Format last_heartbeat as ISO string if it exists + last_heartbeat_iso = None + if self._last_heartbeat is not None: + last_heartbeat_iso = datetime.datetime.fromtimestamp( + self._last_heartbeat, datetime.timezone.utc + ).isoformat() + + return web.json_response({ + "ok": True, + "status": "healthy", + "service": "factorylm-discord-relay", + "timestamp": current_time.isoformat(), + "uptime_seconds": round(uptime, 1), + "guilds_count": max(1, len(get_all_instances(self.config))), + "guild_id": self.config.discord.guild_id or None, + "last_heartbeat": last_heartbeat_iso, + "version": "1.0.0" + }) async def _drain_worker(self) -> None: """Background task: pull from queue and post to webhooks.""" diff --git a/services/factorylm-discord-layer/src/factorylm/setup/config_writer.py b/services/factorylm-discord-layer/src/factorylm/setup/config_writer.py index 72f7b30..8661926 100644 --- a/services/factorylm-discord-layer/src/factorylm/setup/config_writer.py +++ b/services/factorylm-discord-layer/src/factorylm/setup/config_writer.py @@ -5,8 +5,14 @@ from __future__ import annotations +import sys from pathlib import Path +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + from factorylm.setup.discord_setup import SetupResult DEFAULT_OUTPUT = Path("~/.factorylm/config.toml").expanduser() @@ -14,6 +20,18 @@ # Agent channels that map to agent configs AGENT_CHANNELS = {"tony", "ultron", "jarvis", "hetzner"} +# Instance metadata +INSTANCE_META: dict[str, dict[str, str]] = { + "oc_macaroni": {"machine": "Mac Mini (100.108.19.94)", "role": "Boss agent, coordinator"}, + "ultron": {"machine": "DO VPS (100.68.120.99)", "role": "Cloud reasoning, web research"}, + "jarvis-local": {"machine": "Travel Laptop (100.83.251.23)", "role": "PLC/Modbus edge compute"}, + "hetzner": { + "machine": "Hetzner (100.67.25.53)", + "role": "Batch compute, large model inference", + }, + "jarvis-legacy": {"machine": "Legacy", "role": "Legacy operations"}, +} + def write_config( result: SetupResult, @@ -67,3 +85,139 @@ def write_config( path.write_text("\n".join(lines)) return path + + +def write_instance_config( + result: SetupResult, + instance_name: str, + output_path: str | Path | None = None, + bot_token_env_var: str = "DISCORD_BOT_TOKEN", +) -> Path: + """Merge an instance section into existing config.toml. + + Reads the existing config, adds/updates [discord.instances.], + then writes back. Creates the file with defaults if it doesn't exist. + + Args: + result: SetupResult from DiscordSetup.run_setup(). + instance_name: Instance key (e.g. "ultron", "jarvis-local"). + output_path: Where to write. Defaults to ~/.factorylm/config.toml. + bot_token_env_var: Env var name for this instance's bot token. + + Returns: + The path written to. + """ + path = Path(output_path) if output_path else DEFAULT_OUTPUT + path.parent.mkdir(parents=True, exist_ok=True) + + # Load existing config or start fresh + existing: dict = {} + if path.exists(): + with open(path, "rb") as f: + existing = tomllib.load(f) + + # Ensure top-level sections exist + discord_section = existing.setdefault("discord", {}) + instances = discord_section.setdefault("instances", {}) + + # Build instance data + meta = INSTANCE_META.get(instance_name, {}) + instance_data: dict = { + "name": instance_name, + "guild_id": result.guild_id, + "bot_token_env_var": bot_token_env_var, + "machine": meta.get("machine", ""), + "role": meta.get("role", ""), + "agents": {}, + } + + # Map agent channels to agent configs + for agent_name in sorted(AGENT_CHANNELS & set(result.channels)): + ch_id = result.channels.get(agent_name, 0) + webhook = result.webhooks.get(agent_name, "") + instance_data["agents"][agent_name] = { + "name": agent_name.capitalize(), + "webhook_url": webhook, + "channel_id": ch_id, + "machine": meta.get("machine", ""), + "role": meta.get("role", ""), + } + + instances[instance_name] = instance_data + + # Write back as TOML (manual serialization — tomllib is read-only) + _write_toml(existing, path) + return path + + +def _write_toml(data: dict, path: Path) -> None: + """Serialize config dict to TOML format and write to path.""" + lines: list[str] = [ + "# FactoryLM Discord Layer config", + "# Generated by factorylm-setup — edit as needed", + "# Bot token is read from env var, NEVER stored here", + "", + ] + + discord = data.get("discord", {}) + + # [discord] top-level + lines.append("[discord]") + if "guild_id" in discord: + lines.append(f"guild_id = {discord['guild_id']}") + if "bot_token_env_var" in discord: + lines.append(f'bot_token_env_var = "{discord["bot_token_env_var"]}"') + lines.append("") + + # [discord.agents.*] (legacy top-level agents) + for agent_name, agent_cfg in sorted(discord.get("agents", {}).items()): + lines.append(f"[discord.agents.{agent_name}]") + _write_agent_lines(lines, agent_cfg) + lines.append("") + + # [discord.instances.*] + for inst_name, inst_cfg in sorted(discord.get("instances", {}).items()): + lines.append(f"[discord.instances.{inst_name}]") + lines.append(f'name = "{inst_cfg.get("name", inst_name)}"') + lines.append(f'guild_id = {inst_cfg.get("guild_id", 0)}') + token_var = inst_cfg.get("bot_token_env_var", "DISCORD_BOT_TOKEN") + lines.append(f'bot_token_env_var = "{token_var}"') + if inst_cfg.get("machine"): + lines.append(f'machine = "{inst_cfg["machine"]}"') + if inst_cfg.get("role"): + lines.append(f'role = "{inst_cfg["role"]}"') + lines.append("") + + # Instance agents + for agent_name, agent_cfg in sorted(inst_cfg.get("agents", {}).items()): + lines.append(f"[discord.instances.{inst_name}.agents.{agent_name}]") + _write_agent_lines(lines, agent_cfg) + lines.append("") + + # [telegram] + telegram = data.get("telegram", {}) + lines.append("[telegram]") + lines.append(f'token_env_var = "{telegram.get("token_env_var", "TELEGRAM_BOT_TOKEN")}"') + lines.append(f'chat_id = {telegram.get("chat_id", 0)}') + lines.append("") + + # [relay] + relay = data.get("relay", {}) + lines.append("[relay]") + lines.append(f'host = "{relay.get("host", "127.0.0.1")}"') + lines.append(f'port = {relay.get("port", 8765)}') + lines.append(f'log_level = "{relay.get("log_level", "INFO")}"') + lines.append("") + + path.write_text("\n".join(lines)) + + +def _write_agent_lines(lines: list[str], agent_cfg: dict) -> None: + """Append TOML lines for an agent config dict.""" + lines.append(f'name = "{agent_cfg.get("name", "")}"') + lines.append(f'webhook_url = "{agent_cfg.get("webhook_url", "")}"') + lines.append(f'channel_id = {agent_cfg.get("channel_id", 0)}') + if agent_cfg.get("machine"): + lines.append(f'machine = "{agent_cfg["machine"]}"') + if agent_cfg.get("role"): + lines.append(f'role = "{agent_cfg["role"]}"') diff --git a/services/factorylm-discord-layer/src/factorylm/setup/discord_setup.py b/services/factorylm-discord-layer/src/factorylm/setup/discord_setup.py index de5aeed..762a172 100644 --- a/services/factorylm-discord-layer/src/factorylm/setup/discord_setup.py +++ b/services/factorylm-discord-layer/src/factorylm/setup/discord_setup.py @@ -174,10 +174,26 @@ def _webhook_url(webhook_data: dict) -> str: def cli_main() -> None: - """Entry point for factorylm-setup command.""" + """Entry point for factorylm-setup command. + + Flags: + --instance Instance key (e.g. "ultron"). Enables merge mode. + --merge Merge into existing config instead of overwriting. + --bot-token-env Env var name for this instance's bot token. + """ + import argparse import os import sys + parser = argparse.ArgumentParser(description="Set up Discord server for FactoryLM") + parser.add_argument("--instance", type=str, default="", help="Instance name (enables merge)") + parser.add_argument("--merge", action="store_true", help="Merge into existing config.toml") + parser.add_argument( + "--bot-token-env", type=str, default="DISCORD_BOT_TOKEN", + help="Env var name for bot token (default: DISCORD_BOT_TOKEN)", + ) + args = parser.parse_args() + token = os.environ.get("DISCORD_BOT_TOKEN", "") guild_id_str = os.environ.get("DISCORD_GUILD_ID", "") @@ -188,14 +204,24 @@ def cli_main() -> None: print("Error: DISCORD_GUILD_ID not set") sys.exit(1) - from factorylm.setup.config_writer import write_config + from factorylm.setup.config_writer import write_config, write_instance_config + + use_merge = args.merge or bool(args.instance) async def _run() -> None: setup = DiscordSetup(token, int(guild_id_str)) try: result = await setup.run_setup() - output_path = write_config(result) - print(f"Setup complete. Config written to {output_path}") + if use_merge and args.instance: + output_path = write_instance_config( + result, + instance_name=args.instance, + bot_token_env_var=args.bot_token_env, + ) + print(f"Setup complete. Instance '{args.instance}' merged into {output_path}") + else: + output_path = write_config(result) + print(f"Setup complete. Config written to {output_path}") finally: await setup.close() diff --git a/services/factorylm-discord-layer/tests/test_commands.py b/services/factorylm-discord-layer/tests/test_commands.py index 54c599a..f1dba39 100644 --- a/services/factorylm-discord-layer/tests/test_commands.py +++ b/services/factorylm-discord-layer/tests/test_commands.py @@ -6,7 +6,7 @@ import pytest -from factorylm.bot.commands import FLEET_TABLE, create_commands +from factorylm.bot.commands import _build_fleet_table, create_commands from factorylm.bot.events import setup_events from factorylm.models import AgentConfig, DiscordConfig, FactoryLMConfig, RelayConfig @@ -47,16 +47,22 @@ def test_registers_commands(self, config): class TestFleetTable: - def test_fleet_table_content(self): - assert "Tony" in FLEET_TABLE - assert "Ultron" in FLEET_TABLE - assert "Jarvis" in FLEET_TABLE - assert "Hetzner" in FLEET_TABLE - assert "100.108.19.94" in FLEET_TABLE - - def test_fleet_table_is_code_block(self): - assert FLEET_TABLE.strip().startswith("```") - assert FLEET_TABLE.strip().endswith("```") + def test_fleet_table_content(self, config): + table = _build_fleet_table(config) + assert "Tony" in table + assert "Mac Mini" in table + assert "Boss agent" in table + + def test_fleet_table_is_code_block(self, config): + table = _build_fleet_table(config) + assert table.strip().startswith("```") + assert table.strip().endswith("```") + + def test_fleet_table_no_instances(self): + from factorylm.models import DiscordConfig + empty_config = FactoryLMConfig(discord=DiscordConfig()) + table = _build_fleet_table(empty_config) + assert "No" in table class TestConfigShowHidesSecrets: diff --git a/services/factorylm-discord-layer/tests/test_daemon.py b/services/factorylm-discord-layer/tests/test_daemon.py index aa6b285..ea44585 100644 --- a/services/factorylm-discord-layer/tests/test_daemon.py +++ b/services/factorylm-discord-layer/tests/test_daemon.py @@ -2,6 +2,8 @@ from __future__ import annotations +import asyncio +import time from unittest.mock import AsyncMock, patch import pytest @@ -57,6 +59,14 @@ async def test_health_returns_ok(self, client): assert resp.status == 200 data = await resp.json() assert data["ok"] is True + assert data["status"] == "healthy" + assert data["service"] == "factorylm-discord-relay" + assert "timestamp" in data + assert "uptime_seconds" in data + assert data["guilds_count"] == 1 + assert data["guild_id"] == 999 # From config fixture + assert "last_heartbeat" in data # Can be None initially + assert data["version"] == "1.0.0" class TestStatus: @@ -121,6 +131,26 @@ async def test_relay_increments_count(self, client, daemon): await client.post("/relay", json={"agent": "ultron", "message": "msg2"}) assert daemon._message_count == 2 + @pytest.mark.asyncio + async def test_heartbeat_field_exists(self, client): + """Test that the heartbeat field exists in health response (can be None initially).""" + health_resp = await client.get("/health") + health_data = await health_resp.json() + assert "last_heartbeat" in health_data + # Initially it can be None (no messages sent yet) + + @pytest.mark.asyncio + async def test_heartbeat_initial_state(self, daemon): + """Test that heartbeat is initialized as None and can be updated.""" + # Initially heartbeat should be None + assert daemon._last_heartbeat is None + + # We can set it manually to simulate a successful send + import time + test_time = time.time() + daemon._last_heartbeat = test_time + assert daemon._last_heartbeat == test_time + class TestGracefulShutdown: @pytest.mark.asyncio diff --git a/services/jarvis-telegram/bot.py b/services/jarvis-telegram/bot.py index 1883f67..6936bb2 100644 --- a/services/jarvis-telegram/bot.py +++ b/services/jarvis-telegram/bot.py @@ -160,7 +160,7 @@ def main() -> None: providers = [] if config.groq_api_key: providers.append({"name": "groq", "base_url": "https://api.groq.com/openai/v1", - "api_key": config.groq_api_key, "model": "llama-3.3-70b-versatile"}) + "api_key": config.groq_api_key, "model": "moonshotai/kimi-k2-instruct"}) if os.getenv("CEREBRAS_API_KEY"): providers.append({"name": "cerebras", "base_url": "https://api.cerebras.ai/v1", "api_key": os.getenv("CEREBRAS_API_KEY"), "model": "llama3.1-70b"}) diff --git a/services/openclaw-configs/jarvis-local-openclaw.json b/services/openclaw-configs/jarvis-local-openclaw.json new file mode 100644 index 0000000..34319de --- /dev/null +++ b/services/openclaw-configs/jarvis-local-openclaw.json @@ -0,0 +1,239 @@ +{ + "meta": { + "lastTouchedVersion": "2026.1.24-3", + "lastTouchedAt": "2026-02-26T23:00:00.000Z", + "note": "Jarvis-Local config - Travel Laptop (100.83.251.23). Windows paths. Deploy to ~/.clawdbot/clawdbot.json" + }, + "env": { + "GROQ_API_KEY": "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" + }, + "models": { + "providers": { + "groq": { + "baseUrl": "https://api.groq.com/openai/v1", + "apiKey": "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", + "api": "openai-completions", + "models": [ + { + "id": "moonshotai/kimi-k2-instruct", + "name": "Kimi K2 (Groq)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 16384 + }, + { + "id": "meta-llama/llama-4-maverick-17b-128e-instruct", + "name": "Llama 4 Maverick (Groq)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 32768 + }, + { + "id": "qwen/qwen3-32b", + "name": "Qwen3 32B (Groq)", + "reasoning": true, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 32768 + }, + { + "id": "llama-3.3-70b-versatile", + "name": "Llama 3.3 70B (Groq)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 32768 + }, + { + "id": "llama-3.1-8b-instant", + "name": "Llama 3.1 8B Instant (Groq)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 8192 + } + ] + }, + "cerebras": { + "baseUrl": "https://api.cerebras.ai/v1", + "apiKey": "csk-2dfv34kpm68fnx6r4twhdk8n8jye5wjhmtjtd2v9nncwd3mh", + "api": "openai-completions", + "models": [ + { + "id": "gpt-oss-120b", + "name": "GPT-OSS 120B (Cerebras)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 8192 + }, + { + "id": "llama3.1-8b", + "name": "Llama 3.1 8B (Cerebras)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 8192 + } + ] + }, + "deepseek": { + "baseUrl": "https://api.deepseek.com/v1", + "apiKey": "sk-4a1441bac66940a3adc83e31e33987c0", + "api": "openai-completions", + "models": [ + { + "id": "deepseek-reasoner", + "name": "DeepSeek R1 Reasoner (671B)", + "reasoning": true, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 65536, + "maxTokens": 8192 + }, + { + "id": "deepseek-chat", + "name": "DeepSeek V3 Chat", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 65536, + "maxTokens": 8192 + } + ] + }, + "openrouter": { + "baseUrl": "https://openrouter.ai/api/v1", + "apiKey": "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", + "api": "openai-completions", + "models": [ + { + "id": "nousresearch/hermes-3-llama-3.1-405b:free", + "name": "Hermes 3 405B (OpenRouter Free)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 16384 + }, + { + "id": "meta-llama/llama-3.3-70b-instruct:free", + "name": "Llama 3.3 70B (OpenRouter Free)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 32768 + }, + { + "id": "openai/gpt-oss-120b:free", + "name": "GPT-OSS 120B (OpenRouter Free)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 16384 + } + ] + } + } + }, + "agents": { + "defaults": { + "model": { + "primary": "groq/moonshotai/kimi-k2-instruct", + "fallbacks": [ + "groq/llama-3.3-70b-versatile", + "deepseek/deepseek-chat", + "cerebras/gpt-oss-120b", + "openrouter/nousresearch/hermes-3-llama-3.1-405b:free", + "groq/meta-llama/llama-4-maverick-17b-128e-instruct", + "openrouter/meta-llama/llama-3.3-70b-instruct:free", + "openrouter/openai/gpt-oss-120b:free" + ] + }, + "workspace": "C:\\Users\\mike\\openclaw-workspace", + "memorySearch": { "enabled": true, "provider": "local" }, + "contextPruning": { "mode": "cache-ttl", "ttl": "5m" }, + "compaction": { "mode": "safeguard", "reserveTokensFloor": 4000 }, + "maxConcurrent": 2, + "subagents": { + "maxConcurrent": 2, + "model": { + "primary": "groq/moonshotai/kimi-k2-instruct", + "fallbacks": [ + "groq/llama-3.3-70b-versatile", + "deepseek/deepseek-chat", + "cerebras/gpt-oss-120b", + "openrouter/nousresearch/hermes-3-llama-3.1-405b:free", + "openrouter/meta-llama/llama-3.3-70b-instruct:free" + ] + } + } + }, + "list": [ + { + "id": "jarvis-local", + "default": true, + "agentDir": "C:\\Users\\mike\\clawd\\jarvis", + "tools": { + "allow": [ + "group:runtime", + "group:fs", + "group:sessions", + "group:messaging", + "group:web", + "group:memory" + ] + } + } + ] + }, + "tools": { + "agentToAgent": { "enabled": true, "allow": ["*"] } + }, + "messages": { + "ackReaction": "👀", + "ackReactionScope": "all", + "removeAckAfterReply": false + }, + "commands": { "native": "auto", "nativeSkills": "auto" }, + "session": { + "dmScope": "main", + "identityLinks": { + "mike": ["telegram:8445149012"] + }, + "reset": { "mode": "daily", "atHour": 9 } + }, + "channels": { + "telegram": { + "enabled": true, + "dmPolicy": "allowlist", + "botToken": "PLACEHOLDER_NEED_JARVIS_BOT_TOKEN", + "allowFrom": ["8445149012"], + "groupPolicy": "disabled", + "streamMode": "partial" + }, + "discord": { "enabled": false } + }, + "gateway": { + "port": 18789, + "mode": "local", + "auth": { "mode": "token", "token": "jarvis-local-gateway-token-2026" } + }, + "plugins": { + "entries": { + "telegram": { "enabled": true }, + "memory-core": { "enabled": true } + } + } +} diff --git a/services/openclaw-configs/ultron-openclaw.json b/services/openclaw-configs/ultron-openclaw.json new file mode 100644 index 0000000..b008b5a --- /dev/null +++ b/services/openclaw-configs/ultron-openclaw.json @@ -0,0 +1,239 @@ +{ + "meta": { + "lastTouchedVersion": "2026.1.24-3", + "lastTouchedAt": "2026-02-26T23:00:00.000Z", + "note": "Ultron config - DO VPS (100.68.120.99). Deploy: scp this to root@100.68.120.99:/root/.clawdbot/clawdbot.json" + }, + "env": { + "GROQ_API_KEY": "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" + }, + "models": { + "providers": { + "groq": { + "baseUrl": "https://api.groq.com/openai/v1", + "apiKey": "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", + "api": "openai-completions", + "models": [ + { + "id": "moonshotai/kimi-k2-instruct", + "name": "Kimi K2 (Groq)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 16384 + }, + { + "id": "meta-llama/llama-4-maverick-17b-128e-instruct", + "name": "Llama 4 Maverick (Groq)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 32768 + }, + { + "id": "qwen/qwen3-32b", + "name": "Qwen3 32B (Groq)", + "reasoning": true, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 32768 + }, + { + "id": "llama-3.3-70b-versatile", + "name": "Llama 3.3 70B (Groq)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 32768 + }, + { + "id": "llama-3.1-8b-instant", + "name": "Llama 3.1 8B Instant (Groq)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 8192 + } + ] + }, + "cerebras": { + "baseUrl": "https://api.cerebras.ai/v1", + "apiKey": "csk-2dfv34kpm68fnx6r4twhdk8n8jye5wjhmtjtd2v9nncwd3mh", + "api": "openai-completions", + "models": [ + { + "id": "gpt-oss-120b", + "name": "GPT-OSS 120B (Cerebras)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 8192 + }, + { + "id": "llama3.1-8b", + "name": "Llama 3.1 8B (Cerebras)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 8192 + } + ] + }, + "deepseek": { + "baseUrl": "https://api.deepseek.com/v1", + "apiKey": "sk-4a1441bac66940a3adc83e31e33987c0", + "api": "openai-completions", + "models": [ + { + "id": "deepseek-reasoner", + "name": "DeepSeek R1 Reasoner (671B)", + "reasoning": true, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 65536, + "maxTokens": 8192 + }, + { + "id": "deepseek-chat", + "name": "DeepSeek V3 Chat", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 65536, + "maxTokens": 8192 + } + ] + }, + "openrouter": { + "baseUrl": "https://openrouter.ai/api/v1", + "apiKey": "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", + "api": "openai-completions", + "models": [ + { + "id": "nousresearch/hermes-3-llama-3.1-405b:free", + "name": "Hermes 3 405B (OpenRouter Free)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 16384 + }, + { + "id": "meta-llama/llama-3.3-70b-instruct:free", + "name": "Llama 3.3 70B (OpenRouter Free)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 32768 + }, + { + "id": "openai/gpt-oss-120b:free", + "name": "GPT-OSS 120B (OpenRouter Free)", + "reasoning": false, + "input": ["text"], + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "contextWindow": 131072, + "maxTokens": 16384 + } + ] + } + } + }, + "agents": { + "defaults": { + "model": { + "primary": "groq/moonshotai/kimi-k2-instruct", + "fallbacks": [ + "groq/llama-3.3-70b-versatile", + "deepseek/deepseek-chat", + "cerebras/gpt-oss-120b", + "openrouter/nousresearch/hermes-3-llama-3.1-405b:free", + "groq/meta-llama/llama-4-maverick-17b-128e-instruct", + "openrouter/meta-llama/llama-3.3-70b-instruct:free", + "openrouter/openai/gpt-oss-120b:free" + ] + }, + "workspace": "/root/openclaw-workspace", + "memorySearch": { "enabled": true, "provider": "local" }, + "contextPruning": { "mode": "cache-ttl", "ttl": "5m" }, + "compaction": { "mode": "safeguard", "reserveTokensFloor": 4000 }, + "maxConcurrent": 2, + "subagents": { + "maxConcurrent": 2, + "model": { + "primary": "groq/moonshotai/kimi-k2-instruct", + "fallbacks": [ + "groq/llama-3.3-70b-versatile", + "deepseek/deepseek-chat", + "cerebras/gpt-oss-120b", + "openrouter/nousresearch/hermes-3-llama-3.1-405b:free", + "openrouter/meta-llama/llama-3.3-70b-instruct:free" + ] + } + } + }, + "list": [ + { + "id": "ultron", + "default": true, + "agentDir": "/root/clawd/ultron", + "tools": { + "allow": [ + "group:runtime", + "group:fs", + "group:sessions", + "group:messaging", + "group:web", + "group:memory" + ] + } + } + ] + }, + "tools": { + "agentToAgent": { "enabled": true, "allow": ["*"] } + }, + "messages": { + "ackReaction": "👀", + "ackReactionScope": "all", + "removeAckAfterReply": false + }, + "commands": { "native": "auto", "nativeSkills": "auto" }, + "session": { + "dmScope": "main", + "identityLinks": { + "mike": ["telegram:8445149012"] + }, + "reset": { "mode": "daily", "atHour": 9 } + }, + "channels": { + "telegram": { + "enabled": true, + "dmPolicy": "allowlist", + "botToken": "PLACEHOLDER_NEED_ULTRON_BOT_TOKEN", + "allowFrom": ["8445149012"], + "groupPolicy": "disabled", + "streamMode": "partial" + }, + "discord": { "enabled": false } + }, + "gateway": { + "port": 18789, + "mode": "local", + "auth": { "mode": "token", "token": "ultron-gateway-token-2026" } + }, + "plugins": { + "entries": { + "telegram": { "enabled": true }, + "memory-core": { "enabled": true } + } + } +} diff --git a/services/telegram/friday_bot.py b/services/telegram/friday_bot.py index a368c3a..a6ea52d 100644 --- a/services/telegram/friday_bot.py +++ b/services/telegram/friday_bot.py @@ -654,7 +654,7 @@ async def chat_with_ai(message: str, context_info: str = "") -> str: resp = await client.post( "https://api.groq.com/openai/v1/chat/completions", json={ - "model": "llama-3.1-8b-instant", + "model": "moonshotai/kimi-k2-instruct", "messages": [ {"role": "system", "content": system_prompt}, {"role": "user", "content": message} @@ -790,7 +790,7 @@ async def analyze_image(image_bytes: bytes, prompt: str = "What's in this image? key = OPENAI_API_KEY or GROQ_API_KEY payload = { - "model": "gpt-4o" if OPENAI_API_KEY else "llama-3.2-90b-vision-preview", + "model": "llama-3.2-90b-vision-preview" if GROQ_API_KEY else "gpt-4o", "messages": [ { "role": "user", diff --git a/services/telegram/jarvis_mio/remoteme/backend/services/command_parser.py b/services/telegram/jarvis_mio/remoteme/backend/services/command_parser.py index 9978d68..f9845dc 100644 --- a/services/telegram/jarvis_mio/remoteme/backend/services/command_parser.py +++ b/services/telegram/jarvis_mio/remoteme/backend/services/command_parser.py @@ -1,11 +1,12 @@ """ Command Parser Service ====================== -Parse natural language commands using Groq (primary) with Anthropic fallback. +Parse natural language commands using Groq (primary) with Cerebras/OpenRouter free fallbacks. """ import json import logging +import os from typing import Dict, Any, Optional import httpx @@ -102,35 +103,95 @@ async def _call_groq(text: str) -> Optional[Dict[str, Any]]: return None -async def _call_anthropic(text: str) -> Optional[Dict[str, Any]]: - """Call Anthropic API for command parsing (fallback).""" - if not settings.ANTHROPIC_API_KEY: +async def _call_cerebras(text: str) -> Optional[Dict[str, Any]]: + """Call Cerebras API for command parsing (fallback 1, free).""" + api_key = getattr(settings, 'CEREBRAS_API_KEY', None) or os.environ.get('CEREBRAS_API_KEY') + if not api_key: return None - + try: - import anthropic - client = anthropic.Anthropic(api_key=settings.ANTHROPIC_API_KEY) - - response = client.messages.create( - model="claude-3-haiku-20240307", - max_tokens=500, - system=COMMAND_PARSER_PROMPT, - messages=[{"role": "user", "content": text}] - ) - - result_text = response.content[0].text.strip() - - if "```" in result_text: - result_text = result_text.split("```")[1] - if result_text.startswith("json"): - result_text = result_text[4:] - - result = json.loads(result_text) - logger.info(f"Anthropic parsed '{text}' → {result}") - return result - + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + "https://api.cerebras.ai/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }, + json={ + "model": "llama3.1-8b", + "messages": [ + {"role": "system", "content": COMMAND_PARSER_PROMPT}, + {"role": "user", "content": text} + ], + "max_tokens": 500, + "temperature": 0.1 + } + ) + + if response.status_code == 200: + data = response.json() + result_text = data["choices"][0]["message"]["content"].strip() + + if "```" in result_text: + result_text = result_text.split("```")[1] + if result_text.startswith("json"): + result_text = result_text[4:] + + result = json.loads(result_text) + logger.info(f"Cerebras parsed '{text}' → {result}") + return result + else: + logger.warning(f"Cerebras API error: {response.status_code}") + return None + + except Exception as e: + logger.error(f"Cerebras parsing error: {e}") + return None + + +async def _call_openrouter(text: str) -> Optional[Dict[str, Any]]: + """Call OpenRouter API for command parsing (fallback 2, free).""" + api_key = getattr(settings, 'OPENROUTER_API_KEY', None) or os.environ.get('OPENROUTER_API_KEY') + if not api_key: + return None + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post( + "https://openrouter.ai/api/v1/chat/completions", + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + }, + json={ + "model": "meta-llama/llama-3.3-70b-instruct:free", + "messages": [ + {"role": "system", "content": COMMAND_PARSER_PROMPT}, + {"role": "user", "content": text} + ], + "max_tokens": 500, + "temperature": 0.1 + } + ) + + if response.status_code == 200: + data = response.json() + result_text = data["choices"][0]["message"]["content"].strip() + + if "```" in result_text: + result_text = result_text.split("```")[1] + if result_text.startswith("json"): + result_text = result_text[4:] + + result = json.loads(result_text) + logger.info(f"OpenRouter parsed '{text}' → {result}") + return result + else: + logger.warning(f"OpenRouter API error: {response.status_code}") + return None + except Exception as e: - logger.error(f"Anthropic parsing error: {e}") + logger.error(f"OpenRouter parsing error: {e}") return None @@ -176,16 +237,21 @@ async def parse_command(text: str) -> Dict[str, Any]: "confidence": 0.95 } - # Try Groq first (fast & free-tier friendly) + # Try Groq first (fast & free) result = await _call_groq(text) if result: return result - - # Fallback to Anthropic - result = await _call_anthropic(text) + + # Fallback to Cerebras (free) + result = await _call_cerebras(text) if result: return result - + + # Fallback to OpenRouter (free) + result = await _call_openrouter(text) + if result: + return result + # Last resort: fallback to interpret intent logger.warning("All AI parsers failed, using fallback") return { diff --git a/services/telegram/pepper/run.py b/services/telegram/pepper/run.py index bd1eb7c..b53c059 100644 --- a/services/telegram/pepper/run.py +++ b/services/telegram/pepper/run.py @@ -182,42 +182,85 @@ async def get_llm_response(self, user_id: int, message: str, message_type: str = }) as span: start = time.time() - try: - response = self.groq.chat.completions.create( - model="llama-3.3-70b-versatile", - messages=messages, - max_tokens=1024, - temperature=0.7, - ) - - assistant_message = response.choices[0].message.content - latency = (time.time() - start) * 1000 - - # Record telemetry - span.set_attribute("llm.model", "llama-3.3-70b-versatile") - span.set_attribute("llm.latency_ms", latency) - span.set_attribute("llm.tokens_input", response.usage.prompt_tokens) - span.set_attribute("llm.tokens_output", response.usage.completion_tokens) - span.set_attribute("response.length", len(assistant_message)) - span.set_attribute("action.type", self._detect_action_type(message)) - - # Add to history - history.append({"role": "assistant", "content": assistant_message}) - - telemetry = { - "trace_id": span.trace_id, - "latency_ms": latency, - "tokens": response.usage.total_tokens, - "model": "llama-3.3-70b-versatile", - "mode": mode - } - - return assistant_message, telemetry - - except Exception as e: - span.set_status("ERROR", str(e)) - logger.error(f"Groq error: {e}") - return f"🔥 Brain hiccup: {str(e)[:100]}", {"error": str(e)} + # Fallback chain: Groq Kimi K2 → Groq Llama 3.3 → Cerebras → OpenRouter + fallback_models = [ + ("groq", "moonshotai/kimi-k2-instruct"), + ("groq", "llama-3.3-70b-versatile"), + ("cerebras", "llama3.1-8b"), + ("openrouter", "meta-llama/llama-3.3-70b-instruct:free"), + ] + + last_error = None + for provider, model_id in fallback_models: + try: + if provider == "groq": + response = self.groq.chat.completions.create( + model=model_id, + messages=messages, + max_tokens=1024, + temperature=0.7, + ) + assistant_message = response.choices[0].message.content + usage_total = response.usage.total_tokens + usage_in = response.usage.prompt_tokens + usage_out = response.usage.completion_tokens + else: + import httpx as _httpx + base_url = { + "cerebras": "https://api.cerebras.ai/v1", + "openrouter": "https://openrouter.ai/api/v1", + }[provider] + api_key = { + "cerebras": os.environ.get("CEREBRAS_API_KEY", ""), + "openrouter": os.environ.get("OPENROUTER_API_KEY", ""), + }[provider] + if not api_key: + logger.warning(f"No API key for {provider}, skipping") + continue + async with _httpx.AsyncClient(timeout=30.0) as hclient: + resp = await hclient.post( + f"{base_url}/chat/completions", + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json={"model": model_id, "messages": messages, "max_tokens": 1024, "temperature": 0.7}, + ) + if resp.status_code != 200: + logger.warning(f"{provider}/{model_id} returned {resp.status_code}") + continue + data = resp.json() + assistant_message = data["choices"][0]["message"]["content"] + usage_total = data.get("usage", {}).get("total_tokens", 0) + usage_in = data.get("usage", {}).get("prompt_tokens", 0) + usage_out = data.get("usage", {}).get("completion_tokens", 0) + + latency = (time.time() - start) * 1000 + + span.set_attribute("llm.model", f"{provider}/{model_id}") + span.set_attribute("llm.latency_ms", latency) + span.set_attribute("llm.tokens_input", usage_in) + span.set_attribute("llm.tokens_output", usage_out) + span.set_attribute("response.length", len(assistant_message)) + span.set_attribute("action.type", self._detect_action_type(message)) + + history.append({"role": "assistant", "content": assistant_message}) + + telemetry = { + "trace_id": span.trace_id, + "latency_ms": latency, + "tokens": usage_total, + "model": f"{provider}/{model_id}", + "mode": mode + } + + return assistant_message, telemetry + + except Exception as e: + last_error = e + logger.warning(f"{provider}/{model_id} failed: {e}, trying next fallback") + continue + + span.set_status("ERROR", str(last_error)) + logger.error(f"All LLM fallbacks failed, last error: {last_error}") + return f"🔥 Brain hiccup: {str(last_error)[:100]}", {"error": str(last_error)} def _detect_action_type(self, message: str) -> str: """Detect the type of action from the message.""" diff --git a/services/tony-macaroni/soul/AGENTS.md b/services/tony-macaroni/soul/AGENTS.md index b723bc0..b466b08 100644 --- a/services/tony-macaroni/soul/AGENTS.md +++ b/services/tony-macaroni/soul/AGENTS.md @@ -51,18 +51,19 @@ --- -### hetzner — Batch Compute (Future) +### hetzner — Batch Compute & Large Inference | Field | Value | |-------|-------| | **Instance ID** | `hetzner` | -| **Bot Handle** | _(pending — needs bot creation)_ | +| **Bot Handle** | @UltronVPS_bot | | **Host** | Hetzner dedicated server | | **Tailscale IP** | 100.67.25.53 | | **Public IP** | 46.225.103.156 | -| **Status** | Fresh — needs full clawdbot install | +| **Config** | clawdbot 2026.1.24-3, Node v22 | +| **Status** | Active | -**Planned Capabilities:** +**Capabilities:** - Batch compute and large model inference - Training runs - Heavy data processing @@ -88,7 +89,6 @@ These are standalone Python/Node bots, not part of the clawdbot swarm. Tony shou | Bot | Handle | Purpose | |-----|--------|---------| -| Gus | @FactoryLM_bot | Factory floor Python bot | | FRIDAY | @FRIDAY_MCU_bot | Dev companion | | RemoteMe | @JarvisMIO_bot | PLC Copilot / VPS heartbeat | | Pepper | @Spicyclawd_bot | God-mode pepper service (Doppler-managed) | diff --git a/workers/.enricher_state.json b/workers/.enricher_state.json new file mode 100644 index 0000000..d0c067e --- /dev/null +++ b/workers/.enricher_state.json @@ -0,0 +1,3195 @@ +{ + "enriched_commits": [ + "c517e2c", + "bafbf2f", + "a4b9755", + "f57109a", + "eff3831", + "a40d972", + "84bd41c", + "f0019c6", + "27948bc", + "97361d4", + "1b88034", + "f2790f4", + "a5f97b7", + "57bae17", + "5b82846", + "5945d14", + "94be804", + "b2eb62c", + "4144399", + "820252c", + "1fecb3c", + "47c5cd1", + "b580edd", + "3344a97", + "0e5677d", + "4cc1f3e", + "64ad1ca", + "cff60cd", + "3af74ed", + "883cbe3", + "a20c620", + "1f8ce32", + "c15afa1", + "088ad94", + "68cc1d5", + "9b3d6a8", + "5802612", + "08b66a5", + "b4b515a", + "8ca02e3", + "af64586", + "f39e4f6", + "fe1928a", + "9845201", + "c5aaad8", + "671bfc7", + "b173d4d", + "ed77ab2", + "e726d41", + "a66e6fd", + "d7ce8a1", + "b01abff", + "a09569d", + "0f9fad1", + "a2c3cc8", + "b432251", + "06a7caf", + "c2f0b48", + "244429a", + "40fbce2", + "7604fbb", + "478bea4", + "a636ca5", + "6d4254f", + "baa7cba", + "aeee9f4", + "75a9c7b", + "c372108", + "dd6c54a", + "fb970fb", + "2cc1116", + "0dc33fa", + "0e50ea0", + "6209f4b", + "65dfdb3", + "4739d00", + "509b052", + "f8d83b6", + "05e8477", + "bfa1c5c", + "b57ad00", + "13a212b", + "2bc4b58", + "9efcc0e", + "a3a382d", + "e901131", + "c4175ad", + "0a6e2ad", + "5892b38", + "b0cbcbe", + "540b1f7", + "d13b388", + "16f9331", + "75d6724", + "a77b124", + "6d3ade2", + "2dbbdbc", + "68e7b0b", + "64d78dc", + "3d0d185", + "f88469b", + "91160b4", + "3fef035", + "f9012c3", + "3f1f831", + "7398f2a", + "4339d01", + "968820e", + "1d62e77", + "a42c32b", + "972c2c3", + "f58ea3f", + "ad76604", + "4bb3f9d", + "95e16e8", + "70d8fd2", + "28cd78d", + "69facf3", + "e9a5915", + "3a4f827", + "bee8413", + "9c8052c", + "8cff594", + "e4891b2", + "ddce940", + "b3f1659", + "a70e695", + "c587673", + "1f50766", + "2ef344e", + "132dad0", + "d84c8ce", + "7ecbdda", + "e81f8e8", + "c2b5b0f", + "33b5175", + "015205d", + "b20ce70", + "f60098e", + "0a7a4c2", + "c629c6e", + "78940c2", + "7d9c684", + "5d80299", + "5e377dc", + "3e72495", + "57179e9", + "e956434", + "8769f81", + "b8fc06f", + "d3d7591", + "00cdbd5", + "d214c17", + "5faea18", + "b6a2126", + "7f7109e", + "e14e03b", + "e7b507b", + "1c17244", + "a7e0411", + "bd359a4", + "6f07f5f", + "f2c2b56", + "c34635c", + "76c1fb3", + "8150db4", + "27e0b7d", + "ce19c8f", + "f6eddc6", + "1ebe8e9", + "3dbc463", + "850d388", + "8556e7c", + "8e5dc2d", + "f9e6128", + "8602880", + "83ee694", + "033f2ad", + "cf29387", + "450884c", + "f499083", + "7c48dbb", + "589602e", + "0016631", + "e2d2d90", + "34c9e15", + "2c3379c", + "4aad99f", + "f6040e3", + "3c39b57", + "b06806c", + "cbff67b", + "6497799", + "512b661", + "06a566f", + "3906f75", + "c47422c", + "49a0557", + "b0e8e4d", + "79fb689", + "ef3637d", + "4adac30", + "a5ddd3f", + "58d76fa", + "8f324ef", + "c087731", + "334f224", + "c50544e", + "908d358", + "e855c8e", + "a05a3ab", + "ccd62d1", + "19f5d46", + "4ea281e", + "5217bfb", + "64f560b", + "e9e8355", + "21ad22f", + "01d7435", + "a5df9e3", + "c56dad1", + "a7063ec", + "08a5b5b", + "c6cfac3", + "207f91b", + "4d480dd", + "493e6fc", + "791a3f2", + "1679706", + "34c3f15", + "86434e2", + "2d0e03d", + "94ea19e", + "9e40011", + "1ea45d1", + "a94a8de", + "1e0b46b", + "a5f24da", + "b0b43fd", + "c55b997", + "a7d01c1", + "b27ffeb", + "a3417a3", + "4fc9af9", + "145ff91", + "cae85b2", + "1e7b1da", + "d73c132", + "3162fcd", + "58b7a0b", + "90f8b8b", + "d478ec4", + "949daf1", + "ce1356f", + "694ac3d", + "7092666", + "b2d1a06", + "af1b717", + "8940053", + "9497bdb", + "1dab581", + "fe2afdf", + "066bbd3", + "9ab8b8f", + "7a97ac4", + "81c7e35", + "033b16f", + "9354075", + "f00d320", + "f52b218", + "7384314", + "18891a3", + "0906da0", + "d1181fb", + "32727ae", + "d8f1f3f", + "8dd0d3b", + "56ec572", + "a71099e", + "07a14f5", + "264e8e4", + "17fe7ff", + "d009156", + "2245635", + "d7bc92a", + "6c71aed", + "bb58f18", + "75abeae", + "8f3697a", + "eee625b", + "67d4b9f", + "dc2bf5e", + "a088485", + "7218019", + "0861d43", + "f42aa82", + "4bbe550", + "61b6772", + "b88640b", + "c3d51a5", + "ecd7ff1", + "cd6916a", + "ab39d5c", + "bbdc6b3", + "daa846c", + "d6863af", + "555332a", + "b7ae480", + "28bd8cd", + "23e7208", + "6058a0b", + "9f47213", + "c19d904", + "29a8534", + "6c0d03c", + "7b305ca", + "486c4fa", + "dcb97ef", + "5c14aa8", + "018c2c2", + "484db20", + "5d50d27", + "179cfc4", + "0aae693", + "306e8ba", + "e7f6380", + "01eb512", + "9bea82f", + "5fb154e", + "8af1f46", + "571b13c", + "d1bc7a3", + "c0fa8d8", + "72ebf93", + "21bf56c", + "c345a03", + "e4d807a", + "9fdae15", + "8f7b31b", + "60b854a", + "6ec7b5d", + "3cb2971", + "7219360", + "c07723e", + "21f9da6", + "c9fe562", + "516a626", + "2335f3c", + "83e23d9", + "3b9c450", + "3611a93", + "90a277c", + "1e2a372", + "e09e34b", + "58c7c8c", + "52a23bb", + "f55b368", + "3b9426f", + "9d2f455", + "095e90d", + "f1df5ed", + "5a080ad", + "7282eea", + "d9a0e94", + "f1db9f8", + "16e3c53", + "ff30b78", + "e82f11d", + "cbfb75a", + "275d5d7", + "e2eea96", + "dac5010", + "f192385", + "493ded2", + "dde9d68", + "8669785", + "9c42017", + "515f182", + "88c3692", + "fad6cf5", + "b45160a", + "1db6572", + "b883a94", + "3da5fe7", + "2922bb2", + "e65af64", + "7a66c9f", + "a59acc0", + "036c14e", + "2ff4f6b", + "8d7277f", + "149c26c", + "8d28797", + "d5b46d3", + "4ce82b1", + "6bb22c4", + "18bdcb6", + "e8f0db0", + "a6fcaa1", + "bafc729", + "7863056", + "6693a4c", + "45a204f", + "cb3d646", + "bcd4956", + "769e902", + "33375c9", + "12be813", + "368f3a1", + "6878686", + "4ebeed7", + "2196eb7", + "05057ef", + "9f9d673", + "e88b32e", + "6e71cc8", + "6ec12f8", + "aa18172", + "4a05d56", + "b443d3e", + "1ab0d1a", + "d76a77f", + "463cbf9", + "cc38425", + "096db11", + "a4424a0", + "f0e1007", + "3ebf797", + "302bde6", + "1f7c267", + "d1c04c7", + "b037fa1", + "d148998", + "d11dedd", + "7c2ffe9", + "ac789d6", + "163d056", + "aa4f8cc", + "d809af8", + "148ef4c", + "1689d93", + "5e72917", + "b8b304f", + "154d15e", + "67c19ac", + "3ca3281", + "926592e", + "d85285a", + "f32e573", + "8c8bb1f", + "a1fabc5", + "f6b1797", + "ac5b704", + "12e55fa", + "b7738da", + "6985320", + "6356477", + "cd9d8d4", + "f06aaf9", + "68e7252", + "c16b14a", + "c868c62", + "847a750", + "cbff3b1", + "7661df6", + "e2a5b6d", + "6077c1b", + "1144f49", + "46a7f1d", + "e59c8d2", + "2a805bd", + "7b54cd1", + "3f15b9e", + "1771ff9", + "148e3cf", + "acba3b2", + "019b8c7", + "f6791e4", + "d63f300", + "761db2f", + "da1aed3", + "13f815e", + "5ce9bc8", + "9b19d70", + "0e95f67", + "ada0644", + "b249ce3", + "1bc8284", + "9756e4f", + "49de837", + "1e2f1b0", + "e608636", + "d15b5ce", + "61f6cd6", + "80df9ac", + "558b151", + "f7adea2", + "5294c44", + "4c459de", + "6c4b295", + "14aa728", + "7c5a502", + "fa301f9", + "c472e09", + "c0860ba", + "f66680b", + "ad44565", + "176c005", + "aa01af0", + "d49f975", + "3ea82e1", + "8cef987", + "2e6d1a6", + "46cb725", + "6319726", + "88016b0", + "8e2a55c", + "1ca9cf6", + "320927a", + "739ec4f", + "f70e77b", + "a5ed927", + "61ffa97", + "317b494", + "000fadd", + "f24bd68", + "9b58ef4", + "9c4664c", + "1f681ef", + "5c0d996", + "31c6b1c", + "dbdd6b7", + "7ebf45c", + "6e5cd14", + "8ae0d54", + "97c41d6", + "cba95fa", + "95c4669", + "9d7dfe9", + "3c170b8", + "e0ba48b", + "44f11ac", + "783a2ea", + "49abe51", + "db267fa", + "90c14fc", + "a7eae47", + "ec1f4a3", + "3f553e9", + "d3e4d28", + "dc014e5", + "152c4c6", + "5f1b110", + "ebcf32d", + "f462632", + "7a81ae6", + "b67291e", + "73e4a5a", + "89dd626", + "488b9ba", + "c7c4732", + "4f1b74d", + "eae33f5", + "3b43036", + "06341d3", + "74be1f5", + "23e43e3", + "ec801f1", + "3da377f", + "6c81e25", + "6ddffb1", + "6048746", + "06af0a9", + "fe46e69", + "110ecec", + "6e93485", + "dd99aca", + "77c5e27", + "b56b1da", + "f4fc515", + "7614ac5", + "fd896b4", + "da1416c", + "6e12341", + "b119989", + "8910f51", + "2aefbde", + "fdd77de", + "d8324cb", + "f365592", + "b383c71", + "213b99c", + "f8ceb8e", + "08beec7", + "fa93ccd", + "3b6850c", + "48011c6", + "7e93be9", + "cdea1af", + "2a2b8b8", + "c8065f2", + "8a026b5", + "684b122", + "59e01dc", + "f4652f6", + "9d1186d", + "0b72087", + "142b4fe", + "13ff139", + "fb5b78e", + "081892f", + "950a685", + "0629f4a", + "5214539", + "df152a5", + "a31f718", + "89d9844", + "20c640e", + "243d986", + "5a6c11d", + "83d2803", + "4fb7024", + "f1c9989", + "25be8d1", + "93d63cb", + "1b78576", + "aa088fc", + "b5ae7d2", + "e25bf37", + "a2fd513", + "9c6a587", + "2fc65fb", + "702ce41", + "9d394d6", + "d4fc8d1", + "512694a", + "15c910c", + "4582ebc", + "49ca42e", + "b81c07c", + "82f56e9", + "f9f6183", + "267580b", + "02d1f64", + "daa1a98", + "1bfb6c8", + "516a1a3", + "dab623e", + "730f9c7", + "a25d58f", + "9bc462f", + "e72fba4", + "2e9e7a0", + "1631aee", + "509a969", + "fffabbe", + "741d62e", + "40c006c", + "ccb4049", + "f525f33", + "cf9caaf", + "af31411", + "6c677e8", + "030b12b", + "7802a7a", + "e4ea142", + "ecd9af7", + "caae9f3", + "f46dfa3", + "28e562d", + "e61e628", + "09bd082", + "f6c7db7", + "2a49f63", + "dd694ec", + "aca3670", + "3e8d3fd", + "b628e29", + "ec74f88", + "6af32cb", + "5c4fc34", + "ed42af1", + "0a3007c", + "50035b8", + "16ca558", + "c729967", + "5c62546", + "8a03c39", + "4c15b30", + "42c8a35", + "318bec9", + "1c2bab2", + "3edb20f", + "bd33abe", + "fc247cb", + "0ae503c", + "26e5933", + "03bee8c", + "d9e019d", + "a9c7d7f", + "03facf6", + "da0ef6d", + "52d5559", + "8d72cad", + "ba8ce7c", + "050714c", + "d318236", + "0f9464e", + "238cc2f", + "d56b4de", + "5b646ac", + "ca8d24e", + "3dc3479", + "1ba55a4", + "9110e3d", + "81fac49", + "369e118", + "253c888", + "0ff6012", + "94cb749", + "d0cb80f", + "ae5c4d9", + "f623012", + "fce57e2", + "a566bb9", + "a68febc", + "0935dd8", + "abce781", + "71480de", + "076308b", + "4679b16", + "e28d20b", + "bada82e", + "8123e8f", + "d0208c4", + "1c72010", + "8520881", + "1c9059b", + "bcc3db9", + "6dde9cb", + "4b1ca9b", + "6ace046", + "cafaacd", + "274f496", + "d3310d1", + "3a474b4", + "a2e7e93", + "fdff095", + "cad49b4", + "f200ac4", + "367feb4", + "4132fa8", + "7a82d3e", + "7c30e4b", + "eacf1e4", + "3d91d92", + "b06d979", + "27df197", + "a6587aa", + "c68e484", + "4a83b87", + "3503b9b", + "31ba3eb", + "6688c27", + "4da4d52", + "30cdf78", + "0fbb92a", + "6c08757", + "970e683", + "7db4a63", + "0c0b0b6", + "f286710", + "3e76f80", + "5823003", + "54963d4", + "7407f0f", + "fffcc26", + "f476022", + "c7c6c93", + "50f6ef7", + "e7e54d8", + "da5640e", + "aab6d90", + "0d30de4", + "ddbed08", + "282d272", + "2e6f381", + "d1a5d27", + "d109a59", + "870181b", + "3540c1e", + "afa22d1", + "5e7b782", + "7636770", + "99a2da5", + "c65256a", + "0b24de8", + "842e766", + "222027d", + "4735f2c", + "a461fbd", + "274f110", + "1ee96f5", + "1fcb367", + "4a12084", + "0ae9757", + "04b290a", + "cf4a3dc", + "9b24633", + "a95caee", + "7b22645", + "7683941", + "f78b935", + "e3f37bf", + "6877ac9", + "45d56cf", + "a7bbf8f", + "9b507bd", + "80cfdd6", + "1740dd1", + "74aef1e", + "96f412c", + "bdb4798", + "599d04b", + "6e2239b", + "10c1b5a", + "68abacd", + "05a4533", + "b2303d2", + "b6b127a", + "db0b35f", + "4a8c6ef", + "fc31c70", + "d45c6ec", + "c0db51e", + "379ca7f", + "06aea76", + "4ad3b6d", + "e6068fc", + "46e472d", + "78d1281", + "144f3b1", + "b781a20", + "ee99df6", + "f5a9140", + "8d12c68", + "8e18d94", + "f33bc3f", + "3d4253b", + "e8f5197", + "2160b2f", + "38617ee", + "6c35906", + "26bb426", + "aab10b4", + "1ff1047", + "f7c5fca", + "53d9412", + "fbd397d", + "b3a15e3", + "5c1ec18", + "b4845d2", + "906cc5a", + "991ba77", + "f0ebeb9", + "60dd5f0", + "e85f299", + "5d4b42a", + "53d6d82", + "d316d4d", + "113f136", + "8063562", + "6ab11ac", + "9806f17", + "6349b7c", + "8e58e92", + "fb0381d", + "06bd729", + "f2ec44c", + "9ba9bbf", + "3490a98", + "3b4f5f0", + "d9960e4", + "f705e90", + "f8b5c04", + "4b1f538", + "0560c66", + "fdc5d46", + "6b68b17", + "ec4413c", + "abe1539", + "fb8aa00", + "f492107", + "56518f6", + "eb4e720", + "256b075", + "4f6aae9", + "6c647ef", + "70279c0", + "3d7db2c", + "42b3a1e", + "38aa53c", + "8158e38", + "63590b3", + "890720a", + "90fb558", + "8fc5375", + "e370c1e", + "a98a167", + "fa24fef", + "ae20717", + "844cac3", + "d46bc0f", + "832eb81", + "7d977ab", + "a714c31", + "5a66de7", + "e27bf0d", + "7ad033d", + "bd9a3df", + "ac555ad", + "079da95", + "99fcedd", + "ba33131", + "0ecbdd5", + "f1b3f6f", + "00761bc", + "45c904a", + "7b2c9be", + "09ed87a", + "786697c", + "97a17ac", + "0297908", + "27a6ed1", + "2c4e5d6", + "20d5d2d", + "945976c", + "b3dbb58", + "68e6ce1", + "d29d521", + "3de9982", + "069eb87", + "1e73d57", + "4a65091", + "4c934eb", + "870b22e", + "42aa384", + "c6e0562", + "ab10396", + "6b8caa4", + "b2f7024", + "49a82ca", + "d760643", + "01c6678", + "447d737", + "cf76dfa", + "3e1bc2b", + "355eb9b", + "07d6244", + "3ec7777", + "996173a", + "29bf17d", + "4f38ca5", + "b0e0c46", + "4bdd678", + "f8899a8", + "bcf0bd1", + "cdf4a1f", + "1e28eb4", + "86c4e1e", + "7325200", + "a6fd690", + "6c789f9", + "aed2fbd", + "483fa8a", + "dfe6c16", + "1bc854d", + "f9fab2b", + "30e4334", + "b0f42a4", + "29e86ed", + "d898c09", + "8f034da", + "837933f", + "1fa2cc2", + "f589126", + "884e397", + "becff74", + "0382582", + "8991898", + "e58a01b", + "e939fc8", + "057be00", + "9c9caef", + "d62c7e4", + "f4537f4", + "1bd08a7", + "a1540b7", + "43bcb06", + "d3b27c6", + "10c9dc2", + "bd121f7", + "49fd7dc", + "742c5f3", + "5601b07", + "965f47b", + "2461d12", + "706d603", + "47ea782", + "7d50726", + "ed738ca", + "20a8500", + "0dec25d", + "be1406b", + "7dd9133", + "d3eedf4", + "8c19681", + "2d4c1df", + "1b67817", + "16cd206", + "43c177d", + "553afff", + "b733d30", + "929d63c", + "a527961", + "fc71483", + "816faa1", + "610c7e1", + "8577ca8", + "c2af690", + "e795901", + "498f9b7", + "a3c4224", + "6981a70", + "ff7172f", + "a30955d", + "7d2db5d", + "a44dadb", + "97efa70", + "a3e4774", + "751911b", + "4ca60fb", + "199ee6c", + "c428b01", + "d39da0f", + "8080dce", + "fece31a", + "6d3c8fc", + "b11aaf4", + "fc610ee", + "386ca12", + "c9dad29", + "cc13050", + "3166e7f", + "5711e5f", + "46d63a1", + "f294e08", + "ddea80b", + "f74b777", + "de099f9", + "6fe7291", + "437033b", + "9eed3bf", + "dd09e06", + "2b29b44", + "a16f431", + "e21a1a6", + "8fe4804", + "4dd6b7a", + "4d14964", + "68806b8", + "2c8cbbe", + "5f9571e", + "934acad", + "ef97b37", + "3ea17e0", + "7958009", + "d0d85b4", + "d78a599", + "46af32a", + "afb7491", + "2b91a20", + "6b2abbe", + "2a58a2c", + "127087b", + "66ab044", + "0fa8641", + "a54b280", + "f34018a", + "d8a621f", + "70c739d", + "37e5071", + "dedf621", + "8e1c243", + "30c45a0", + "8177135", + "57887bc", + "ea6f758", + "960e8f7", + "4fc1009", + "80a8fd0", + "d9d974d", + "ce687db", + "d85c374", + "3a2a5e2", + "49f4a1d", + "d38b43c", + "d79e06d", + "a84787b", + "f2ec4bd", + "9e2761d", + "2161da0", + "e29cd1f", + "9cf96de", + "a95fe79", + "339e078", + "685915b", + "fff05c0", + "7b9cbba", + "8e89899", + "7cefa0b", + "fd74c0b", + "7cbceaf", + "2f74d6a", + "584e435", + "a6ed135", + "58d478f", + "bc7315d", + "9346c52", + "8f97a66", + "83637d0", + "ad95786", + "3ec76f4", + "6af9d84", + "e0657e5", + "d283ba0", + "bb7e096", + "8cab271", + "a1f91d9", + "78308fc", + "38b199d", + "4cd9c71", + "118e1b9", + "54bd1e2", + "66d2ac7", + "446a1d6", + "f8a04c3", + "90c99b9", + "cfaa319", + "ef7e82d", + "b969fa9", + "f137e5e", + "7d16ea9", + "64ab66a", + "9eb86da", + "802406d", + "47ebcf0", + "818d80a", + "b5f2eed", + "efe4c76", + "b29697b", + "934ac07", + "cc785d3", + "864100d", + "fe4fdf9", + "1aa01df", + "6e7ebd9", + "9af79e4", + "3cd8872", + "82c9891", + "726d470", + "73d9e26", + "f676919", + "c2bc284", + "424b9fd", + "c76c11b", + "f43455a", + "3545499", + "2f065ba", + "abbeeea", + "35d90da", + "41031bd", + "461ac34", + "324660c", + "6dcdf81", + "cdf27e6", + "cda3355", + "b27b68f", + "c6cd116", + "54b5a66", + "3b5dd4c", + "2c22354", + "5c0f4b2", + "620789d", + "d2122c7", + "9f87627", + "75d563f", + "3500290", + "18be9f1", + "7339ab4", + "0c845b4", + "910510c", + "892f228", + "4c0ec92", + "e694f1f", + "6e59906", + "21c3c77", + "fe7f921", + "b95beef", + "c75f003", + "6b5bdfb", + "21fcb77", + "44fb433", + "033b4b2", + "35574fb", + "84050c0", + "4aab1e1", + "671e672", + "39c227b", + "dfc4e7f", + "ecd8089", + "cc207ef", + "7511ec1", + "e953252", + "015bc9e", + "b85e4d6", + "25fb959", + "c5e7756", + "e1ea552", + "99a7aff", + "25201f4", + "f22f214", + "44dc2d2", + "6fefa61", + "5348197", + "3e5ff23", + "8e2ca94", + "b5b7b26", + "012701c", + "c283552", + "d11654e", + "113f929", + "b5a180f", + "5ced053", + "8ec2701", + "a3de2a1", + "946523b", + "eefeeae", + "3659ba7", + "36c8847", + "d0a4b82", + "d17688c", + "00e229d", + "11ec35a", + "78bc598", + "9c04244", + "acae790", + "15e4f7a", + "bc9c271", + "743f282", + "271e1e4", + "042c42f", + "f196460", + "8f7e221", + "7ae25e3", + "b5656d5", + "e0c2685", + "223fc57", + "7aa3b07", + "9698ecc", + "17bf14d", + "927efdc", + "127b422", + "008c8ea", + "930a4c7", + "8a5dd95", + "e613c92", + "28f6a83", + "c6c0a21", + "402e313", + "1826473", + "be117fe", + "7de655f", + "1e0e66a", + "2548492", + "61d8057", + "1c490e4", + "ef54b9d", + "7f240e5", + "d0c4bb9", + "64f8a09", + "18b8b77", + "27eac86", + "b77f85f", + "1841ffc", + "ead6db8", + "2474b1f", + "fb23581", + "4515d2b", + "27b3507", + "b49c410", + "617dcdd", + "20f3418", + "0edd890", + "7b5da8d", + "d473bfc", + "f6d2b04", + "6d6f28f", + "2a95821", + "a66f106", + "ba659c0", + "d32d3cb", + "e25f96b", + "9809f6a", + "01f639b", + "5d98188", + "bc150a1", + "8d4719b", + "dbf79d0", + "dcd1a75", + "46c3504", + "84d716c", + "3acc19c", + "cc5cc23", + "33f10ed", + "0f5160e", + "9facb48", + "55fbf8c", + "cd9d5f9", + "fd9db15", + "6c4b71e", + "e2f58f1", + "c471f8a", + "fc574aa", + "09678b4", + "c120dfe", + "a55ee1f", + "f6c50f7", + "3c1bb49", + "e50741a", + "3534db9", + "0c7cfc1", + "4eb586d", + "666a3aa", + "a50d18e", + "4fd98da", + "ddc1dac", + "f49b929", + "b20db0f", + "7083aef", + "4c37a97", + "1a50125", + "1546bc7", + "ee48134", + "27b8d3c", + "b474dd7", + "1ed9c98", + "d0ebdf9", + "ce55f68", + "e3ab49d", + "a987e9f", + "e1aa277", + "7f02dbb", + "d2b0d22", + "cf8288d", + "834e748", + "fefbbf0", + "91d3d09", + "3451b00", + "1e3083b", + "a1b9430", + "b70e808", + "cb50a49", + "a892e69", + "25b5f03", + "a00b505", + "fef9fb1", + "c7f74e9", + "76885c6", + "5217df0", + "5562252", + "b2fe841", + "5d6e73f", + "38d712f", + "14158fb", + "f0e5944", + "9b615dd", + "ff52a33", + "427aae5", + "c9d8a3c", + "56bf535", + "5fcdc69", + "d6c2ce9", + "4c8f9c2", + "7b754c0", + "750844c", + "ce18b4a", + "c13fe4f", + "61cea3a", + "78fc17c", + "51a21d1", + "e175a74", + "5bbf5b3", + "14febe6", + "2603f11", + "3c80452", + "3d5a32f", + "9a4e328", + "5939a10", + "071a0d3", + "45ba7cf", + "165c896", + "b6eb6f8", + "6f67366", + "88c86f2", + "de2f04f", + "d08fef7", + "5212425", + "f477dcd", + "a417344", + "f0fb194", + "d72d929", + "97dd833", + "85c404b", + "f3bbb8d", + "f2b5481", + "25b3567", + "0365835", + "b4d6d59", + "a92ee7c", + "4810b37", + "0931ea3", + "855569d", + "c8a784f", + "280c574", + "1f778f1", + "ba3a349", + "f8474b2", + "f18f8b9", + "20e8a47", + "3e3f19f", + "aba5aee", + "2a75ae7", + "42a4779", + "7b7e5f1", + "84aebb6", + "150cacb", + "d932396", + "e04c9fa", + "a371774", + "4c854e6", + "24cf97f", + "83d7bab", + "49cf2a6", + "6e0032f", + "e00515a", + "26276ca", + "2feab12", + "dabaea9", + "79913df", + "ba76c8a", + "1e931f6", + "f3f8b50", + "0c1d859", + "c3324dc", + "ae49774", + "74eee9f", + "cb18089", + "ed79f2f", + "c6aaadb", + "9a67197", + "81e5161", + "6fb272e", + "ca9d331", + "6f3dc2e", + "d2f6e41", + "b25c358", + "2b00466", + "71e1c39", + "a985e78", + "63d0164", + "c9da8cf", + "40419fd", + "ffd5c7b", + "b9b84ef", + "e6c88af", + "2b236ab", + "a44ebfa", + "6a10e8f", + "6a07724", + "0410f74", + "450d221", + "7fdca1a", + "f417cdb", + "8ac4c42", + "4514d1f", + "6058f97", + "d66d93e", + "e9e888a", + "7b00a55", + "8b09cc7", + "8ad179b", + "9bcb7ff", + "218bf32", + "3ad1970", + "bc0eebb", + "756fba2", + "63f8f35", + "4e2e572", + "dfdeb63", + "71d4d53", + "c007ff0", + "178e940", + "3523ec4", + "5555748", + "ee2e9d5", + "04e55aa", + "027268c", + "371f812", + "89d5ed1", + "848342b", + "727229d", + "5246a57", + "8c3f9a7", + "f54ffe3", + "045d000", + "74ead48", + "f15f921", + "1e7b717", + "ab97c36", + "7b3031e", + "fa9d34b", + "f200d17", + "a9e119c", + "cdc5f5a", + "cfdb26e", + "473e1df", + "4652f0a", + "1ee996e", + "ca28457", + "16dc3a5", + "5cdfdd0", + "55baaf2", + "37efbc5", + "27b1943", + "e202f6d", + "9d5ee66", + "a531e5b", + "b48b698", + "21315fe", + "dea566f", + "5ed714e", + "0c75ca0", + "4ef260e", + "bea812b", + "779f2fc", + "c3378a8", + "43d1067", + "eea87da", + "7b99e42", + "99af5ce", + "09a73c7", + "65d1551", + "b3631ae", + "7e04e51", + "22c9b67", + "9647d9e", + "b54f29a", + "4f4d80c", + "b49778d", + "ae669c0", + "64f5f30", + "af28b84", + "bd2973d", + "3514499", + "bf077dc", + "763f2aa", + "f835c29", + "a0797a6", + "3066fce", + "763e53b", + "aa8e901", + "2dad75e", + "22a32f1", + "67fda32", + "975b05b", + "e5d8e74", + "8b0128c", + "f82034f", + "8d6ece6", + "a31f5c2", + "a809f65", + "ebb353b", + "dfdf76d", + "235293e", + "71bf504", + "dc84f2f", + "321cfcb", + "ddd5726", + "3fcdf15", + "8c3ccb7", + "2bb202b", + "6553b1b", + "8b418d9", + "92c69f4", + "622be55", + "506c935", + "b69a20b", + "1f818c2", + "aade312", + "94f1e22", + "2f3a5bb", + "1c8751c", + "ac0f2be", + "ad7eb3f", + "cf2001f", + "857790c", + "6369646", + "232cf56", + "4febe23", + "9fd0449", + "3d6f242", + "c14e8c7", + "69d3706", + "e270605", + "99a1c95", + "5cc2203", + "bf2341e", + "149f5ea", + "9deb7d9", + "cb4cc24", + "a0ac989", + "1090cea", + "59c3e78", + "c10ce24", + "cc5f6a6", + "d453769", + "d1b24cb", + "0720507", + "ca21697", + "7e83f09", + "0e895d6", + "4a8de71", + "6e39187", + "239203b", + "3661088", + "4b1dc8d", + "ec5f2e3", + "9098318", + "23797cb", + "9b2b4aa", + "e449d34", + "d9903f8", + "8ecff00", + "812d4ef", + "dadd657", + "53d0034", + "f46e845", + "10457f7", + "1c5e71b", + "2dbe58c", + "313bb67", + "aad34b8", + "cc3ac84", + "da5835d", + "1a004ec", + "95b45db", + "a34ad51", + "0273bab", + "b82b8cf", + "2cb3253", + "25c0664", + "b1be263", + "4fd10f4", + "049b5ec", + "6d77a95", + "ce99dbf", + "99d9aad", + "62ee906", + "595a6dc", + "268fe3f", + "d09723b", + "d669cc8", + "2aa4628", + "b4ce842", + "809a096", + "792221b", + "4239c28", + "3ceacd8", + "10768ac", + "8937abc", + "6cbed2e", + "9a4a8f0", + "9d76a99", + "569b0e0", + "f78a92f", + "5866581", + "ed8f50b", + "25f077c", + "2bc9d65", + "1680d5b", + "3690cf4", + "9de67f5", + "9999619", + "0fc5a96", + "e4d49a1", + "6f26d68", + "f496ce1", + "dddfae6", + "853fce0", + "d90dc5f", + "b3c1f35", + "9e90261", + "9586d24", + "7805044", + "45dbe7e", + "20105e3", + "7ff5130", + "bb5cd43", + "6dfe4f1", + "272fe57", + "4e42791", + "6f082a1", + "30fb705", + "bb5e9f1", + "cd5d8b9", + "4ed19b2", + "d0477eb", + "c0ff3d6", + "9bc07f6", + "0da6690", + "a125871", + "592abfb", + "a45d6a3", + "126caf9", + "742a3aa", + "c4ccf11", + "820cc75", + "871a6f0", + "4d02bcc", + "469e509", + "8f29fd3", + "3fa120f", + "17151ad", + "1354207", + "63e3fde", + "f6081ce", + "cdfac48", + "42d2942", + "b68d191", + "efe67d4", + "c0d602c", + "2a78574", + "cb282b9", + "4b644c9", + "7657401", + "2f97114", + "864ddb0", + "ebc4e9a", + "9ccdc76", + "3db75f2", + "6306cbe", + "d092e46", + "e99f803", + "e931731", + "3c4c037", + "9944694", + "19e6189", + "131716e", + "412b163", + "379c2d9", + "50533a6", + "d8a6fe6", + "6645dbb", + "dd9dc7a", + "5c1608e", + "5d3ef82", + "34f5a98", + "cc128e2", + "de30d68", + "8f1e9da", + "34ca361", + "dfdac50", + "7ad527e", + "1d1c57e", + "a6003d9", + "b743d48", + "6dab135", + "47d1b2a", + "4f03fb6", + "65336a5", + "1223819", + "7ae655e", + "e27a27a", + "debe1e0", + "4bd8a3f", + "20993c6", + "df087c1", + "7715ec1", + "4980035", + "2606997", + "d9e97bd", + "d1c9cd1", + "be96298", + "23b3bff", + "9f4f0e2", + "67253fe", + "6880a89", + "57e2a29", + "3df8d60", + "9f18a55", + "f1d0bc3", + "31bfcee", + "db311b3", + "c310e43", + "3fe63cd", + "c81e846", + "b8f0f09", + "38a6525", + "cbb0eff", + "8322db4", + "a658c70", + "230b770", + "27bfdc3", + "ccfb5fe", + "a2609fb", + "323986c", + "1a20b09", + "94b0f5c", + "86da24f", + "ffa4dd8", + "536dc86", + "e17e1d8", + "81384be", + "25403b7", + "1fb03af", + "416f2e4", + "3cf5732", + "e072bdb", + "45f3b86", + "c28e876", + "0cf5fcf", + "46e7f04", + "d4baa35", + "747c69e", + "3101efc", + "da4497b", + "1607745", + "1560dd8", + "aad7230", + "3a3fcbc", + "cfd5aff", + "bb031a2", + "61323b0", + "3319501", + "2974812", + "eebd5c9", + "63d040c", + "805c0d9", + "6726a5f", + "a70c735", + "9845df6", + "a823d7c", + "1243971", + "049a9af", + "2b5da73", + "a56f95a", + "d31119e", + "e66c4d3", + "cc8d2b5", + "843edfd", + "a76f976", + "6423ced", + "21c8547", + "18e1ec0", + "47ef018", + "5a91db2", + "48a7bb8", + "fc84ebd", + "00f4f62", + "ccf8fc1", + "17e8b90", + "1e1f9ff", + "40698aa", + "55af79f", + "b492659", + "c0ea071", + "9ada2a5", + "de93bc8", + "dd3cb1b", + "d7171e2", + "33c5b00", + "3ae67f6", + "03abd89", + "d215d96", + "2cf06b0", + "abdbe01", + "8240e44", + "275678e", + "a3a132d", + "8ad49e6", + "1f8fb4e", + "9dc313d", + "ec4f78f", + "6aa82f6", + "8b3540b", + "263d97f", + "43b02a1", + "08de5e1", + "8ff95c6", + "152d047", + "2256a04", + "1929dbe", + "8b3431f", + "df04b7a", + "1a2ca1c", + "469ee3d", + "96bb405", + "b5d2e84", + "45662a5", + "468ef0b", + "110b194", + "c4d7235", + "4964b87", + "b84781d", + "4090fb8", + "90de2fc", + "3cf8acb", + "2f3ca85", + "74d5107", + "2c4411f", + "40b0fb3", + "3f1afdb", + "a9594be", + "339dde3", + "4f88183", + "7e7f771", + "5bcdace", + "d3dca2e", + "71e023e", + "55cac3b", + "692df1b", + "fc8aac6", + "29a9bf4", + "a9533bf", + "8aaace0", + "762c0d9", + "f95a0f7", + "30f612f", + "077b026", + "af83518", + "f4d80b8", + "0f29980", + "10963a4", + "aab9373", + "2291da8", + "b00008e", + "2743be0", + "6f190d9", + "a83cbc5", + "d0fbfba", + "39ca9fc", + "c10c468", + "3351947", + "b695079", + "c8bdbf5", + "f454b7b", + "5132d37", + "a719002", + "f699746", + "94d6aa4", + "4f9747e", + "013e67c", + "a7256d9", + "8ae0113", + "51a7d27", + "96597d0", + "1bc75ea", + "c05e9c3", + "9d35737", + "e05c820", + "20d185a", + "ccf169a", + "4bf3bab", + "721ccd1", + "b5929f5", + "5d20981", + "3482db0", + "6fcade6", + "b51dc50", + "d068e98", + "7105e98", + "e2fbfce", + "cea475a", + "05d2c86", + "a308caf", + "f511130", + "6f09b49", + "4ff4eed", + "3bd0fe9", + "2f8fee0", + "feabc11", + "d0b2e7b", + "e4d00db", + "8b05c23", + "6bc3136", + "11d7b82", + "1c4b27e", + "117a481", + "0910abe", + "0dd0f87", + "3ec18a3", + "83b0fc0", + "fd5c708", + "8d466b7", + "b18d65e", + "226336e", + "cd71cbc", + "e3ca16c", + "acfa58e", + "58d3f89", + "cf49144", + "aaa631d", + "37004b8", + "cb7108d", + "4bb4875", + "122fca5", + "6caf077", + "c52f5d3", + "74141a5", + "fe102a5", + "974b2e2", + "aaa5d7f", + "f3dd325", + "50513f0", + "9887ed0", + "0c11383", + "018034c", + "a7271d0", + "4d23430", + "5dc1572", + "367620a", + "825346e", + "f1dd237", + "dc79449", + "e036e68", + "125f71a", + "3289d01", + "591261e", + "c678d35", + "fe32fc3", + "883f63c", + "76abf77", + "9db240b", + "d873f79", + "281e097", + "9db4b06", + "54d0bd1", + "8f5b440", + "c25443a", + "ceb6430", + "6b7bc33", + "fb77e17", + "1756107", + "a5678a7", + "652d566", + "3b305ef", + "4f98aef", + "d3f2fff", + "c610b98", + "f079602", + "d3cc818", + "fbc50e8", + "e432da1", + "349be5a", + "ab4f545", + "b1b20a5", + "5616ac3", + "558dad0", + "78d8864", + "562da20", + "cd8623f", + "e4c4260", + "58e0217", + "fb96722", + "08fe93e", + "fa53e76", + "55acea6", + "a3e63b9", + "0cef060", + "5d30fd1", + "3a80343", + "3924454", + "a771213", + "c13a14d", + "e3056b8", + "c1d2b60", + "ff4e88b", + "04b3b06", + "cfbb819", + "07c35c8", + "770f716", + "d35b828", + "64cb524", + "d49b59f", + "b5d1fc0", + "b9fa426", + "60f9da3", + "b0e63d7", + "0635b65", + "2ed13c3", + "d6b3802", + "5459ec0", + "da5841d", + "5f1f5e5", + "aa188b5", + "dac9629", + "f1866b2", + "7732acd", + "95e5481", + "bddef15", + "4c16d3a", + "1203218", + "15f62fc", + "6c0777a", + "aac4ca8", + "295d867", + "047d492", + "f513b81", + "e667d59", + "e0593d4", + "344cd44", + "423c0db", + "244c1b1", + "ae6c066", + "edadb17", + "bfe2092", + "68e6849", + "f03c4f5", + "17b5f1c", + "84e64b3", + "2e8c883", + "c7b6839", + "0bd06d8", + "4dd774c", + "76faff3", + "4eed6ed", + "c80975c", + "7b6f71f", + "d480c1e", + "6b967bf", + "9a1124a", + "b987d82", + "68e150d", + "2a5452c", + "a09957c", + "a3c2b0c", + "6aa5e97", + "6bf8cd8", + "4961b93", + "31e515e", + "738e91a", + "76a8022", + "05146b1", + "6d64be9", + "d815195", + "907f36c", + "3ad4d95", + "48bd008", + "5e1d873", + "7d2a80d", + "a666641", + "f217ba2", + "f0e7784", + "79f48f3", + "0dd1210", + "9d24896", + "25348c8", + "88ebb84", + "69842f8", + "383e81b", + "0b14bfd", + "11237af", + "f7001aa", + "390f834", + "2f13546", + "33c8688", + "4a8c6bd", + "f1167e2", + "bf736a6", + "5d839b2", + "e183049", + "70274b0", + "3d2c788", + "f3a6fa6", + "ba3b783", + "6a6aa77", + "6eebca9", + "08fcb7b", + "53f2eb4", + "5db78da", + "8e5ea04", + "2e2334d", + "cba3236", + "f3135a4", + "6f988cc", + "c880bc3", + "6177210", + "1a704a5", + "c390c5b", + "eed7fe9", + "47b8db6", + "8810464", + "cfd14e0", + "ca3b364", + "2f4359a", + "ae0b0ad", + "12ef728", + "337604c", + "71bdbdb", + "e4eee41", + "18a1664", + "41c58e5", + "8743c05", + "f96a9a4", + "e2e51f3", + "4a4663b", + "ade4397", + "caefacc", + "e9a19ff", + "8157670", + "e98f52a", + "aab0721", + "ad1b8bf", + "4df58d5", + "c929042", + "e020e33", + "bb64af9", + "8d189b9", + "ba00d34", + "5908403", + "8525dee", + "f87d690", + "e47be11", + "9759b6c", + "24ce437", + "a3e9762", + "8e5d374", + "1bd64a3", + "5d128cb", + "87691ff", + "5bcb312", + "4fd1fe3", + "94ee90c", + "a017eac", + "a01f415", + "7c39edc", + "39e01a8", + "62b3790", + "2c5c162", + "c2aff05", + "6f5ac72", + "8cfc231", + "14e276c", + "d374668", + "0a20acd", + "162731e", + "344d5af", + "a17b30b", + "940f1d9", + "8c2dd7e", + "787eec3", + "94d582a", + "150883a", + "67d73d8", + "5351296", + "bb72e41", + "4ef7eae", + "a63e6d2", + "9b3b3b4", + "0fb33a1", + "0146c59", + "a201f7d", + "6085623", + "e2ed71d", + "c1b5761", + "3366a1d", + "d27cdfd", + "00a9789", + "bacfbc3", + "8d052e0", + "6d0fc29", + "4f69fde", + "0531e00", + "15b3e56", + "464a385", + "9dd0eb7", + "98a8aed", + "02bbb53", + "b8ef9ce", + "ef22d31", + "5dec085", + "78dd923", + "d9171ef", + "98ed669", + "e2b5791", + "b914835", + "0a9a5c7", + "0103e9e", + "6e5597e", + "e9a4265", + "d2de34f", + "806158c", + "225ae6f", + "45c0ca8", + "dc8841e", + "c2d135f", + "2f48789", + "68a7913", + "1da2857", + "bb7c788", + "2d56bbc", + "fd12f0a", + "767122b", + "24d1a01", + "c42e3a9", + "555e46a", + "2341416", + "f071c9c", + "ef6da42", + "8271b5e", + "f72573a", + "91755f5", + "fb45288", + "9b8438c", + "2436f0f", + "5ad3f34", + "c91875a", + "6278b41", + "95152c2", + "b41a1eb", + "e6df641", + "0648deb", + "35e2496", + "b79508c", + "67eeaa1", + "d7081cc", + "1516c47", + "7f3fe6c", + "df05ca1", + "2d58ca6", + "00f138e", + "feec17c", + "56a8689", + "b446fa9", + "d34fd95", + "415ec5a", + "e14132a", + "b3efd0c", + "5faa947", + "68211fe", + "fabcf46", + "e833930", + "5eaa8fc", + "fec03d0", + "d9b4488", + "c5391fe", + "b94499d", + "f09b0d1", + "06093ad", + "29ec9df", + "4e4c097", + "6dc8550", + "6f404df", + "d77b7e1", + "7d6ed33", + "c752d73", + "14522cf", + "b2bf98b", + "479a3d8", + "c4e932a", + "7aff161", + "4fe535d", + "f422d0b", + "6803c3f", + "2e60fb4", + "1a08623", + "fd1a8ee", + "2f7c2d0", + "c88485d", + "4454943", + "dd50f4e", + "11f40ec", + "8722375", + "8732469", + "80c938c", + "116f887", + "b209f1f", + "5fc0127", + "57031e3", + "e552773", + "633f8b9", + "0fdb8ed", + "cd38d1f", + "f9558fc", + "2ef5ec2", + "d4394ca", + "d55fd00", + "99bf6f7", + "033c4e9", + "e887a4b", + "509142e", + "99a5b79", + "062e19b", + "e45034c", + "92897bf", + "e7930b0", + "e1c6073", + "b21335f", + "c2f0405", + "3324e6a", + "3b365c8", + "7b4fdfb", + "677ff49", + "e3bee8d", + "7c0939b", + "7e9c72d", + "c1f5baf", + "2145d6d", + "83b414c", + "e82fd6b", + "7b41a9e", + "335e003", + "6f84f98", + "1a9d279", + "d79a144", + "a7add0c", + "d768e7a", + "95c1b69", + "f95545e", + "76a29e4", + "5677966", + "14ca75e", + "5845902", + "a2f8c76", + "ec39b05", + "41efb7b", + "88d4d9a", + "0105061", + "1f4277d", + "2c2ab27", + "3c8ca17", + "dbefecd", + "f3fa7dd", + "92239b1", + "a2aede6", + "197e923", + "c67bf42", + "5369754", + "ef504a9", + "6dbba27", + "12f97fd", + "b79ae91", + "2a6737f", + "868196a", + "3e460e6", + "1a0aa60", + "013b022", + "f0fd972", + "6cc461f", + "97d055b", + "2684885", + "37d745a", + "4427466", + "3276467", + "70aa651", + "11ebd4a", + "b2f3251", + "6d7b0fd", + "a8ad2b1", + "71fc0d7", + "f80acdf", + "e38781e", + "73203a6", + "1ed9938", + "500c8e1", + "cd7ec00", + "a7c6ac9", + "d48774c", + "9f9652f", + "ebd6d7f", + "db36c0c", + "dee4e69", + "f49d571", + "ec33336", + "f02dd48", + "46fae33", + "6437222", + "31c6adc", + "c1ac98e", + "65b2603", + "41b8472", + "0dc5b01", + "ce17645", + "f86ebc3", + "123195a", + "5e0e2a2", + "0390046", + "7f3e85f", + "b048041", + "f3b68cb", + "47ffd8e", + "f665124", + "c73185e", + "f27efde", + "dd57a5a", + "599efe9", + "fd8504d", + "a7f6f0a", + "5d40086", + "1898bda", + "9d901db", + "eb49a6c", + "5fd790e", + "7d5b414", + "9442c50", + "9bedff9", + "7d96cd3", + "ce17cec", + "119face", + "72a0a2b", + "7be004f", + "687bdaa", + "6378417", + "5488559", + "d7b8fda", + "83d4dac", + "dc976b2", + "994f40f", + "4abcbd5", + "d2fc3de", + "20787ca", + "9610a69", + "eec8d85", + "7433b15", + "4d33eba", + "997d243", + "bc8fc7f", + "238877c", + "f8c161a", + "b2c3ba4", + "3284b79", + "e338dc4", + "de5b9cf", + "4195094", + "2f6ae5c", + "635f99e", + "041b364", + "23f1717", + "c9822b8", + "edf3db9", + "bd4d97b", + "cb6156a", + "500c4fc", + "6da9c22", + "de532b6", + "c7ed5c8", + "d87e5f1", + "69437ef", + "0f8a646", + "f9a419b", + "e60912f", + "44947dd", + "df4c9b0", + "3b3fe1c", + "b0c103e", + "010e204", + "7c4261e", + "b97c5af", + "eff15ca", + "48ed56f", + "4d11ca3", + "ac61e2a", + "7b2c65f", + "56135d4", + "87cf8d7", + "39539c3", + "230da36", + "b1cf34d", + "542a21a", + "f680a85", + "3c2d7b9", + "7ce2d9d", + "a12a199", + "b227b4d", + "c67a1d2", + "79cd78b", + "7fb7e6f", + "b534424", + "98e0112", + "8bc0d73", + "2d313f0", + "73b3c1b", + "35f678a", + "859a6b9", + "49097e6", + "45a8661", + "88b6afa", + "2ba5fe2", + "523784f", + "5d218a5", + "3f64b16", + "8006b16", + "91f9ffa", + "2796bbc", + "0e990a7", + "95121bf", + "4241792", + "8c53ad8", + "c20a53d", + "63f5f3c", + "859590f", + "b74c310", + "e4c8ff7", + "71b88a3", + "531c08d", + "b85413f", + "b85cbb7", + "23d507a", + "4d5e1a3", + "65c8a94", + "d1fef63", + "d4f940a", + "7292cea", + "eaec5f4", + "9ee0cf4", + "79fb145", + "e074296", + "47061b9", + "198691e", + "6955b66", + "b73d24a", + "d827804", + "7de9cf0", + "db45887", + "7b6a1d3", + "0df7705", + "2ea874e", + "0d2c7a8", + "7ac6e0b", + "1bf3f9c", + "7e738ea", + "1fef5ce", + "1441fab", + "03890ef", + "b72e565", + "13f1ec3", + "5f66659", + "5e3b2f1", + "38e8535", + "f0cd917", + "065fc34", + "daef119", + "5844275", + "01a0949", + "6e0c1f0", + "cebd42a", + "dcbf77d", + "31f5ab2", + "6051fa5", + "1f90ae4", + "2d605d7", + "4c9eafb", + "01521a2", + "5d3dc1b", + "6a5fa08", + "c5c37af", + "0fde513", + "954fe56", + "ff7ae07", + "c56a393", + "f592e26", + "1ec2f1f", + "02fa6e2", + "8a23cb1", + "413e2a8", + "c6708aa", + "df238ee", + "ab6e972", + "0ccd478", + "2da49df", + "42d9650", + "2622720", + "2f0d5f3", + "32985d7", + "87911da", + "42e17ac", + "0ceed57", + "5ebc249", + "b331097", + "9f0432c", + "54ddd47", + "f2b43e0", + "9c14c5e", + "0931347", + "f6e27c6", + "e0c65b9", + "0214fdf", + "d16caed", + "306de97", + "499bbd1", + "c4158ff", + "2c980be", + "e4d608b", + "44275fe", + "d39f9ca", + "454b24e", + "8650f5b", + "ec2f839", + "918cd98", + "3f2e2d0", + "94ab289", + "675c122", + "7ab0cc6", + "4c7146b", + "35deaba", + "913f541", + "ecd802a", + "3ec9f6a", + "96640aa", + "1d715c9", + "2d0dc44", + "28b2a24", + "7c444ba", + "f97a8c7", + "a9d6702", + "ccae214", + "c12b6d5", + "3c6860d", + "53dc29f", + "a610cfb", + "6ff4627", + "462ed08", + "d66c596", + "dcbf713", + "b3d7e7b", + "6b34470", + "eb9b98d", + "e2d72f7", + "32e7aa3", + "de8b6bf", + "f512452", + "7711e09", + "83813e5", + "6c7c9a3", + "83ced9e", + "9274d80", + "c9815ab", + "520295b", + "c41412f", + "e4579a1", + "5239d10", + "1677a4f", + "5771864", + "dc9c225", + "bb7bd1a", + "95370e1", + "04de79a", + "325f1fd", + "4e4a87c", + "d133c7e", + "58fd8d0", + "a61e921", + "fa16922", + "c7df49d", + "2eb795b", + "1e85ccf", + "9d0183d", + "c365ba4", + "5b9118c", + "6383ad5", + "60c5164", + "ca84559", + "acb826a", + "9b95736", + "d28c3d1", + "3fa7332", + "6827c58", + "827acae", + "502c2ba", + "1b102ac", + "fa16c31", + "0ad1a45", + "e242cb5", + "7408cc8", + "e263095", + "d5b320d", + "92c7828", + "1f7b1ac", + "7274b1e", + "58eb517", + "8153940", + "68b7c56", + "83f7f8b", + "bd91215", + "6026bf0", + "d7d25b9", + "634f142", + "c0a496e", + "a278f8f", + "a2fe74c", + "02c1d47", + "9682462", + "9e3d130", + "5276dee", + "fd93cbf", + "6f7b2b3", + "29e81d6", + "5d23686", + "52205fc", + "ac6ed0e", + "ec1da00", + "3ed02a5", + "b01cf24", + "0e8fce5", + "0cd9b3d", + "66a1f72", + "110ff36", + "2448e39", + "ff3630d", + "e9cc03d", + "bbc0730", + "bfc106f", + "1dce9c9", + "0c9a853", + "1e19698", + "0b85f7d", + "863003c", + "943a303", + "d37aa0b", + "cdd37ce", + "14f5f14", + "4a919a0", + "dbe11e0", + "ef24b9f", + "06c2d2f", + "3626a7d", + "d4c0b30", + "53deaed", + "aa7f0d9", + "66fcc4a", + "46850ed", + "9f15ed3", + "9b81295", + "69ecdb6", + "b635bc6", + "67488dd", + "8923ab7", + "6b3fce5", + "f95b76e", + "385f01d", + "ae64bb2", + "045af47", + "0451719", + "8890ad2", + "d40e8ce", + "9e2772f", + "94ff144", + "7f3542d", + "4d939d1", + "b08a9ab", + "7f8859c", + "2e4c4e1", + "1166ae5", + "f1a68bb", + "fc287cf", + "85638ed", + "2d726ca", + "70300bb", + "43d490e", + "6a4849f", + "49b2255", + "392511a", + "04cad0b", + "b652e57", + "5055c05", + "ec3213d", + "9018626", + "3bac0ca", + "7e2a9f4", + "55b46df", + "13e1fc6", + "a16c09e", + "efa348f", + "ec099a8", + "348f36a", + "a700dfa", + "30e64ee", + "176c37e", + "f798e09", + "7acd335", + "bbc689d", + "a200373", + "661bf42", + "2802b54", + "0c1bd48", + "52699e7", + "d0be488", + "ec0d347", + "14c8139", + "8a15b5a", + "66570a5", + "0a47b75", + "1cea74e", + "5607734", + "342c2a9", + "fbe6360", + "b493f70", + "f6f20e5", + "691cffe", + "56fa2f7", + "c02f7aa", + "1ee4b9c", + "0e715bc", + "6e5fb40", + "8621e0d", + "50c4a9b", + "2283e6e", + "3a3943d", + "78b036f", + "930db78", + "d7d2851", + "0ca4fea", + "0c2ac17", + "4941064", + "36fdd9e", + "49b3f48", + "74bf4bd", + "3c51aa2", + "94e7608", + "5f93fed", + "adf96f0", + "b0658e2", + "b93cd04", + "f742a40", + "943043a", + "fd7aba9", + "172e8d0", + "b9256a7", + "ab2781a", + "469bacc", + "9c1c0ea", + "9c558bd", + "f35a958", + "5e0eacf", + "7467165", + "b807a02", + "dc935a7", + "cd29b13", + "e967b1d", + "2275c15", + "5d590cf", + "f7bbcf7", + "a1b639e", + "1016ba4", + "0e60a88", + "2785ea7", + "d375de2", + "a90f24f", + "6965c9e", + "f865bab", + "8a21046", + "bcd0219", + "b7e011d", + "49d418b", + "230661b", + "289e031", + "2561d7f", + "a6d4c6d", + "2943dff", + "cd4c3f6", + "f46b4bf", + "1ea4d13", + "09170ca", + "74da875", + "fca6e15", + "f8cc940", + "c7f92b5", + "a21d23f", + "7e77c94", + "54501c9", + "30e586b", + "fc3c77e", + "8e14b47", + "efc38e1", + "a9a6537", + "9153366", + "501781c", + "b86ec0d", + "df7363a", + "f0e0fd6", + "9ea1918", + "c442195", + "b871c26", + "f7779c4", + "3a4ad68", + "f2ea3b7", + "6ee2bbf", + "8f045cc", + "ef16875", + "1c05540", + "b527350", + "c1909a3", + "43c14ba", + "64be20b", + "4073ada", + "2a1b820", + "9bf3e53", + "a912021", + "fcca718", + "ba0005c", + "a72d1a9", + "1061c34", + "118774b", + "27d5832", + "53cfd5e", + "a8f6b10", + "ee5d70c", + "893c5b2", + "4e72bc7", + "1b96168", + "fbe69f5", + "9c4b176", + "4b40b00", + "397200e", + "3645237", + "bfbdf97", + "40a8c6c", + "1fe160b", + "990662b", + "4179ff0", + "3b43083", + "d4254b5", + "27a87a0", + "31f12b7", + "7f39611", + "3a119b7", + "80c61b0", + "7aac568", + "66c7a5d", + "cb13257", + "8d45f3a", + "5ef5f34", + "450f5f2", + "ae4f6d3", + "dc46b76", + "b8f7e3a", + "7892b61", + "ee53990", + "8f4c8a3", + "7212618", + "6ebf2eb", + "edac100", + "599679d", + "e140f45", + "0b5dffd", + "16870d2", + "fede101", + "5fc2872", + "2a1f92a", + "676f976", + "7c28839", + "673bea5", + "7409647", + "1603230", + "d5501f6", + "576c884", + "84bd9a9", + "8708b8b", + "cd0a6dd", + "8ca5788", + "3bbf5e1", + "814346c", + "084fd89", + "51b6ccd", + "a9435e3", + "c34d362", + "52a9ae1", + "7a9e706", + "a1ac6f0", + "a776eec", + "24b611b", + "a96834e", + "b5fce1f", + "e2805b8", + "c87bf34", + "1e82479", + "d228709", + "2bd3e5f", + "297f598", + "2934c34", + "d627dce", + "1d557eb", + "ef13460", + "369d4ee", + "d9f9e83", + "1f0953b", + "ed4ea56", + "349c522", + "1664e55", + "dd62a19", + "a39cdff", + "87b5d7e", + "dd54c6e", + "ae51e70", + "3ee5c38", + "f0d4171", + "4dc58d1", + "6f94332", + "6404d86", + "c024298", + "942d1f8", + "a611766", + "da2ad1d", + "5edb2d9", + "0872a64", + "1908459", + "2e9906f", + "074fb84", + "b88e4c7", + "1f8a848", + "f24d371", + "88dc0f8", + "aa33e36", + "47ae65f", + "4a7f56c", + "6b62347", + "6ee6b46", + "586ee4e", + "3ba563e", + "94513a3", + "edce064", + "eacd37c", + "4f84c95", + "96ca618", + "cec11c0", + "430df52", + "62638dc", + "52ee688", + "55959eb", + "e19c75b", + "933380c", + "7a2915d", + "1598464", + "f765b5d", + "87522d5", + "71a941f", + "b6d71c1", + "55dd9e6", + "a906173", + "1f09cb0", + "edbe6cd", + "b52740c", + "8bd7be4", + "be7a815", + "e42f978", + "9ba1684", + "b7956e1", + "9c7cab2", + "1f7914b", + "18bbfef", + "0a2edb8", + "1af23a4", + "c55c020", + "1a68c60", + "a1201c1", + "b2aeaf0", + "3a0c646", + "92a52c0", + "5720c0b", + "6fd14d4", + "7c26788", + "52d3ce2", + "53c40be", + "2166b50" + ], + "last_run": "2026-02-26T00:33:03.681946", + "stats": { + "total_enriched": 3185, + "total_skipped": 14, + "total_errors": 6 + } +} \ No newline at end of file