#!/usr/bin/env python3
"""Enter La Forge (https://scoreia.ai/forge/) with a model that runs on your own machine.

Works with any server that speaks the OpenAI chat-completions API with tool calls: Ollama, LM Studio, llama.cpp
(llama-server), vLLM... The model receives the Forge's MCP tools as functions; this loop runs its calls on the public
MCP door https://scoreia.ai/forge/mcp until the model seals its knight. Python 3.9+, no dependency.

    # Ollama (a model with tool support: qwen2.5, qwen3, llama3.1, mistral-nemo...)
    python3 forge_local.py --base-url http://localhost:11434/v1 --model qwen2.5:14b --host ollama
    # LM Studio (load a model, start the local server)
    python3 forge_local.py --base-url http://localhost:1234/v1 --model <model id> --host lm-studio

Options: --campaign (empty by default = the apprentice trial, level 1; or taille-1..3, maitre-1..3), --commission 1-3,
--provider (who made the weights, e.g. alibaba, meta), --api-key if your server wants one.
Your attempt becomes a public card in the hall; the model name is what you declare (not verified).
"""
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request

DOOR = os.environ.get("FORGE_DOOR", "https://scoreia.ai/forge/mcp")   # override only to test against another door
UA = {"User-Agent": "forge-local/1 (+https://scoreia.ai/forge/)"}

PROMPT = """You are the model under test in La Forge (scoreia.ai). Use ONLY the tools you are given.
1. Call enter_forge with provider_claim "{provider}", model_claim "{model}", product "forge_local.py", host "{host}",
   origin "relayed", participation_class "external_candidate"{entry}.
2. Read the commission and its part_format carefully. Build the knight with forge_add (armoury pieces: shape "armour",
   size {{"piece": ..., "w": ..., "h": ..., "d": ...}}, position [x, y, z] in metres, rotation [rx, ry, rz] in degrees).
3. If the commission is animated, move parts frame by frame with forge_keyframe.
4. Call seal_forge once, then say the score.
Make one tool call at a time."""


def post(url, body, headers=None, timeout=600):
    req = urllib.request.Request(url, json.dumps(body).encode(), {"Content-Type": "application/json", **UA, **(headers or {})})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read())


def mcp(method, params, _id=[0]):
    _id[0] += 1
    return post(DOOR, {"jsonrpc": "2.0", "id": _id[0], "method": method, "params": params}, {"Accept": "application/json, text/event-stream"}, 60)


def main():
    for stream in (sys.stdout, sys.stderr):      # Windows consoles are not UTF-8: never crash on an accent
        try:
            stream.reconfigure(errors="replace")
        except (AttributeError, ValueError):
            pass
    ap = argparse.ArgumentParser(description="Enter La Forge with a local model (OpenAI-compatible server).")
    ap.add_argument("--base-url", required=True, help="e.g. http://localhost:11434/v1 (Ollama) or http://localhost:1234/v1 (LM Studio)")
    ap.add_argument("--model", required=True, help="model name as your server knows it")
    ap.add_argument("--host", default="local", help="what serves the model: ollama, lm-studio, llama.cpp, vllm...")
    ap.add_argument("--provider", default="unknown", help="who made the weights (declared, not verified)")
    ap.add_argument("--campaign", default="", help="empty = the apprentice trial (level 1, one standing knight); or taille-1, taille-2, taille-3, maitre-1..3")
    ap.add_argument("--commission", type=int, default=1)
    ap.add_argument("--api-key", default="local")
    ap.add_argument("--max-steps", type=int, default=120)
    a = ap.parse_args()

    tools = [{"type": "function", "function": {"name": t["name"], "description": t["description"], "parameters": t["inputSchema"]}}
             for t in mcp("tools/list", {})["result"]["tools"]]
    init = mcp("initialize", {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "forge-local", "version": "1"}})
    messages = [{"role": "system", "content": init["result"].get("instructions", "")},
                {"role": "user", "content": PROMPT.format(provider=a.provider, model=a.model, host=a.host,
                                                          entry=(f', campaign "{a.campaign}", commission {a.commission}' if a.campaign else ", level 1"))}]
    handle, nudges, sealed = None, 0, False
    for step in range(a.max_steps):
        try:
            r = post(a.base_url.rstrip("/") + "/chat/completions", {"model": a.model, "messages": messages, "tools": tools, "temperature": 0.2},
                     {"Authorization": "Bearer " + a.api_key})
        except urllib.error.HTTPError as e:
            sys.exit(f"model server error {e.code}: {e.read()[:300]!r}")
        msg = r["choices"][0]["message"]
        messages.append({"role": "assistant", "content": msg.get("content") or "", **({"tool_calls": msg["tool_calls"]} if msg.get("tool_calls") else {})})
        if not msg.get("tool_calls"):
            if not sealed and nudges < 3:              # a small model may answer in prose: remind it, at most three times
                nudges += 1
                messages.append({"role": "user", "content": ("Use the tools now: call enter_forge first." if handle is None else
                                                             "Continue with the tools: forge_add the knight's parts, then seal_forge.")})
                continue
            print(msg.get("content") or "")
            break
        for c in msg["tool_calls"]:
            name = c["function"]["name"]
            try:
                args = json.loads(c["function"].get("arguments") or "{}")
            except ValueError:
                args = None
            if args is None:
                text = json.dumps({"ok": False, "code": "arguments_not_json", "error": "the tool arguments were not valid JSON"})
            else:
                if name != "enter_forge" and handle:
                    args["forge_handle"] = handle          # the loop holds the attempt's handle: small models tend to invent one
                res = mcp("tools/call", {"name": name, "arguments": args})
                text = res["result"]["content"][0]["text"] if "result" in res else json.dumps(res.get("error"))
                data = res.get("result", {}).get("structuredContent") or {}
                if name == "enter_forge" and data.get("ok"):
                    handle = data["forge_handle"]
                if name == "seal_forge" and data.get("sealed"):
                    sealed = True
                    card = data["card"]
                    got = f"{card['score']} / 100" if card.get("score") is not None else f"{card.get('passed')} / {card.get('total')} checks"
                    print(f"sealed: {got} - https://scoreia.ai/forge/?run={handle}")
            print(f"[{step}] {name} -> {text[:160]}", file=sys.stderr)
            messages.append({"role": "tool", "tool_call_id": c.get("id", name), "content": text[:20000]})
    else:
        print("stopped after --max-steps; the unsealed attempt expires on its own", file=sys.stderr)


if __name__ == "__main__":
    main()
