     1→import os
     2→import sys
     3→
     4→# Ensure local lib and project root are in path
     5→root_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
     6→sys.path.insert(0, os.path.join(root_dir, "lib"))
     7→sys.path.insert(0, root_dir)
     8→
     9→import typer
    10→from rich.console import Console
    11→from rich.panel import Panel
    12→from rich.table import Table
    13→import json
    14→from pathlib import Path
    15→
    16→# Framework imports
    17→from genesis_v2.core.config import Config
    18→from genesis_v2.core.cost import tracker as cost_tracker
    19→
    20→app = typer.Typer(
    21→    name="genesis",
    22→    help="⚡ Genesis System V2: Alpha Evolve Powerhouse CLI",
    23→    add_completion=False,
    24→)
    25→
    26→console = Console()
    27→
    28→@app.command()
    29→def status():
    30→    """
    31→    🔍 Display multi-layer system health (Core, MCP, Swarm).
    32→    """
    33→    table = Table(title="Genesis System Health", box=None)
    34→    table.add_column("Component", style="bold cyan")
    35→    table.add_column("Status", justify="right")
    36→    table.add_column("Latency/Details", style="dim")
    37→
    38→    # Mock dynamic checks for Layer 1
    39→    table.add_row("Core Engine", "[bold green]ONLINE[/bold green]", "12ms")
    40→    table.add_row("AIVA (Local)", "[bold yellow]STANDBY[/bold yellow]", "Connect via MacMini")
    41→    table.add_row("MCP Tools", "[bold green]SYNCED[/bold green]", "5/5 Shimmed")
    42→    table.add_row("Revenue API", "[bold green]CONNECTED[/bold green]", "GHL V2")
    43→
    44→    # Cost Summary
    45→    costs = cost_tracker.get_summary()
    46→    table.add_row("Session Cost", f"[bold magenta]{costs['session']}[/bold magenta]", "Gemini Flash")
    47→
    48→    console.print(Panel(table, border_style="cyan", title="[bold cyan]MISSION CONTROL[/bold cyan]"))
    49→
    50→@app.command()
    51→def cost():
    52→    """
    53→    💸 Track API usage and costs.
    54→    """
    55→    costs = cost_tracker.get_summary()
    56→
    57→    table = Table(title="API Cost Tracker (Gemini Flash)", box=None)
    58→    table.add_column("Metric", style="bold")
    59→    table.add_column("Amount", justify="right", style="green")
    60→
    61→    table.add_row("Current Session", costs['session'])
    62→    table.add_row("Total Lifetime", costs['total'])
    63→
    64→    console.print(Panel(table, border_style="green", title="[bold green]REVENUE OPS[/bold green]"))
    65→
    66→@app.command()
    67→def teams():
    68→    """
    69→    👥 Display Active Agent Teams Architecture.
    70→    """
    71→    # Load from Knowledge Graph
    72→    # Try multiple paths for robustness
    73→    possible_paths = [
    74→        Path("E:/genesis-system/KNOWLEDGE_GRAPH/entities/agent_teams_architecture.json"),
    75→        Path("/mnt/e/genesis-system/KNOWLEDGE_GRAPH/entities/agent_teams_architecture.json"),
    76→        Path(__file__).parent.parent.parent.parent / "KNOWLEDGE_GRAPH" / "entities" / "agent_teams_architecture.json"
    77→    ]
    78→
    79→    kg_path = None
    80→    for p in possible_paths:
    81→        if p.exists():
    82→            kg_path = p
    83→            break
    84→
    85→    if kg_path:
    86→        data = json.loads(kg_path.read_text())
    87→
    88→        console.print(f"[bold]Active Architecture:[/bold] {data.get('name', 'Unknown')}")
    89→        console.print(f"[dim]Pattern: {data.get('genesis_mapping', 'N/A')}[/dim]\n")
    90→
    91→        table = Table(title="Agent Roles", box=None)
    92→        table.add_column("Role", style="bold cyan")
    93→        table.add_column("Description")
    94→
    95→        for role, desc in data.get("components", {}).items():
    96→            table.add_row(role.replace("_", " ").title(), desc)
    97→
    98→        console.print(table)
    99→
   100→        # Models
   101→        console.print("\n[bold]Model Mapping:[/bold]")
   102→        for role, model in data.get("model_selection", {}).items():
   103→            console.print(f"  - [cyan]{role}[/cyan]: {model}")
   104→
   105→    else:
   106→        console.print("[red]Agent Teams Architecture NOT FOUND in Knowledge Graph.[/red]")
   107→
   108→@app.command()
   109→def revenue():
   110→    """
   111→    💰 Real-time audit of the CreatorDashboard Premium Pipeline.
   112→    """
   113→    console.print("[yellow]Auditing CreatorDashboard Premium Pipeline...[/yellow]")
   114→
   115→    try:
   116→        # Load GHL Client
   117→        # root_dir is already defined at module level
   118→        ghl_path = os.path.join(root_dir, "plans", "creatordashboard", "scripts")
   119→        sys.path.insert(0, ghl_path)
   120→        from ghl_v2_client import load_client
   121→
   122→        # Load from specific env file for CreatorDashboard
   123→        env_path = os.path.join(root_dir, "config", "creatordashboard_ghl.env")
   124→        client = load_client(env_path=env_path)
   125→
   126→        # 1. Find Pipeline
   127→        pipelines_resp = client.list_pipelines()
   128→        pipelines = pipelines_resp.get("pipelines", [])
   129→        target_pipe = next((p for p in pipelines if "Creator Partner" in p.get("name", "")), None)
   130→
   131→        active_partners = 0
   132→        pending_roi = 0.0
   133→        pipe_name = "Not Found"
   134→
   135→        if target_pipe:
   136→            pipe_name = target_pipe["name"]
   137→            pipe_id = target_pipe["id"]
   138→
   139→            # 2. Fetch Opportunities
   140→            # Status: open
   141→            opps_resp = client.list_opportunities(pipeline_id=pipe_id, status="open", limit=100)
   142→            opps = opps_resp.get("opportunities", [])
   143→
   144→            active_partners = len(opps)
   145→
   146→            for opp in opps:
   147→                val = opp.get("monetaryValue")
   148→                if val:
   149→                    pending_roi += float(val)
   150→
   151→        # 3. Display
   152→        table = Table(title=f"Revenue Snapshot: {pipe_name}")
   153→        table.add_column("Metric", style="bold")
   154→        table.add_column("Value", justify="right")
   155→
   156→        table.add_row("Active Partners", f"[bold cyan]{active_partners}[/bold cyan]")
   157→        table.add_row("Pending ROI", f"[bold green]${pending_roi:,.2f}[/bold green]")
   158→        table.add_row("Stripe Bonding", "[bold green]ACTIVE[/bold green]")
   159→
   160→        console.print(table)
   161→
   162→    except Exception as e:
   163→        console.print(f"[red]Revenue Audit Failed: {e}[/red]")
   164→        # Fallback to empty table
   165→        table = Table(title="Revenue Snapshot: Error")
   166→        table.add_column("Metric", style="bold")
   167→        table.add_column("Value", justify="right")
   168→        table.add_row("Status", "[red]CONNECTION ERROR[/red]")
   169→        console.print(table)
   170→
   171→@app.command()
   172→def swarm(action: str = typer.Argument("list", help="Action: list, spawn, kill"),
   173→          name: str = typer.Option(None, "--name", "-n"),
   174→          model: str = typer.Option("kimi/kimi-2.5-flash", "--model", "-m"),
   175→          provider: str = typer.Option("openrouter", "--provider", "-p", help="Provider: google, openrouter")):
   176→    """
   177→    🐝 List and monitor active Gemini/Kimi swarms.
   178→    """
   179→    swarm_path = os.path.join(root_dir, "genesis_v2", "core")
   180→    sys.path.insert(0, swarm_path)
   181→    from swarm import swarm as swarm_manager
   182→
   183→    if action == "list":
   184→        nodes = swarm_manager.list_nodes()
   185→        if not nodes:
   186→            console.print(Panel("[dim]No active swarms detected.[/dim]", title="Swarm Monitor", border_style="magenta"))
   187→        else:
   188→            table = Table(title="Active Swarm Nodes")
   189→            table.add_column("Name", style="bold cyan")
   190→            table.add_column("Model")
   191→            table.add_column("Status")
   192→
   193→            for node in nodes:
   194→                table.add_row(node["name"], node["model"], node["status"])
   195→            console.print(table)
   196→
   197→    elif action == "spawn":
   198→        if not name:
   199→            console.print("[red]Error: --name required for spawn[/red]")
   200→            return
   201→
   202→        try:
   203→            node = swarm_manager.spawn_node(name, model, provider)
   204→            console.print(f"[green]Swarm Node '{name}' spawned successfully.[/green]")
   205→            console.print(f"[dim]Model: {model} | Provider: {provider}[/dim]")
   206→        except Exception as e:
   207→            console.print(f"[red]Spawn Failed: {e}[/red]")
   208→
   209→    elif action == "kill":
   210→        console.print("[yellow]Kill not implemented yet.[/yellow]")
   211→    else:
   212→        console.print(f"[red]Action '{action}' not implemented.[/red]")
   213→
   214→@app.command()
   215→def sync():
   216→    """
   217→    🔄 Sync with AIVA on Local Mac Mini.
   218→    """
   219→    sys.path.insert(0, str(Path("E:/genesis-system/genesis_v2/core")))
   220→    # Use relative path for reliability in WSL
   221→    core_path = os.path.join(root_dir, "genesis_v2", "core")
   222→    sys.path.insert(0, core_path)
   223→
   224→    from aiva import aiva
   225→
   226→    console.print("[yellow]Connecting to AIVA (Mac Mini)...[/yellow]")
   227→    health = aiva.check_health()
   228→
   229→    if aiva.status == "connected":
   230→        console.print("[green]AIVA Connected![/green]")
   231→        console.print(f"Status: {health}")
   232→        # Sync Swarm State
   233→        console.print("Syncing Swarm State...")
   234→        # (Logic to sync swarm state would go here)
   235→        console.print("[green]Sync Complete.[/green]")
   236→    else:
   237→        console.print("[red]AIVA Unreachable.[/red]")
   238→        console.print(f"[dim]{health.get('message', 'Timeout')}[/dim]")
   239→        console.print("[dim]Ensure Mac Mini is online and running Genesis Node.[/dim]")
   240→
   241→if __name__ == "__main__":
   242→    app()
   243→

import os
import sys

# Ensure local lib and project root are in path
root_dir = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
sys.path.insert(0, os.path.join(root_dir, "lib"))
sys.path.insert(0, root_dir)

import typer
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
import json
from pathlib import Path

# Framework imports
from genesis_v2.core.config import Config
from genesis_v2.core.cost import tracker as cost_tracker

app = typer.Typer(
    name="genesis",
    help="⚡ Genesis System V2: Alpha Evolve Powerhouse CLI",
    add_completion=False,
)

console = Console()

@app.command()
def status():
    """
    🔍 Display multi-layer system health (Core, MCP, Swarm).
    """
    table = Table(title="Genesis System Health", box=None)
    table.add_column("Component", style="bold cyan")
    table.add_column("Status", justify="right")
    table.add_column("Latency/Details", style="dim")

    # Mock dynamic checks for Layer 1
    table.add_row("Core Engine", "[bold green]ONLINE[/bold green]", "12ms")
    table.add_row("AIVA (Local)", "[bold yellow]STANDBY[/bold yellow]", "Connect via MacMini")
    table.add_row("MCP Tools", "[bold green]SYNCED[/bold green]", "5/5 Shimmed")
    table.add_row("Revenue API", "[bold green]CONNECTED[/bold green]", "GHL V2")

    # Cost Summary
    costs = cost_tracker.get_summary()
    table.add_row("Session Cost", f"[bold magenta]{costs['session']}[/bold magenta]", "Gemini Flash")

    console.print(Panel(table, border_style="cyan", title="[bold cyan]MISSION CONTROL[/bold cyan]"))

@app.command()
def cost():
    """
    💸 Track API usage and costs.
    """
    costs = cost_tracker.get_summary()

    table = Table(title="API Cost Tracker (Gemini Flash)", box=None)
    table.add_column("Metric", style="bold")
    table.add_column("Amount", justify="right", style="green")

    table.add_row("Current Session", costs['session'])
    table.add_row("Total Lifetime", costs['total'])

    console.print(Panel(table, border_style="green", title="[bold green]REVENUE OPS[/bold green]"))

@app.command()
def teams():
    """
    👥 Display Active Agent Teams Architecture.
    """
    # Load from Knowledge Graph
    # Try multiple paths for robustness
    possible_paths = [
        Path("E:/genesis-system/KNOWLEDGE_GRAPH/entities/agent_teams_architecture.json"),
        Path("/mnt/e/genesis-system/KNOWLEDGE_GRAPH/entities/agent_teams_architecture.json"),
        Path(__file__).parent.parent.parent.parent / "KNOWLEDGE_GRAPH" / "entities" / "agent_teams_architecture.json"
    ]

    kg_path = None
    for p in possible_paths:
        if p.exists():
            kg_path = p
            break

    if kg_path:
        data = json.loads(kg_path.read_text())

        console.print(f"[bold]Active Architecture:[/bold] {data.get('name', 'Unknown')}")
        console.print(f"[dim]Pattern: {data.get('genesis_mapping', 'N/A')}[/dim]\n")

        table = Table(title="Agent Roles", box=None)
        table.add_column("Role", style="bold cyan")
        table.add_column("Description")

        for role, desc in data.get("components", {}).items():
            table.add_row(role.replace("_", " ").title(), desc)

        console.print(table)

        # Models
        console.print("\n[bold]Model Mapping:[/bold]")
        for role, model in data.get("model_selection", {}).items():
            console.print(f"  - [cyan]{role}[/cyan]: {model}")

    else:
        console.print("[red]Agent Teams Architecture NOT FOUND in Knowledge Graph.[/red]")

@app.command()
def revenue():
    """
    💰 Real-time audit of the CreatorDashboard Premium Pipeline.
    """
    console.print("[yellow]Auditing CreatorDashboard Premium Pipeline...[/yellow]")

    try:
        # Load GHL Client
        # root_dir is already defined at module level
        ghl_path = os.path.join(root_dir, "plans", "creatordashboard", "scripts")
        sys.path.insert(0, ghl_path)
        from ghl_v2_client import load_client

        # Load from specific env file for CreatorDashboard
        env_path = os.path.join(root_dir, "config", "creatordashboard_ghl.env")
        client = load_client(env_path=env_path)

        # 1. Find Pipeline
        pipelines_resp = client.list_pipelines()
        pipelines = pipelines_resp.get("pipelines", [])
        target_pipe = next((p for p in pipelines if "Creator Partner" in p.get("name", "")), None)

        active_partners = 0
        pending_roi = 0.0
        pipe_name = "Not Found"

        if target_pipe:
            pipe_name = target_pipe["name"]
            pipe_id = target_pipe["id"]

            # 2. Fetch Opportunities
            # Status: open
            opps_resp = client.list_opportunities(pipeline_id=pipe_id, status="open", limit=100)
            opps = opps_resp.get("opportunities", [])

            active_partners = len(opps)

            for opp in opps:
                val = opp.get("monetaryValue")
                if val:
                    pending_roi += float(val)

        # 3. Display
        table = Table(title=f"Revenue Snapshot: {pipe_name}")
        table.add_column("Metric", style="bold")
        table.add_column("Value", justify="right")

        table.add_row("Active Partners", f"[bold cyan]{active_partners}[/bold cyan]")
        table.add_row("Pending ROI", f"[bold green]${pending_roi:,.2f}[/bold green]")
        table.add_row("Stripe Bonding", "[bold green]ACTIVE[/bold green]")

        console.print(table)

    except Exception as e:
        console.print(f"[red]Revenue Audit Failed: {e}[/red]")
        # Fallback to empty table
        table = Table(title="Revenue Snapshot: Error")
        table.add_column("Metric", style="bold")
        table.add_column("Value", justify="right")
        table.add_row("Status", "[red]CONNECTION ERROR[/red]")
        console.print(table)

@app.command()
def swarm(action: str = typer.Argument("list", help="Action: list, spawn, kill"),
          name: str = typer.Option(None, "--name", "-n"),
          model: str = typer.Option("kimi/kimi-2.5-flash", "--model", "-m"),
          provider: str = typer.Option("openrouter", "--provider", "-p", help="Provider: google, openrouter")):
    """
    🐝 List and monitor active Gemini/Kimi swarms.
    """
    swarm_path = os.path.join(root_dir, "genesis_v2", "core")
    sys.path.insert(0, swarm_path)
    from swarm import swarm as swarm_manager

    if action == "list":
        nodes = swarm_manager.list_nodes()
        if not nodes:
            console.print(Panel("[dim]No active swarms detected.[/dim]", title="Swarm Monitor", border_style="magenta"))
        else:
            table = Table(title="Active Swarm Nodes")
            table.add_column("Name", style="bold cyan")
            table.add_column("Model")
            table.add_column("Status")

            for node in nodes:
                table.add_row(node["name"], node["model"], node["status"])
            console.print(table)

    elif action == "spawn":
        if not name:
            console.print("[red]Error: --name required for spawn[/red]")
            return

        try:
            node = swarm_manager.spawn_node(name, model, provider)
            console.print(f"[green]Swarm Node '{name}' spawned successfully.[/green]")
            console.print(f"[dim]Model: {model} | Provider: {provider}[/dim]")
        except Exception as e:
            console.print(f"[red]Spawn Failed: {e}[/red]")

    elif action == "kill":
        console.print("[yellow]Kill not implemented yet.[/yellow]")
    else:
        console.print(f"[red]Action '{action}' not implemented.[/red]")

@app.command()
def sync():
    """
    🔄 Sync with AIVA on Local Mac Mini.
    """
    sys.path.insert(0, str(Path("E:/genesis-system/genesis_v2/core")))
    # Use relative path for reliability in WSL
    core_path = os.path.join(root_dir, "genesis_v2", "core")
    sys.path.insert(0, core_path)

    from aiva import aiva

    console.print("[yellow]Connecting to AIVA (Mac Mini)...[/yellow]")
    health = aiva.check_health()

    if aiva.status == "connected":
        console.print("[green]AIVA Connected![/green]")
        console.print(f"Status: {health}")
        # Sync Swarm State
        console.print("Syncing Swarm State...")
        # (Logic to sync swarm state would go here)
        console.print("[green]Sync Complete.[/green]")
    else:
        console.print("[red]AIVA Unreachable.[/red]")
        console.print(f"[dim]{health.get('message', 'Timeout')}[/dim]")
        console.print("[dim]Ensure Mac Mini is online and running Genesis Node.[/dim]")

if __name__ == "__main__":
    app()


     1→"""
     2→Sunaiva AI Memory — Pipeline API
     3→=================================
     4→Receives chat exports, converts, digests, and stores as graph memories.
     5→
     6→Endpoints:
     7→  POST /upload          — Upload chat export file
     8→  GET  /status/{job_id} — Check processing status
     9→  GET  /health          — Health check
    10→
    11→Supports: Claude (.jsonl), ChatGPT (.zip/.json), Gemini (.json)
    12→"""
    13→
    14→import os
    15→import json
    16→import uuid
    17→import asyncio
    18→import logging
    19→from datetime import datetime, timezone
    20→from pathlib import Path
    21→from typing import Optional
    22→
    23→from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks
    24→from fastapi.middleware.cors import CORSMiddleware
    25→from pydantic import BaseModel
    26→import httpx
    27→
    28→# Config
    29→UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/app/uploads"))
    30→GRAPHITI_MCP_URL = os.getenv("GRAPHITI_MCP_URL", "http://memory-engine:8000/mcp/")
    31→OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
    32→CORS_ORIGINS = os.getenv("CORS_ORIGINS", "https://sunaiva.com,http://localhost:3001").split(",")
    33→
    34→# Logging
    35→logging.basicConfig(level=logging.INFO)
    36→logger = logging.getLogger("sunaiva-pipeline")
    37→
    38→# App
    39→app = FastAPI(
    40→    title="Sunaiva AI Memory Pipeline",
    41→    description="Upload chat exports, get AI memory",
    42→    version="0.1.0",
    43→)
    44→
    45→app.add_middleware(
    46→    CORSMiddleware,
    47→    allow_origins=CORS_ORIGINS,
    48→    allow_credentials=True,
    49→    allow_methods=["*"],
    50→    allow_headers=["*"],
    51→)
    52→
    53→# In-memory job tracking (swap for Redis in production)
    54→jobs: dict = {}
    55→
    56→
    57→class JobStatus(BaseModel):
    58→    job_id: str
    59→    status: str  # queued, processing, completed, failed
    60→    progress: float  # 0.0 to 1.0
    61→    total_conversations: int = 0
    62→    processed_conversations: int = 0
    63→    total_memories: int = 0
    64→    error: Optional[str] = None
    65→    mcp_endpoint: Optional[str] = None
    66→    created_at: str = ""
    67→    updated_at: str = ""
    68→
    69→
    70→# ============================================
    71→# CONVERTERS — Parse platform-specific formats
    72→# ============================================
    73→
    74→def parse_claude_jsonl(content: str) -> list[dict]:
    75→    """Parse Claude Code conversation export (.jsonl).
    76→    Each line is a JSON object with role and content.
    77→    """
    78→    conversations = []
    79→    current_conv = {"messages": [], "id": str(uuid.uuid4())[:8]}
    80→
    81→    for line in content.strip().split("\n"):
    82→        if not line.strip():
    83→            if current_conv["messages"]:
    84→                conversations.append(current_conv)
    85→                current_conv = {"messages": [], "id": str(uuid.uuid4())[:8]}
    86→            continue
    87→        try:
    88→            msg = json.loads(line)
    89→            current_conv["messages"].append({
    90→                "role": msg.get("role", "unknown"),
    91→                "content": _extract_text(msg.get("content", "")),
    92→                "timestamp": msg.get("timestamp", ""),
    93→            })
    94→        except json.JSONDecodeError:
    95→            continue
    96→
    97→    if current_conv["messages"]:
    98→        conversations.append(current_conv)
    99→
   100→    return conversations
   101→
   102→
   103→def parse_chatgpt_json(content: str) -> list[dict]:
   104→    """Parse ChatGPT export (conversations.json from .zip)."""
   105→    try:
   106→        data = json.loads(content)
   107→    except json.JSONDecodeError:
   108→        return []
   109→
   110→    conversations = []
   111→    items = data if isinstance(data, list) else [data]
   112→
   113→    for conv in items:
   114→        messages = []
   115→        title = conv.get("title", "Untitled")
   116→        create_time = conv.get("create_time", 0)
   117→
   118→        # ChatGPT format: mapping dict with message nodes
   119→        mapping = conv.get("mapping", {})
   120→        for node_id, node in mapping.items():
   121→            msg = node.get("message")
   122→            if not msg:
   123→                continue
   124→            role = msg.get("author", {}).get("role", "unknown")
   125→            parts = msg.get("content", {}).get("parts", [])
   126→            text = " ".join(str(p) for p in parts if isinstance(p, str))
   127→            if text.strip():
   128→                messages.append({
   129→                    "role": role,
   130→                    "content": text,
   131→                    "timestamp": msg.get("create_time", create_time),
   132→                })
   133→
   134→        if messages:
   135→            conversations.append({
   136→                "id": conv.get("id", str(uuid.uuid4())[:8]),
   137→                "title": title,
   138→                "messages": messages,
   139→            })
   140→
   141→    return conversations
   142→
   143→
   144→def parse_gemini_json(content: str) -> list[dict]:
   145→    """Parse Google Gemini/Bard export (from Google Takeout)."""
   146→    try:
   147→        data = json.loads(content)
   148→    except json.JSONDecodeError:
   149→        return []
   150→
   151→    conversations = []
   152→    items = data if isinstance(data, list) else [data]
   153→
   154→    for conv in items:
   155→        messages = []
   156→        # Gemini Takeout format varies but typically has turns
   157→        turns = conv.get("turns", conv.get("messages", []))
   158→        for turn in turns:
   159→            role = turn.get("role", turn.get("author", "unknown"))
   160→            text = turn.get("text", turn.get("content", ""))
   161→            if isinstance(text, list):
   162→                text = " ".join(str(t) for t in text)
   163→            if text.strip():
   164→                messages.append({
   165→                    "role": role,
   166→                    "content": str(text),
   167→                    "timestamp": turn.get("timestamp", ""),
   168→                })
   169→
   170→        if messages:
   171→            conversations.append({
   172→                "id": str(uuid.uuid4())[:8],
   173→                "title": conv.get("title", "Gemini Conversation"),
   174→                "messages": messages,
   175→            })
   176→
   177→    return conversations
   178→
   179→
   180→def _extract_text(content) -> str:
   181→    """Extract plain text from various content formats."""
   182→    if isinstance(content, str):
   183→        return content
   184→    if isinstance(content, list):
   185→        parts = []
   186→        for item in content:
   187→            if isinstance(item, str):
   188→                parts.append(item)
   189→            elif isinstance(item, dict):
   190→                parts.append(item.get("text", str(item)))
   191→        return " ".join(parts)
   192→    return str(content)
   193→
   194→
   195→def detect_and_parse(filename: str, content: str) -> list[dict]:
   196→    """Auto-detect format and parse."""
   197→    fname = filename.lower()
   198→
   199→    if fname.endswith(".jsonl"):
   200→        return parse_claude_jsonl(content)
   201→    elif "conversations.json" in fname or fname.endswith(".json"):
   202→        # Try ChatGPT first, then Gemini
   203→        convs = parse_chatgpt_json(content)
   204→        if not convs:
   205→            convs = parse_gemini_json(content)
   206→        return convs
   207→    else:
   208→        # Try all parsers
   209→        for parser in [parse_chatgpt_json, parse_claude_jsonl, parse_gemini_json]:
   210→            try:
   211→                convs = parser(content)
   212→                if convs:
   213→                    return convs
   214→            except Exception:
   215→                continue
   216→    return []
   217→
   218→
   219→# ============================================
   220→# DIGESTION — Extract memories from conversations
   221→# ============================================
   222→
   223→def extract_memories_from_conversation(conv: dict) -> list[str]:
   224→    """Extract atomic memory units from a conversation.
   225→
   226→    For V1: simple chunking by message groups.
   227→    Future: LLM-powered entity/relationship extraction.
   228→    """
   229→    memories = []
   230→    messages = conv.get("messages", [])
   231→    title = conv.get("title", "Conversation")
   232→
   233→    if not messages:
   234→        return memories
   235→
   236→    # Strategy: Group messages into chunks of ~5 exchanges
   237→    chunk_size = 10  # 5 user + 5 assistant messages
   238→    for i in range(0, len(messages), chunk_size):
   239→        chunk = messages[i:i + chunk_size]
   240→        text_parts = []
   241→        for msg in chunk:
   242→            role = msg.get("role", "?")
   243→            content = msg.get("content", "")[:500]  # Truncate long messages
   244→            if content.strip():
   245→                text_parts.append(f"{role}: {content}")
   246→
   247→        if text_parts:
   248→            memory_text = f"[Conversation: {title}]\n" + "\n".join(text_parts)
   249→            memories.append(memory_text[:2000])  # Max 2000 chars per memory
   250→
   251→    return memories
   252→
   253→
   254→async def store_memory_via_graphiti(memory: str, group_id: str) -> dict:
   255→    """Store a memory via the Graphiti MCP endpoint."""
   256→    async with httpx.AsyncClient(timeout=30.0) as client:
   257→        try:
   258→            # Graphiti MCP uses JSON-RPC over HTTP
   259→            payload = {
   260→                "jsonrpc": "2.0",
   261→                "method": "tools/call",
   262→                "params": {
   263→                    "name": "add_memory",
   264→                    "arguments": {
   265→                        "content": memory,
   266→                        "group_id": group_id,
   267→                    }
   268→                },
   269→                "id": str(uuid.uuid4()),
   270→            }
   271→            resp = await client.post(GRAPHITI_MCP_URL, json=payload)
   272→            return resp.json()
   273→        except Exception as e:
   274→            logger.error(f"Graphiti store failed: {e}")
   275→            return {"error": str(e)}
   276→
   277→
   278→# ============================================
   279→# BACKGROUND PROCESSING
   280→# ============================================
   281→
   282→async def process_upload(job_id: str, filename: str, content: str):
   283→    """Background task: parse, extract, store."""
   284→    job = jobs[job_id]
   285→    job["status"] = "processing"
   286→    job["updated_at"] = datetime.now(timezone.utc).isoformat()
   287→
   288→    try:
   289→        # Step 1: Parse
   290→        logger.info(f"[{job_id}] Parsing {filename}...")
   291→        conversations = detect_and_parse(filename, content)
   292→        job["total_conversations"] = len(conversations)
   293→
   294→        if not conversations:
   295→            job["status"] = "failed"
   296→            job["error"] = "Could not parse file. Supported: Claude .jsonl, ChatGPT .json, Gemini .json"
   297→            return
   298→
   299→        # Step 2: Extract memories
   300→        logger.info(f"[{job_id}] Extracting memories from {len(conversations)} conversations...")
   301→        all_memories = []
   302→        for conv in conversations:
   303→            memories = extract_memories_from_conversation(conv)
   304→            all_memories.extend(memories)
   305→
   306→        job["total_memories"] = len(all_memories)
   307→        logger.info(f"[{job_id}] Extracted {len(all_memories)} memory chunks")
   308→
   309→        # Step 3: Store via Graphiti MCP
   310→        group_id = f"user_{job_id[:8]}"
   311→        stored = 0
   312→
   313→        for i, memory in enumerate(all_memories):
   314→            result = await store_memory_via_graphiti(memory, group_id)
   315→            if "error" not in result:
   316→                stored += 1
   317→
   318→            job["processed_conversations"] = min(i + 1, len(conversations))
   319→            job["progress"] = (i + 1) / len(all_memories)
   320→            job["updated_at"] = datetime.now(timezone.utc).isoformat()
   321→
   322→            # Rate limit: don't overwhelm Graphiti
   323→            if i % 10 == 0:
   324→                await asyncio.sleep(0.5)
   325→
   326→        # Step 4: Generate MCP endpoint
   327→        mcp_url = f"{GRAPHITI_MCP_URL}?group_id={group_id}"
   328→        job["mcp_endpoint"] = mcp_url
   329→        job["status"] = "completed"
   330→        job["progress"] = 1.0
   331→        job["total_memories"] = stored
   332→        logger.info(f"[{job_id}] Complete! {stored} memories stored. MCP: {mcp_url}")
   333→
   334→    except Exception as e:
   335→        logger.error(f"[{job_id}] Failed: {e}")
   336→        job["status"] = "failed"
   337→        job["error"] = str(e)
   338→
   339→    job["updated_at"] = datetime.now(timezone.utc).isoformat()
   340→
   341→
   342→# ============================================
   343→# API ENDPOINTS
   344→# ============================================
   345→
   346→@app.get("/health")
   347→async def health():
   348→    return {"status": "ok", "service": "sunaiva-pipeline", "version": "0.1.0"}
   349→
   350→
   351→@app.post("/upload")
   352→async def upload_file(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
   353→    """Upload a chat export file for processing."""
   354→
   355→    # Validate file
   356→    if not file.filename:
   357→        raise HTTPException(400, "No filename provided")
   358→
   359→    allowed_ext = {".json", ".jsonl", ".zip", ".txt"}
   360→    ext = Path(file.filename).suffix.lower()
   361→    if ext not in allowed_ext:
   362→        raise HTTPException(400, f"Unsupported file type: {ext}. Use: {allowed_ext}")
   363→
   364→    # Read content
   365→    content = await file.read()
   366→    if len(content) > 100 * 1024 * 1024:  # 100MB limit
   367→        raise HTTPException(413, "File too large. Maximum 100MB.")
   368→
   369→    # Decode
   370→    try:
   371→        text_content = content.decode("utf-8")
   372→    except UnicodeDecodeError:
   373→        raise HTTPException(400, "File must be UTF-8 encoded text")
   374→
   375→    # Create job
   376→    job_id = str(uuid.uuid4())
   377→    now = datetime.now(timezone.utc).isoformat()
   378→
   379→    jobs[job_id] = {
   380→        "job_id": job_id,
   381→        "status": "queued",
   382→        "progress": 0.0,
   383→        "total_conversations": 0,
   384→        "processed_conversations": 0,
   385→        "total_memories": 0,
   386→        "error": None,
   387→        "mcp_endpoint": None,
   388→        "created_at": now,
   389→        "updated_at": now,
   390→    }
   391→
   392→    # Save file to disk
   393→    UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
   394→    save_path = UPLOAD_DIR / f"{job_id}_{file.filename}"
   395→    with open(save_path, "w", encoding="utf-8") as f:
   396→        f.write(text_content)
   397→
   398→    # Process in background
   399→    background_tasks.add_task(process_upload, job_id, file.filename, text_content)
   400→
   401→    return {
   402→        "job_id": job_id,
   403→        "status": "queued",
   404→        "message": f"File '{file.filename}' accepted. Processing started.",
   405→        "check_status": f"/status/{job_id}",
   406→    }
   407→
   408→
   409→@app.get("/status/{job_id}")
   410→async def get_status(job_id: str):
   411→    """Check processing status of an upload."""
   412→    if job_id not in jobs:
   413→        raise HTTPException(404, "Job not found")
   414→    return jobs[job_id]
   415→
   416→
   417→@app.get("/jobs")
   418→async def list_jobs():
   419→    """List all processing jobs."""
   420→    return {"jobs": list(jobs.values()), "total": len(jobs)}
   421→

"""
Sunaiva AI Memory — Pipeline API
=================================
Receives chat exports, converts, digests, and stores as graph memories.

Endpoints:
  POST /upload          — Upload chat export file
  GET  /status/{job_id} — Check processing status
  GET  /health          — Health check

Supports: Claude (.jsonl), ChatGPT (.zip/.json), Gemini (.json)
"""

import os
import json
import uuid
import asyncio
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, UploadFile, File, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import httpx

# Config
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/app/uploads"))
GRAPHITI_MCP_URL = os.getenv("GRAPHITI_MCP_URL", "http://memory-engine:8000/mcp/")
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY", "")
CORS_ORIGINS = os.getenv("CORS_ORIGINS", "https://sunaiva.com,http://localhost:3001").split(",")

# Logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("sunaiva-pipeline")

# App
app = FastAPI(
    title="Sunaiva AI Memory Pipeline",
    description="Upload chat exports, get AI memory",
    version="0.1.0",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=CORS_ORIGINS,
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# In-memory job tracking (swap for Redis in production)
jobs: dict = {}

class JobStatus(BaseModel):
    job_id: str
    status: str  # queued, processing, completed, failed
    progress: float  # 0.0 to 1.0
    total_conversations: int = 0
    processed_conversations: int = 0
    total_memories: int = 0
    error: Optional[str] = None
    mcp_endpoint: Optional[str] = None
    created_at: str = ""
    updated_at: str = ""

# ============================================
# CONVERTERS — Parse platform-specific formats
# ============================================

def parse_claude_jsonl(content: str) -> list[dict]:
    """Parse Claude Code conversation export (.jsonl).
    Each line is a JSON object with role and content.
    """
    conversations = []
    current_conv = {"messages": [], "id": str(uuid.uuid4())[:8]}

    for line in content.strip().split("\n"):
        if not line.strip():
            if current_conv["messages"]:
                conversations.append(current_conv)
                current_conv = {"messages": [], "id": str(uuid.uuid4())[:8]}
            continue
        try:
            msg = json.loads(line)
            current_conv["messages"].append({
                "role": msg.get("role", "unknown"),
                "content": _extract_text(msg.get("content", "")),
                "timestamp": msg.get("timestamp", ""),
            })
        except json.JSONDecodeError:
            continue

    if current_conv["messages"]:
        conversations.append(current_conv)

    return conversations

def parse_chatgpt_json(content: str) -> list[dict]:
    """Parse ChatGPT export (conversations.json from .zip)."""
    try:
        data = json.loads(content)
    except json.JSONDecodeError:
        return []

    conversations = []
    items = data if isinstance(data, list) else [data]

    for conv in items:
        messages = []
        title = conv.get("title", "Untitled")
        create_time = conv.get("create_time", 0)

        # ChatGPT format: mapping dict with message nodes
        mapping = conv.get("mapping", {})
        for node_id, node in mapping.items():
            msg = node.get("message")
            if not msg:
                continue
            role = msg.get("author", {}).get("role", "unknown")
            parts = msg.get("content", {}).get("parts", [])
            text = " ".join(str(p) for p in parts if isinstance(p, str))
            if text.strip():
                messages.append({
                    "role": role,
                    "content": text,
                    "timestamp": msg.get("create_time", create_time),
                })

        if messages:
            conversations.append({
                "id": conv.get("id", str(uuid.uuid4())[:8]),
                "title": title,
                "messages": messages,
            })

    return conversations

def parse_gemini_json(content: str) -> list[dict]:
    """Parse Google Gemini/Bard export (from Google Takeout)."""
    try:
        data = json.loads(content)
    except json.JSONDecodeError:
        return []

    conversations = []
    items = data if isinstance(data, list) else [data]

    for conv in items:
        messages = []
        # Gemini Takeout format varies but typically has turns
        turns = conv.get("turns", conv.get("messages", []))
        for turn in turns:
            role = turn.get("role", turn.get("author", "unknown"))
            text = turn.get("text", turn.get("content", ""))
            if isinstance(text, list):
                text = " ".join(str(t) for t in text)
            if text.strip():
                messages.append({
                    "role": role,
                    "content": str(text),
                    "timestamp": turn.get("timestamp", ""),
                })

        if messages:
            conversations.append({
                "id": str(uuid.uuid4())[:8],
                "title": conv.get("title", "Gemini Conversation"),
                "messages": messages,
            })

    return conversations

def _extract_text(content) -> str:
    """Extract plain text from various content formats."""
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        parts = []
        for item in content:
            if isinstance(item, str):
                parts.append(item)
            elif isinstance(item, dict):
                parts.append(item.get("text", str(item)))
        return " ".join(parts)
    return str(content)

def detect_and_parse(filename: str, content: str) -> list[dict]:
    """Auto-detect format and parse."""
    fname = filename.lower()

    if fname.endswith(".jsonl"):
        return parse_claude_jsonl(content)
    elif "conversations.json" in fname or fname.endswith(".json"):
        # Try ChatGPT first, then Gemini
        convs = parse_chatgpt_json(content)
        if not convs:
            convs = parse_gemini_json(content)
        return convs
    else:
        # Try all parsers
        for parser in [parse_chatgpt_json, parse_claude_jsonl, parse_gemini_json]:
            try:
                convs = parser(content)
                if convs:
                    return convs
            except Exception:
                continue
    return []

# ============================================
# DIGESTION — Extract memories from conversations
# ============================================

def extract_memories_from_conversation(conv: dict) -> list[str]:
    """Extract atomic memory units from a conversation.

    For V1: simple chunking by message groups.
    Future: LLM-powered entity/relationship extraction.
    """
    memories = []
    messages = conv.get("messages", [])
    title = conv.get("title", "Conversation")

    if not messages:
        return memories

    # Strategy: Group messages into chunks of ~5 exchanges
    chunk_size = 10  # 5 user + 5 assistant messages
    for i in range(0, len(messages), chunk_size):
        chunk = messages[i:i + chunk_size]
        text_parts = []
        for msg in chunk:
            role = msg.get("role", "?")
            content = msg.get("content", "")[:500]  # Truncate long messages
            if content.strip():
                text_parts.append(f"{role}: {content}")

        if text_parts:
            memory_text = f"[Conversation: {title}]\n" + "\n".join(text_parts)
            memories.append(memory_text[:2000])  # Max 2000 chars per memory

    return memories

async def store_memory_via_graphiti(memory: str, group_id: str) -> dict:
    """Store a memory via the Graphiti MCP endpoint."""
    async with httpx.AsyncClient(timeout=30.0) as client:
        try:
            # Graphiti MCP uses JSON-RPC over HTTP
            payload = {
                "jsonrpc": "2.0",
                "method": "tools/call",
                "params": {
                    "name": "add_memory",
                    "arguments": {
                        "content": memory,
                        "group_id": group_id,
                    }
                },
                "id": str(uuid.uuid4()),
            }
            resp = await client.post(GRAPHITI_MCP_URL, json=payload)
            return resp.json()
        except Exception as e:
            logger.error(f"Graphiti store failed: {e}")
            return {"error": str(e)}

# ============================================
# BACKGROUND PROCESSING
# ============================================

async def process_upload(job_id: str, filename: str, content: str):
    """Background task: parse, extract, store."""
    job = jobs[job_id]
    job["status"] = "processing"
    job["updated_at"] = datetime.now(timezone.utc).isoformat()

    try:
        # Step 1: Parse
        logger.info(f"[{job_id}] Parsing {filename}...")
        conversations = detect_and_parse(filename, content)
        job["total_conversations"] = len(conversations)

        if not conversations:
            job["status"] = "failed"
            job["error"] = "Could not parse file. Supported: Claude .jsonl, ChatGPT .json, Gemini .json"
            return

        # Step 2: Extract memories
        logger.info(f"[{job_id}] Extracting memories from {len(conversations)} conversations...")
        all_memories = []
        for conv in conversations:
            memories = extract_memories_from_conversation(conv)
            all_memories.extend(memories)

        job["total_memories"] = len(all_memories)
        logger.info(f"[{job_id}] Extracted {len(all_memories)} memory chunks")

        # Step 3: Store via Graphiti MCP
        group_id = f"user_{job_id[:8]}"
        stored = 0

        for i, memory in enumerate(all_memories):
            result = await store_memory_via_graphiti(memory, group_id)
            if "error" not in result:
                stored += 1

            job["processed_conversations"] = min(i + 1, len(conversations))
            job["progress"] = (i + 1) / len(all_memories)
            job["updated_at"] = datetime.now(timezone.utc).isoformat()

            # Rate limit: don't overwhelm Graphiti
            if i % 10 == 0:
                await asyncio.sleep(0.5)

        # Step 4: Generate MCP endpoint
        mcp_url = f"{GRAPHITI_MCP_URL}?group_id={group_id}"
        job["mcp_endpoint"] = mcp_url
        job["status"] = "completed"
        job["progress"] = 1.0
        job["total_memories"] = stored
        logger.info(f"[{job_id}] Complete! {stored} memories stored. MCP: {mcp_url}")

    except Exception as e:
        logger.error(f"[{job_id}] Failed: {e}")
        job["status"] = "failed"
        job["error"] = str(e)

    job["updated_at"] = datetime.now(timezone.utc).isoformat()

# ============================================
# API ENDPOINTS
# ============================================

@app.get("/health")
async def health():
    return {"status": "ok", "service": "sunaiva-pipeline", "version": "0.1.0"}

@app.post("/upload")
async def upload_file(background_tasks: BackgroundTasks, file: UploadFile = File(...)):
    """Upload a chat export file for processing."""

    # Validate file
    if not file.filename:
        raise HTTPException(400, "No filename provided")

    allowed_ext = {".json", ".jsonl", ".zip", ".txt"}
    ext = Path(file.filename).suffix.lower()
    if ext not in allowed_ext:
        raise HTTPException(400, f"Unsupported file type: {ext}. Use: {allowed_ext}")

    # Read content
    content = await file.read()
    if len(content) > 100 * 1024 * 1024:  # 100MB limit
        raise HTTPException(413, "File too large. Maximum 100MB.")

    # Decode
    try:
        text_content = content.decode("utf-8")
    except UnicodeDecodeError:
        raise HTTPException(400, "File must be UTF-8 encoded text")

    # Create job
    job_id = str(uuid.uuid4())
    now = datetime.now(timezone.utc).isoformat()

    jobs[job_id] = {
        "job_id": job_id,
        "status": "queued",
        "progress": 0.0,
        "total_conversations": 0,
        "processed_conversations": 0,
        "total_memories": 0,
        "error": None,
        "mcp_endpoint": None,
        "created_at": now,
        "updated_at": now,
    }

    # Save file to disk
    UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
    save_path = UPLOAD_DIR / f"{job_id}_{file.filename}"
    with open(save_path, "w", encoding="utf-8") as f:
        f.write(text_content)

    # Process in background
    background_tasks.add_task(process_upload, job_id, file.filename, text_content)

    return {
        "job_id": job_id,
        "status": "queued",
        "message": f"File '{file.filename}' accepted. Processing started.",
        "check_status": f"/status/{job_id}",
    }

@app.get("/status/{job_id}")
async def get_status(job_id: str):
    """Check processing status of an upload."""
    if job_id not in jobs:
        raise HTTPException(404, "Job not found")
    return jobs[job_id]

@app.get("/jobs")
async def list_jobs():
    """List all processing jobs."""
    return {"jobs": list(jobs.values()), "total": len(jobs)}


     1→"""
     2→Task Board MCP Tools
     3→====================
     4→Shared task board accessible by both Claude and Antigravity agents.
     5→Dual-writes to Redis (real-time) + file snapshot (offline fallback).
     6→
     7→Tools:
     8→- task_board_create: Create task in Redis + file snapshot
     9→- task_board_list: List tasks from Redis (file fallback)
    10→- task_board_update: Update task status/priority
    11→- task_board_claim: Claim task ownership (atomic)
    12→- task_board_complete: Mark complete, log result
    13→"""
    14→
    15→import json
    16→import sys
    17→import logging
    18→import uuid
    19→from datetime import datetime
    20→from pathlib import Path
    21→from typing import Optional
    22→
    23→logger = logging.getLogger(__name__)
    24→
    25→# Paths
    26→SYNC_DIR = Path("/mnt/e/genesis-system/data/antigravity-sync")
    27→TASKBOARD_FILE = SYNC_DIR / "taskboard.json"
    28→EVENTS_FILE = SYNC_DIR / "events.jsonl"
    29→
    30→# Lazy-loaded components
    31→_task_queue = None
    32→_agent_bus = None
    33→
    34→
    35→def _get_task_queue():
    36→    """Lazy load TaskQueue."""
    37→    global _task_queue
    38→    if _task_queue is None:
    39→        try:
    40→            sys.path.insert(0, '/mnt/e/genesis-system')
    41→            sys.path.insert(0, '/mnt/e/genesis-system/data/genesis-memory')
    42→            from AIVA.tasks.task_queue import TaskQueue
    43→            _task_queue = TaskQueue()
    44→        except Exception as e:
    45→            logger.warning(f"TaskQueue unavailable: {e}")
    46→    return _task_queue
    47→
    48→
    49→def _get_agent_bus():
    50→    """Lazy load AgentBus."""
    51→    global _agent_bus
    52→    if _agent_bus is None:
    53→        try:
    54→            sys.path.insert(0, '/mnt/e/genesis-system')
    55→            sys.path.insert(0, '/mnt/e/genesis-system/data/genesis-memory')
    56→            from AIVA.agents.agent_bus import AgentBus
    57→            from AIVA.agents.message_schemas import AgentType
    58→            _agent_bus = AgentBus(agent_type=AgentType.CLAUDE, enable_audit=False)
    59→            _agent_bus.connect()
    60→        except Exception as e:
    61→            logger.warning(f"AgentBus unavailable: {e}")
    62→    return _agent_bus
    63→
    64→
    65→def _log_event(source: str, event_type: str, data: dict):
    66→    """Append event to events.jsonl."""
    67→    try:
    68→        event = {
    69→            "timestamp": datetime.utcnow().isoformat(),
    70→            "source": source,
    71→            "type": event_type,
    72→            "data": data
    73→        }
    74→        EVENTS_FILE.parent.mkdir(parents=True, exist_ok=True)
    75→        with open(EVENTS_FILE, "a") as f:
    76→            f.write(json.dumps(event) + "\n")
    77→    except Exception as e:
    78→        logger.error(f"Failed to log event: {e}")
    79→
    80→
    81→def _publish_event(event_type: str, payload: dict):
    82→    """Publish event via AgentBus if available."""
    83→    bus = _get_agent_bus()
    84→    if bus:
    85→        try:
    86→            from AIVA.agents.message_schemas import EventMessage, AgentType
    87→            msg = EventMessage(
    88→                sender=AgentType.CLAUDE,
    89→                channel="aiva:events",
    90→                event_type=event_type,
    91→                payload=payload,
    92→                severity="info"
    93→            )
    94→            bus.publish("aiva:events", msg)
    95→        except Exception as e:
    96→            logger.debug(f"Redis event publish failed (file fallback active): {e}")
    97→
    98→
    99→def _snapshot_board(tasks: list):
   100→    """Write full task board snapshot to file."""
   101→    try:
   102→        snapshot = {
   103→            "tasks": tasks,
   104→            "last_updated": datetime.utcnow().isoformat(),
   105→            "snapshot_source": "task_board_tools"
   106→        }
   107→        TASKBOARD_FILE.parent.mkdir(parents=True, exist_ok=True)
   108→        with open(TASKBOARD_FILE, "w") as f:
   109→            json.dump(snapshot, f, indent=2)
   110→    except Exception as e:
   111→        logger.error(f"Failed to snapshot board: {e}")
   112→
   113→
   114→def _read_board_from_file() -> list:
   115→    """Read task board from file fallback."""
   116→    try:
   117→        if TASKBOARD_FILE.exists():
   118→            with open(TASKBOARD_FILE, "r") as f:
   119→                data = json.load(f)
   120→                return data.get("tasks", [])
   121→    except Exception as e:
   122→        logger.error(f"Failed to read board from file: {e}")
   123→    return []
   124→
   125→
   126→def _read_board_from_redis() -> Optional[list]:
   127→    """Read all tasks from Redis TaskQueue."""
   128→    queue = _get_task_queue()
   129→    if not queue:
   130→        return None
   131→    try:
   132→        tasks = queue.peek(n=50)
   133→        return [t.to_dict() for t in tasks]
   134→    except Exception as e:
   135→        logger.debug(f"Redis board read failed: {e}")
   136→        return None
   137→
   138→
   139→def task_board_create(task_json: str) -> str:
   140→    """
   141→    Create a new task on the shared board.
   142→
   143→    Args:
   144→        task_json: JSON with fields: title, description, type, urgency (1-10),
   145→                   impact (1-10), deadline (ISO-8601 optional)
   146→
   147→    Returns:
   148→        JSON with created task_id and status
   149→    """
   150→    try:
   151→        task_data = json.loads(task_json)
   152→    except json.JSONDecodeError as e:
   153→        return json.dumps({"error": f"Invalid JSON: {e}"})
   154→
   155→    task_id = str(uuid.uuid4())
   156→    now = datetime.utcnow().isoformat()
   157→
   158→    task_record = {
   159→        "id": task_id,
   160→        "title": task_data.get("title", "Untitled"),
   161→        "description": task_data.get("description", ""),
   162→        "type": task_data.get("type", "general"),
   163→        "urgency": min(10, max(1, task_data.get("urgency", 5))),
   164→        "impact": min(10, max(1, task_data.get("impact", 5))),
   165→        "deadline": task_data.get("deadline"),
   166→        "status": "pending",
   167→        "claimed_by": None,
   168→        "result": None,
   169→        "created_at": now,
   170→        "updated_at": now
   171→    }
   172→
   173→    # 1. Write to Redis TaskQueue
   174→    redis_ok = False
   175→    queue = _get_task_queue()
   176→    if queue:
   177→        try:
   178→            from AIVA.tasks.task_schema import Task
   179→            redis_task = Task(
   180→                id=task_id,
   181→                type=task_record["type"],
   182→                urgency=task_record["urgency"],
   183→                impact=task_record["impact"],
   184→                payload={
   185→                    "title": task_record["title"],
   186→                    "description": task_record["description"],
   187→                    "claimed_by": None
   188→                }
   189→            )
   190→            if task_record.get("deadline"):
   191→                redis_task.deadline = datetime.fromisoformat(task_record["deadline"])
   192→            redis_ok = queue.enqueue(redis_task)
   193→        except Exception as e:
   194→            logger.warning(f"Redis enqueue failed: {e}")
   195→
   196→    # 2. Publish event via AgentBus
   197→    _publish_event("task_created", {"task_id": task_id, "title": task_record["title"]})
   198→
   199→    # 3. Snapshot to file (always)
   200→    existing = _read_board_from_file()
   201→    existing.append(task_record)
   202→    _snapshot_board(existing)
   203→
   204→    # 4. Log event
   205→    _log_event("claude", "task_created", {"task_id": task_id, "title": task_record["title"]})
   206→
   207→    return json.dumps({
   208→        "task_id": task_id,
   209→        "status": "created",
   210→        "redis": redis_ok,
   211→        "file_snapshot": True
   212→    })
   213→
   214→
   215→def task_board_list(status: str = "", limit: int = 20) -> str:
   216→    """
   217→    List tasks from the shared board.
   218→
   219→    Args:
   220→        status: Filter by status (pending, in_progress, completed, failed). Empty = all.
   221→        limit: Max tasks to return (default 20)
   222→
   223→    Returns:
   224→        JSON array of tasks
   225→    """
   226→    # Try Redis first
   227→    tasks = _read_board_from_redis()
   228→
   229→    if tasks is None:
   230→        # Fallback to file
   231→        tasks = _read_board_from_file()
   232→
   233→    # Filter by status
   234→    if status:
   235→        tasks = [t for t in tasks if t.get("status") == status]
   236→
   237→    # Limit
   238→    tasks = tasks[:limit]
   239→
   240→    return json.dumps({
   241→        "tasks": tasks,
   242→        "count": len(tasks),
   243→        "source": "redis" if _read_board_from_redis() is not None else "file"
   244→    })
   245→
   246→
   247→def task_board_update(task_id: str, updates_json: str) -> str:
   248→    """
   249→    Update a task on the shared board.
   250→
   251→    Args:
   252→        task_id: ID of task to update
   253→        updates_json: JSON with fields to update (status, priority, description, etc.)
   254→
   255→    Returns:
   256→        JSON with update result
   257→    """
   258→    try:
   259→        updates = json.loads(updates_json)
   260→    except json.JSONDecodeError as e:
   261→        return json.dumps({"error": f"Invalid JSON: {e}"})
   262→
   263→    now = datetime.utcnow().isoformat()
   264→
   265→    # Update in Redis
   266→    redis_ok = False
   267→    queue = _get_task_queue()
   268→    if queue:
   269→        try:
   270→            task = queue.get_task(task_id)
   271→            if task:
   272→                if "urgency" in updates:
   273→                    task.urgency = min(10, max(1, updates["urgency"]))
   274→                if "impact" in updates:
   275→                    task.impact = min(10, max(1, updates["impact"]))
   276→                if "status" in updates:
   277→                    from AIVA.tasks.task_schema import TaskStatus
   278→                    task.status = TaskStatus(updates["status"])
   279→                redis_ok = queue.update_task(task)
   280→        except Exception as e:
   281→            logger.debug(f"Redis update failed: {e}")
   282→
   283→    # Update in file
   284→    tasks = _read_board_from_file()
   285→    updated = False
   286→    for t in tasks:
   287→        if t.get("id") == task_id:
   288→            t.update(updates)
   289→            t["updated_at"] = now
   290→            updated = True
   291→            break
   292→
   293→    if updated:
   294→        _snapshot_board(tasks)
   295→
   296→    # Publish event
   297→    _publish_event("task_updated", {"task_id": task_id, "updates": updates})
   298→    _log_event("claude", "task_updated", {"task_id": task_id, "updates": updates})
   299→
   300→    return json.dumps({
   301→        "task_id": task_id,
   302→        "updated": updated or redis_ok,
   303→        "redis": redis_ok,
   304→        "file": updated
   305→    })
   306→
   307→
   308→def task_board_claim(task_id: str, agent: str) -> str:
   309→    """
   310→    Claim task ownership atomically.
   311→
   312→    Args:
   313→        task_id: ID of task to claim
   314→        agent: Agent name claiming the task (e.g., "claude", "antigravity")
   315→
   316→    Returns:
   317→        JSON with claim result
   318→    """
   319→    now = datetime.utcnow().isoformat()
   320→
   321→    # Atomic claim in Redis via WATCH/MULTI
   322→    redis_claimed = False
   323→    queue = _get_task_queue()
   324→    if queue:
   325→        try:
   326→            task = queue.get_task(task_id)
   327→            if task:
   328→                current_owner = task.payload.get("claimed_by")
   329→                if current_owner and current_owner != agent:
   330→                    return json.dumps({
   331→                        "claimed": False,
   332→                        "error": f"Task already claimed by {current_owner}"
   333→                    })
   334→                task.payload["claimed_by"] = agent
   335→                from AIVA.tasks.task_schema import TaskStatus
   336→                task.status = TaskStatus.IN_PROGRESS
   337→                redis_claimed = queue.update_task(task)
   338→        except Exception as e:
   339→            logger.debug(f"Redis claim failed: {e}")
   340→
   341→    # Claim in file
   342→    tasks = _read_board_from_file()
   343→    file_claimed = False
   344→    for t in tasks:
   345→        if t.get("id") == task_id:
   346→            if t.get("claimed_by") and t["claimed_by"] != agent:
   347→                if not redis_claimed:
   348→                    return json.dumps({
   349→                        "claimed": False,
   350→                        "error": f"Task already claimed by {t['claimed_by']}"
   351→                    })
   352→            t["claimed_by"] = agent
   353→            t["status"] = "in_progress"
   354→            t["updated_at"] = now
   355→            file_claimed = True
   356→            break
   357→
   358→    if file_claimed:
   359→        _snapshot_board(tasks)
   360→
   361→    _publish_event("task_claimed", {"task_id": task_id, "agent": agent})
   362→    _log_event(agent, "task_claimed", {"task_id": task_id})
   363→
   364→    return json.dumps({
   365→        "task_id": task_id,
   366→        "claimed": redis_claimed or file_claimed,
   367→        "claimed_by": agent
   368→    })
   369→
   370→
   371→def task_board_complete(task_id: str, result_json: str) -> str:
   372→    """
   373→    Mark task as complete with result.
   374→
   375→    Args:
   376→        task_id: ID of task to complete
   377→        result_json: JSON with result data (summary, files_changed, etc.)
   378→
   379→    Returns:
   380→        JSON with completion status
   381→    """
   382→    try:
   383→        result_data = json.loads(result_json)
   384→    except json.JSONDecodeError as e:
   385→        return json.dumps({"error": f"Invalid JSON: {e}"})
   386→
   387→    now = datetime.utcnow().isoformat()
   388→
   389→    # Complete in Redis
   390→    redis_ok = False
   391→    queue = _get_task_queue()
   392→    if queue:
   393→        try:
   394→            task = queue.get_task(task_id)
   395→            if task:
   396→                from AIVA.tasks.task_schema import TaskStatus
   397→                task.status = TaskStatus.COMPLETED
   398→                task.result = result_data
   399→                redis_ok = queue.update_task(task)
   400→        except Exception as e:
   401→            logger.debug(f"Redis complete failed: {e}")
   402→
   403→    # Complete in file
   404→    tasks = _read_board_from_file()
   405→    file_ok = False
   406→    for t in tasks:
   407→        if t.get("id") == task_id:
   408→            t["status"] = "completed"
   409→            t["result"] = result_data
   410→            t["updated_at"] = now
   411→            t["completed_at"] = now
   412→            file_ok = True
   413→            break
   414→
   415→    if file_ok:
   416→        _snapshot_board(tasks)
   417→
   418→    _publish_event("task_completed", {"task_id": task_id, "result": result_data})
   419→    _log_event("claude", "task_completed", {"task_id": task_id})
   420→
   421→    return json.dumps({
   422→        "task_id": task_id,
   423→        "completed": redis_ok or file_ok,
   424→        "redis": redis_ok,
   425→        "file": file_ok
   426→    })
   427→

"""
Task Board MCP Tools
====================
Shared task board accessible by both Claude and Antigravity agents.
Dual-writes to Redis (real-time) + file snapshot (offline fallback).

Tools:
- task_board_create: Create task in Redis + file snapshot
- task_board_list: List tasks from Redis (file fallback)
- task_board_update: Update task status/priority
- task_board_claim: Claim task ownership (atomic)
- task_board_complete: Mark complete, log result
"""

import json
import sys
import logging
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)

# Paths
SYNC_DIR = Path("/mnt/e/genesis-system/data/antigravity-sync")
TASKBOARD_FILE = SYNC_DIR / "taskboard.json"
EVENTS_FILE = SYNC_DIR / "events.jsonl"

# Lazy-loaded components
_task_queue = None
_agent_bus = None

def _get_task_queue():
    """Lazy load TaskQueue."""
    global _task_queue
    if _task_queue is None:
        try:
            sys.path.insert(0, '/mnt/e/genesis-system')
            sys.path.insert(0, '/mnt/e/genesis-system/data/genesis-memory')
            from AIVA.tasks.task_queue import TaskQueue
            _task_queue = TaskQueue()
        except Exception as e:
            logger.warning(f"TaskQueue unavailable: {e}")
    return _task_queue

def _get_agent_bus():
    """Lazy load AgentBus."""
    global _agent_bus
    if _agent_bus is None:
        try:
            sys.path.insert(0, '/mnt/e/genesis-system')
            sys.path.insert(0, '/mnt/e/genesis-system/data/genesis-memory')
            from AIVA.agents.agent_bus import AgentBus
            from AIVA.agents.message_schemas import AgentType
            _agent_bus = AgentBus(agent_type=AgentType.CLAUDE, enable_audit=False)
            _agent_bus.connect()
        except Exception as e:
            logger.warning(f"AgentBus unavailable: {e}")
    return _agent_bus

def _log_event(source: str, event_type: str, data: dict):
    """Append event to events.jsonl."""
    try:
        event = {
            "timestamp": datetime.utcnow().isoformat(),
            "source": source,
            "type": event_type,
            "data": data
        }
        EVENTS_FILE.parent.mkdir(parents=True, exist_ok=True)
        with open(EVENTS_FILE, "a") as f:
            f.write(json.dumps(event) + "\n")
    except Exception as e:
        logger.error(f"Failed to log event: {e}")

def _publish_event(event_type: str, payload: dict):
    """Publish event via AgentBus if available."""
    bus = _get_agent_bus()
    if bus:
        try:
            from AIVA.agents.message_schemas import EventMessage, AgentType
            msg = EventMessage(
                sender=AgentType.CLAUDE,
                channel="aiva:events",
                event_type=event_type,
                payload=payload,
                severity="info"
            )
            bus.publish("aiva:events", msg)
        except Exception as e:
            logger.debug(f"Redis event publish failed (file fallback active): {e}")

def _snapshot_board(tasks: list):
    """Write full task board snapshot to file."""
    try:
        snapshot = {
            "tasks": tasks,
            "last_updated": datetime.utcnow().isoformat(),
            "snapshot_source": "task_board_tools"
        }
        TASKBOARD_FILE.parent.mkdir(parents=True, exist_ok=True)
        with open(TASKBOARD_FILE, "w") as f:
            json.dump(snapshot, f, indent=2)
    except Exception as e:
        logger.error(f"Failed to snapshot board: {e}")

def _read_board_from_file() -> list:
    """Read task board from file fallback."""
    try:
        if TASKBOARD_FILE.exists():
            with open(TASKBOARD_FILE, "r") as f:
                data = json.load(f)
                return data.get("tasks", [])
    except Exception as e:
        logger.error(f"Failed to read board from file: {e}")
    return []

def _read_board_from_redis() -> Optional[list]:
    """Read all tasks from Redis TaskQueue."""
    queue = _get_task_queue()
    if not queue:
        return None
    try:
        tasks = queue.peek(n=50)
        return [t.to_dict() for t in tasks]
    except Exception as e:
        logger.debug(f"Redis board read failed: {e}")
        return None

def task_board_create(task_json: str) -> str:
    """
    Create a new task on the shared board.

    Args:
        task_json: JSON with fields: title, description, type, urgency (1-10),
                   impact (1-10), deadline (ISO-8601 optional)

    Returns:
        JSON with created task_id and status
    """
    try:
        task_data = json.loads(task_json)
    except json.JSONDecodeError as e:
        return json.dumps({"error": f"Invalid JSON: {e}"})

    task_id = str(uuid.uuid4())
    now = datetime.utcnow().isoformat()

    task_record = {
        "id": task_id,
        "title": task_data.get("title", "Untitled"),
        "description": task_data.get("description", ""),
        "type": task_data.get("type", "general"),
        "urgency": min(10, max(1, task_data.get("urgency", 5))),
        "impact": min(10, max(1, task_data.get("impact", 5))),
        "deadline": task_data.get("deadline"),
        "status": "pending",
        "claimed_by": None,
        "result": None,
        "created_at": now,
        "updated_at": now
    }

    # 1. Write to Redis TaskQueue
    redis_ok = False
    queue = _get_task_queue()
    if queue:
        try:
            from AIVA.tasks.task_schema import Task
            redis_task = Task(
                id=task_id,
                type=task_record["type"],
                urgency=task_record["urgency"],
                impact=task_record["impact"],
                payload={
                    "title": task_record["title"],
                    "description": task_record["description"],
                    "claimed_by": None
                }
            )
            if task_record.get("deadline"):
                redis_task.deadline = datetime.fromisoformat(task_record["deadline"])
            redis_ok = queue.enqueue(redis_task)
        except Exception as e:
            logger.warning(f"Redis enqueue failed: {e}")

    # 2. Publish event via AgentBus
    _publish_event("task_created", {"task_id": task_id, "title": task_record["title"]})

    # 3. Snapshot to file (always)
    existing = _read_board_from_file()
    existing.append(task_record)
    _snapshot_board(existing)

    # 4. Log event
    _log_event("claude", "task_created", {"task_id": task_id, "title": task_record["title"]})

    return json.dumps({
        "task_id": task_id,
        "status": "created",
        "redis": redis_ok,
        "file_snapshot": True
    })

def task_board_list(status: str = "", limit: int = 20) -> str:
    """
    List tasks from the shared board.

    Args:
        status: Filter by status (pending, in_progress, completed, failed). Empty = all.
        limit: Max tasks to return (default 20)

    Returns:
        JSON array of tasks
    """
    # Try Redis first
    tasks = _read_board_from_redis()

    if tasks is None:
        # Fallback to file
        tasks = _read_board_from_file()

    # Filter by status
    if status:
        tasks = [t for t in tasks if t.get("status") == status]

    # Limit
    tasks = tasks[:limit]

    return json.dumps({
        "tasks": tasks,
        "count": len(tasks),
        "source": "redis" if _read_board_from_redis() is not None else "file"
    })

def task_board_update(task_id: str, updates_json: str) -> str:
    """
    Update a task on the shared board.

    Args:
        task_id: ID of task to update
        updates_json: JSON with fields to update (status, priority, description, etc.)

    Returns:
        JSON with update result
    """
    try:
        updates = json.loads(updates_json)
    except json.JSONDecodeError as e:
        return json.dumps({"error": f"Invalid JSON: {e}"})

    now = datetime.utcnow().isoformat()

    # Update in Redis
    redis_ok = False
    queue = _get_task_queue()
    if queue:
        try:
            task = queue.get_task(task_id)
            if task:
                if "urgency" in updates:
                    task.urgency = min(10, max(1, updates["urgency"]))
                if "impact" in updates:
                    task.impact = min(10, max(1, updates["impact"]))
                if "status" in updates:
                    from AIVA.tasks.task_schema import TaskStatus
                    task.status = TaskStatus(updates["status"])
                redis_ok = queue.update_task(task)
        except Exception as e:
            logger.debug(f"Redis update failed: {e}")

    # Update in file
    tasks = _read_board_from_file()
    updated = False
    for t in tasks:
        if t.get("id") == task_id:
            t.update(updates)
            t["updated_at"] = now
            updated = True
            break

    if updated:
        _snapshot_board(tasks)

    # Publish event
    _publish_event("task_updated", {"task_id": task_id, "updates": updates})
    _log_event("claude", "task_updated", {"task_id": task_id, "updates": updates})

    return json.dumps({
        "task_id": task_id,
        "updated": updated or redis_ok,
        "redis": redis_ok,
        "file": updated
    })

def task_board_claim(task_id: str, agent: str) -> str:
    """
    Claim task ownership atomically.

    Args:
        task_id: ID of task to claim
        agent: Agent name claiming the task (e.g., "claude", "antigravity")

    Returns:
        JSON with claim result
    """
    now = datetime.utcnow().isoformat()

    # Atomic claim in Redis via WATCH/MULTI
    redis_claimed = False
    queue = _get_task_queue()
    if queue:
        try:
            task = queue.get_task(task_id)
            if task:
                current_owner = task.payload.get("claimed_by")
                if current_owner and current_owner != agent:
                    return json.dumps({
                        "claimed": False,
                        "error": f"Task already claimed by {current_owner}"
                    })
                task.payload["claimed_by"] = agent
                from AIVA.tasks.task_schema import TaskStatus
                task.status = TaskStatus.IN_PROGRESS
                redis_claimed = queue.update_task(task)
        except Exception as e:
            logger.debug(f"Redis claim failed: {e}")

    # Claim in file
    tasks = _read_board_from_file()
    file_claimed = False
    for t in tasks:
        if t.get("id") == task_id:
            if t.get("claimed_by") and t["claimed_by"] != agent:
                if not redis_claimed:
                    return json.dumps({
                        "claimed": False,
                        "error": f"Task already claimed by {t['claimed_by']}"
                    })
            t["claimed_by"] = agent
            t["status"] = "in_progress"
            t["updated_at"] = now
            file_claimed = True
            break

    if file_claimed:
        _snapshot_board(tasks)

    _publish_event("task_claimed", {"task_id": task_id, "agent": agent})
    _log_event(agent, "task_claimed", {"task_id": task_id})

    return json.dumps({
        "task_id": task_id,
        "claimed": redis_claimed or file_claimed,
        "claimed_by": agent
    })

def task_board_complete(task_id: str, result_json: str) -> str:
    """
    Mark task as complete with result.

    Args:
        task_id: ID of task to complete
        result_json: JSON with result data (summary, files_changed, etc.)

    Returns:
        JSON with completion status
    """
    try:
        result_data = json.loads(result_json)
    except json.JSONDecodeError as e:
        return json.dumps({"error": f"Invalid JSON: {e}"})

    now = datetime.utcnow().isoformat()

    # Complete in Redis
    redis_ok = False
    queue = _get_task_queue()
    if queue:
        try:
            task = queue.get_task(task_id)
            if task:
                from AIVA.tasks.task_schema import TaskStatus
                task.status = TaskStatus.COMPLETED
                task.result = result_data
                redis_ok = queue.update_task(task)
        except Exception as e:
            logger.debug(f"Redis complete failed: {e}")

    # Complete in file
    tasks = _read_board_from_file()
    file_ok = False
    for t in tasks:
        if t.get("id") == task_id:
            t["status"] = "completed"
            t["result"] = result_data
            t["updated_at"] = now
            t["completed_at"] = now
            file_ok = True
            break

    if file_ok:
        _snapshot_board(tasks)

    _publish_event("task_completed", {"task_id": task_id, "result": result_data})
    _log_event("claude", "task_completed", {"task_id": task_id})

    return json.dumps({
        "task_id": task_id,
        "completed": redis_ok or file_ok,
        "redis": redis_ok,
        "file": file_ok
    })


     1→"""
     2→Handoff MCP Tools
     3→==================
     4→Structured auto-handoff when an agent finishes a block of work.
     5→Enables seamless context transfer between Claude and Antigravity agents.
     6→
     7→Tools:
     8→- handoff_push: Write structured handoff + publish event
     9→- handoff_pull: Read latest handoff(s) + recent events since last sync
    10→"""
    11→
    12→import json
    13→import sys
    14→import logging
    15→from datetime import datetime
    16→from pathlib import Path
    17→
    18→logger = logging.getLogger(__name__)
    19→
    20→# Paths
    21→SYNC_DIR = Path("/mnt/e/genesis-system/data/antigravity-sync")
    22→HANDOFFS_DIR = SYNC_DIR / "handoffs"
    23→EVENTS_FILE = SYNC_DIR / "events.jsonl"
    24→STATE_FILE = SYNC_DIR / "current_state.json"
    25→
    26→# Lazy AgentBus
    27→_agent_bus = None
    28→
    29→
    30→def _get_agent_bus():
    31→    """Lazy load AgentBus."""
    32→    global _agent_bus
    33→    if _agent_bus is None:
    34→        try:
    35→            sys.path.insert(0, '/mnt/e/genesis-system')
    36→            sys.path.insert(0, '/mnt/e/genesis-system/data/genesis-memory')
    37→            from AIVA.agents.agent_bus import AgentBus
    38→            from AIVA.agents.message_schemas import AgentType
    39→            _agent_bus = AgentBus(agent_type=AgentType.CLAUDE, enable_audit=False)
    40→            _agent_bus.connect()
    41→        except Exception as e:
    42→            logger.warning(f"AgentBus unavailable for handoffs: {e}")
    43→    return _agent_bus
    44→
    45→
    46→def _log_event(source: str, event_type: str, data: dict):
    47→    """Append event to events.jsonl."""
    48→    try:
    49→        event = {
    50→            "timestamp": datetime.utcnow().isoformat(),
    51→            "source": source,
    52→            "type": event_type,
    53→            "data": data
    54→        }
    55→        EVENTS_FILE.parent.mkdir(parents=True, exist_ok=True)
    56→        with open(EVENTS_FILE, "a") as f:
    57→            f.write(json.dumps(event) + "\n")
    58→    except Exception as e:
    59→        logger.error(f"Failed to log event: {e}")
    60→
    61→
    62→def _update_current_state(agent: str, handoff_summary: str):
    63→    """Update current_state.json with latest handoff info."""
    64→    try:
    65→        state = {}
    66→        if STATE_FILE.exists():
    67→            with open(STATE_FILE, "r") as f:
    68→                state = json.load(f)
    69→
    70→        state["last_updated"] = datetime.utcnow().isoformat()
    71→        if "coordination" not in state:
    72→            state["coordination"] = {}
    73→
    74→        state["coordination"]["last_handoff"] = {
    75→            "agent": agent,
    76→            "summary": handoff_summary[:500],
    77→            "timestamp": datetime.utcnow().isoformat()
    78→        }
    79→
    80→        with open(STATE_FILE, "w") as f:
    81→            json.dump(state, f, indent=2)
    82→    except Exception as e:
    83→        logger.error(f"Failed to update current state: {e}")
    84→
    85→
    86→def handoff_push(summary: str, files_changed_json: str, next_actions_json: str) -> str:
    87→    """
    88→    Push a structured handoff when an agent finishes a block of work.
    89→
    90→    Args:
    91→        summary: What was done (text description)
    92→        files_changed_json: JSON array of file paths that were changed
    93→        next_actions_json: JSON array of recommended next actions
    94→
    95→    Returns:
    96→        JSON with handoff ID and status
    97→    """
    98→    try:
    99→        files_changed = json.loads(files_changed_json) if files_changed_json else []
   100→    except json.JSONDecodeError:
   101→        files_changed = []
   102→
   103→    try:
   104→        next_actions = json.loads(next_actions_json) if next_actions_json else []
   105→    except json.JSONDecodeError:
   106→        next_actions = []
   107→
   108→    agent = "claude"  # This tool is called from Claude's context
   109→    now = datetime.utcnow().isoformat()
   110→
   111→    handoff = {
   112→        "agent": agent,
   113→        "timestamp": now,
   114→        "summary": summary,
   115→        "files_changed": files_changed,
   116→        "tasks_completed": [],
   117→        "tasks_created": [],
   118→        "next_actions": next_actions,
   119→        "context_snapshot": {
   120→            "session_id": None,
   121→            "token_usage": None
   122→        }
   123→    }
   124→
   125→    # 1. Write to handoff file
   126→    HANDOFFS_DIR.mkdir(parents=True, exist_ok=True)
   127→    latest_file = HANDOFFS_DIR / f"LATEST_{agent}.json"
   128→    archive_file = HANDOFFS_DIR / f"{agent}_{now.replace(':', '-')}.json"
   129→
   130→    try:
   131→        with open(latest_file, "w") as f:
   132→            json.dump(handoff, f, indent=2)
   133→        with open(archive_file, "w") as f:
   134→            json.dump(handoff, f, indent=2)
   135→    except Exception as e:
   136→        logger.error(f"Failed to write handoff file: {e}")
   137→
   138→    # 2. Publish Redis event
   139→    bus = _get_agent_bus()
   140→    if bus:
   141→        try:
   142→            from AIVA.agents.message_schemas import EventMessage, AgentType
   143→            msg = EventMessage(
   144→                sender=AgentType.CLAUDE,
   145→                channel="aiva:events",
   146→                event_type="handoff_pushed",
   147→                payload={
   148→                    "agent": agent,
   149→                    "summary": summary[:200],
   150→                    "files_count": len(files_changed),
   151→                    "actions_count": len(next_actions)
   152→                },
   153→                severity="info"
   154→            )
   155→            bus.publish("aiva:events", msg)
   156→        except Exception as e:
   157→            logger.debug(f"Redis handoff event failed: {e}")
   158→
   159→    # 3. Update current_state.json
   160→    _update_current_state(agent, summary)
   161→
   162→    # 4. Log event
   163→    _log_event(agent, "handoff_pushed", {
   164→        "summary": summary[:200],
   165→        "files_changed": len(files_changed),
   166→        "next_actions": len(next_actions)
   167→    })
   168→
   169→    return json.dumps({
   170→        "handoff_id": f"{agent}_{now}",
   171→        "status": "pushed",
   172→        "file": str(latest_file),
   173→        "files_changed": len(files_changed),
   174→        "next_actions": len(next_actions)
   175→    })
   176→
   177→
   178→def handoff_pull(agent: str = "") -> str:
   179→    """
   180→    Pull latest handoff(s) and recent events since last sync.
   181→
   182→    Args:
   183→        agent: Filter by agent name (empty = all agents). Common values:
   184→               "claude", "antigravity"
   185→
   186→    Returns:
   187→        JSON with latest handoffs, recent events, and sync timestamp
   188→    """
   189→    handoffs = []
   190→    HANDOFFS_DIR.mkdir(parents=True, exist_ok=True)
   191→
   192→    # Read latest handoffs
   193→    if agent:
   194→        agents_to_check = [agent]
   195→    else:
   196→        agents_to_check = ["claude", "antigravity"]
   197→
   198→    for a in agents_to_check:
   199→        latest_file = HANDOFFS_DIR / f"LATEST_{a}.json"
   200→        if latest_file.exists():
   201→            try:
   202→                with open(latest_file, "r") as f:
   203→                    handoff_data = json.load(f)
   204→                    handoffs.append(handoff_data)
   205→            except Exception as e:
   206→                logger.error(f"Failed to read handoff for {a}: {e}")
   207→
   208→    # Read recent events
   209→    recent_events = []
   210→    sync_file = SYNC_DIR / f"{agent or 'claude'}_last_sync.json"
   211→    last_sync = None
   212→
   213→    if sync_file.exists():
   214→        try:
   215→            with open(sync_file, "r") as f:
   216→                sync_data = json.load(f)
   217→                last_sync = sync_data.get("last_sync")
   218→        except Exception:
   219→            pass
   220→
   221→    if EVENTS_FILE.exists():
   222→        try:
   223→            with open(EVENTS_FILE, "r") as f:
   224→                for line in f:
   225→                    line = line.strip()
   226→                    if not line:
   227→                        continue
   228→                    try:
   229→                        event = json.loads(line)
   230→                        if last_sync:
   231→                            if event.get("timestamp", "") > last_sync:
   232→                                recent_events.append(event)
   233→                        else:
   234→                            recent_events.append(event)
   235→                    except json.JSONDecodeError:
   236→                        continue
   237→        except Exception as e:
   238→            logger.error(f"Failed to read events: {e}")
   239→
   240→    # Limit to last 50 events
   241→    recent_events = recent_events[-50:]
   242→
   243→    # Update sync timestamp
   244→    now = datetime.utcnow().isoformat()
   245→    try:
   246→        sync_file.parent.mkdir(parents=True, exist_ok=True)
   247→        with open(sync_file, "w") as f:
   248→            json.dump({"last_sync": now}, f)
   249→    except Exception as e:
   250→        logger.error(f"Failed to update sync timestamp: {e}")
   251→
   252→    return json.dumps({
   253→        "handoffs": handoffs,
   254→        "recent_events": recent_events,
   255→        "events_count": len(recent_events),
   256→        "synced_at": now,
   257→        "last_sync": last_sync
   258→    })
   259→

"""
Handoff MCP Tools
==================
Structured auto-handoff when an agent finishes a block of work.
Enables seamless context transfer between Claude and Antigravity agents.

Tools:
- handoff_push: Write structured handoff + publish event
- handoff_pull: Read latest handoff(s) + recent events since last sync
"""

import json
import sys
import logging
from datetime import datetime
from pathlib import Path

logger = logging.getLogger(__name__)

# Paths
SYNC_DIR = Path("/mnt/e/genesis-system/data/antigravity-sync")
HANDOFFS_DIR = SYNC_DIR / "handoffs"
EVENTS_FILE = SYNC_DIR / "events.jsonl"
STATE_FILE = SYNC_DIR / "current_state.json"

# Lazy AgentBus
_agent_bus = None

def _get_agent_bus():
    """Lazy load AgentBus."""
    global _agent_bus
    if _agent_bus is None:
        try:
            sys.path.insert(0, '/mnt/e/genesis-system')
            sys.path.insert(0, '/mnt/e/genesis-system/data/genesis-memory')
            from AIVA.agents.agent_bus import AgentBus
            from AIVA.agents.message_schemas import AgentType
            _agent_bus = AgentBus(agent_type=AgentType.CLAUDE, enable_audit=False)
            _agent_bus.connect()
        except Exception as e:
            logger.warning(f"AgentBus unavailable for handoffs: {e}")
    return _agent_bus

def _log_event(source: str, event_type: str, data: dict):
    """Append event to events.jsonl."""
    try:
        event = {
            "timestamp": datetime.utcnow().isoformat(),
            "source": source,
            "type": event_type,
            "data": data
        }
        EVENTS_FILE.parent.mkdir(parents=True, exist_ok=True)
        with open(EVENTS_FILE, "a") as f:
            f.write(json.dumps(event) + "\n")
    except Exception as e:
        logger.error(f"Failed to log event: {e}")

def _update_current_state(agent: str, handoff_summary: str):
    """Update current_state.json with latest handoff info."""
    try:
        state = {}
        if STATE_FILE.exists():
            with open(STATE_FILE, "r") as f:
                state = json.load(f)

        state["last_updated"] = datetime.utcnow().isoformat()
        if "coordination" not in state:
            state["coordination"] = {}

        state["coordination"]["last_handoff"] = {
            "agent": agent,
            "summary": handoff_summary[:500],
            "timestamp": datetime.utcnow().isoformat()
        }

        with open(STATE_FILE, "w") as f:
            json.dump(state, f, indent=2)
    except Exception as e:
        logger.error(f"Failed to update current state: {e}")

def handoff_push(summary: str, files_changed_json: str, next_actions_json: str) -> str:
    """
    Push a structured handoff when an agent finishes a block of work.

    Args:
        summary: What was done (text description)
        files_changed_json: JSON array of file paths that were changed
        next_actions_json: JSON array of recommended next actions

    Returns:
        JSON with handoff ID and status
    """
    try:
        files_changed = json.loads(files_changed_json) if files_changed_json else []
    except json.JSONDecodeError:
        files_changed = []

    try:
        next_actions = json.loads(next_actions_json) if next_actions_json else []
    except json.JSONDecodeError:
        next_actions = []

    agent = "claude"  # This tool is called from Claude's context
    now = datetime.utcnow().isoformat()

    handoff = {
        "agent": agent,
        "timestamp": now,
        "summary": summary,
        "files_changed": files_changed,
        "tasks_completed": [],
        "tasks_created": [],
        "next_actions": next_actions,
        "context_snapshot": {
            "session_id": None,
            "token_usage": None
        }
    }

    # 1. Write to handoff file
    HANDOFFS_DIR.mkdir(parents=True, exist_ok=True)
    latest_file = HANDOFFS_DIR / f"LATEST_{agent}.json"
    archive_file = HANDOFFS_DIR / f"{agent}_{now.replace(':', '-')}.json"

    try:
        with open(latest_file, "w") as f:
            json.dump(handoff, f, indent=2)
        with open(archive_file, "w") as f:
            json.dump(handoff, f, indent=2)
    except Exception as e:
        logger.error(f"Failed to write handoff file: {e}")

    # 2. Publish Redis event
    bus = _get_agent_bus()
    if bus:
        try:
            from AIVA.agents.message_schemas import EventMessage, AgentType
            msg = EventMessage(
                sender=AgentType.CLAUDE,
                channel="aiva:events",
                event_type="handoff_pushed",
                payload={
                    "agent": agent,
                    "summary": summary[:200],
                    "files_count": len(files_changed),
                    "actions_count": len(next_actions)
                },
                severity="info"
            )
            bus.publish("aiva:events", msg)
        except Exception as e:
            logger.debug(f"Redis handoff event failed: {e}")

    # 3. Update current_state.json
    _update_current_state(agent, summary)

    # 4. Log event
    _log_event(agent, "handoff_pushed", {
        "summary": summary[:200],
        "files_changed": len(files_changed),
        "next_actions": len(next_actions)
    })

    return json.dumps({
        "handoff_id": f"{agent}_{now}",
        "status": "pushed",
        "file": str(latest_file),
        "files_changed": len(files_changed),
        "next_actions": len(next_actions)
    })

def handoff_pull(agent: str = "") -> str:
    """
    Pull latest handoff(s) and recent events since last sync.

    Args:
        agent: Filter by agent name (empty = all agents). Common values:
               "claude", "antigravity"

    Returns:
        JSON with latest handoffs, recent events, and sync timestamp
    """
    handoffs = []
    HANDOFFS_DIR.mkdir(parents=True, exist_ok=True)

    # Read latest handoffs
    if agent:
        agents_to_check = [agent]
    else:
        agents_to_check = ["claude", "antigravity"]

    for a in agents_to_check:
        latest_file = HANDOFFS_DIR / f"LATEST_{a}.json"
        if latest_file.exists():
            try:
                with open(latest_file, "r") as f:
                    handoff_data = json.load(f)
                    handoffs.append(handoff_data)
            except Exception as e:
                logger.error(f"Failed to read handoff for {a}: {e}")

    # Read recent events
    recent_events = []
    sync_file = SYNC_DIR / f"{agent or 'claude'}_last_sync.json"
    last_sync = None

    if sync_file.exists():
        try:
            with open(sync_file, "r") as f:
                sync_data = json.load(f)
                last_sync = sync_data.get("last_sync")
        except Exception:
            pass

    if EVENTS_FILE.exists():
        try:
            with open(EVENTS_FILE, "r") as f:
                for line in f:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        event = json.loads(line)
                        if last_sync:
                            if event.get("timestamp", "") > last_sync:
                                recent_events.append(event)
                        else:
                            recent_events.append(event)
                    except json.JSONDecodeError:
                        continue
        except Exception as e:
            logger.error(f"Failed to read events: {e}")

    # Limit to last 50 events
    recent_events = recent_events[-50:]

    # Update sync timestamp
    now = datetime.utcnow().isoformat()
    try:
        sync_file.parent.mkdir(parents=True, exist_ok=True)
        with open(sync_file, "w") as f:
            json.dump({"last_sync": now}, f)
    except Exception as e:
        logger.error(f"Failed to update sync timestamp: {e}")

    return json.dumps({
        "handoffs": handoffs,
        "recent_events": recent_events,
        "events_count": len(recent_events),
        "synced_at": now,
        "last_sync": last_sync
    })


Good — I can see the full picture now. The `task_board_tools.py` and `handoff_tools.py` both import from `AIVA.tasks` and `AIVA.agents` modules, but they use lazy loading with try/except, so they won't crash — they'll just log warnings and fall back to file-based operations. These aren't direct network calls to AIVA's server; they're local Python module imports that try to use Redis-based AgentBus. They're safe as-is (they gracefully degrade), but the `sync` command in `main.py` is still problematic since it explicitly imports and calls the AIVA client.

Let me fix the `sync` command in `main.py` to use the sovereign MCP endpoint instead of the disabled AIVA client, and update the status display.


Edit

table.add_row("AIVA (Local)", "[bold yellow]STANDBY[/bold yellow]", "Connect via MacMini")

table.add_row("Sovereign Memory", "[bold green]LIVE[/bold green]", "152.53.201.221:8001/mcp")