#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = ["httpx>=0.27"]
# ///
"""
fsv-agent — thin retrieval-aware agent runtime that closes the FullStackVibes
verification flywheel.

What it does (in order, per invocation):

  1. Calls the FullStackVibes Handshake API to retrieve a Precision Bundle of
     typed context windows matching your filters.
  2. Folds the bundle into a system prompt with a verbatim citation rule.
  3. Calls your LLM (OpenRouter, Anthropic, or any OpenAI-compatible endpoint)
     with the bundle + your query, using YOUR API key directly. Prompts and
     responses NEVER touch FSV.
  4. Optionally records a retrieval session back to FSV: window_ids, provider,
     model, latency. No prompt content. The anonymousActorId is generated
     locally on first run and stored at ~/.fsv-agent/actor.txt.
  5. Prints a session id you can later mark SUCCESS or FAILURE — that's the
     empirical-quality signal nothing else in the agent ecosystem produces.

Source : https://fullstackvibes.com/integrations/agent/fsv_agent.py
Docs   : https://fullstackvibes.com/docs/integrations/agent/

Quickstart:

    # one-shot, no install (uv handles deps via the inline metadata block above)
    uv run https://fullstackvibes.com/integrations/agent/fsv_agent.py \\
        --space fintech "how should I handle TradingView webhooks safely?"

    # report outcome later
    fsv_agent.py rate <session-id> --success
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import time
import uuid
from pathlib import Path
from typing import Any

import httpx

API_BASE = os.environ.get("FSV_API_BASE", "https://api.osenv.io")
ACTOR_FILE = Path.home() / ".fsv-agent" / "actor.txt"


def load_or_create_actor_id() -> str:
    """Anonymous, client-generated UUID. Stored once locally; never tied to identity."""
    if ACTOR_FILE.exists():
        try:
            existing = ACTOR_FILE.read_text().strip()
            if len(existing) == 36:
                return existing
        except Exception:
            pass
    ACTOR_FILE.parent.mkdir(parents=True, exist_ok=True)
    new = str(uuid.uuid4())
    ACTOR_FILE.write_text(new + "\n")
    return new


# ────────────────────────────────────────────────────────────
# Handshake
# ────────────────────────────────────────────────────────────

def fetch_bundle(args, client: httpx.Client) -> dict[str, Any]:
    body: dict[str, Any] = {}
    if args.space:        body["spaces"] = args.space
    if args.window_type:  body["windowTypes"] = [t.upper() for t in args.window_type]
    if args.window_tag:   body["windowTags"] = [t.lower() for t in args.window_tag]
    if args.pattern_tag:
        pt: dict[str, list[str]] = {}
        for p in args.pattern_tag:
            if ":" not in p:
                continue
            kind, slug = p.split(":", 1)
            pt.setdefault(kind.upper(), []).append(slug.lower())
        if pt:
            body["patternTags"] = pt
    if args.quality_min is not None: body["qualityMin"]   = args.quality_min
    if args.max_chars:               body["maxChars"]     = args.max_chars
    if args.max_windows:             body["maxWindows"]   = args.max_windows
    if args.include_owner_kept:      body["includeOwnerKept"] = True

    r = client.post(f"{API_BASE}/api/v1/handshake", json=body, timeout=15.0)
    r.raise_for_status()
    return r.json()


def fold_bundle_into_system(bundle: dict[str, Any], extra_system: str | None) -> str:
    windows = bundle.get("data", {}).get("windows", [])
    if not windows:
        return extra_system or ""

    parts: list[str] = []
    parts.append(
        "You have access to a Precision Bundle of typed context windows from "
        "the FullStackVibes verified-context corpus. Each window is a verbatim "
        "slice of a real published artifact, structurally typed (GOAL, "
        "CONSTRAINT, INSTRUCTION, SCHEMA, ANTI_PATTERN, etc.) and provenance-"
        "tracked. Treat the windows as authoritative reference material. When "
        "you cite content from a window, mention the parent artifact's title "
        "in parentheses for traceability."
    )
    parts.append("")
    parts.append("=== BEGIN PRECISION BUNDLE ===")
    for w in windows:
        m = w.get("manifest", {}) or {}
        parts.append(
            f"[{w.get('windowType','?')}] from \"{m.get('title','')}\" "
            f"(quality {m.get('qualityScore') if m.get('qualityScore') is not None else '—'})"
        )
        parts.append(w.get("content", "").rstrip())
        parts.append("")
    parts.append("=== END PRECISION BUNDLE ===")

    if extra_system:
        parts.append("")
        parts.append(extra_system)

    return "\n".join(parts)


# ────────────────────────────────────────────────────────────
# LLM dispatch (raw HTTP — no SDK dependency hell)
# ────────────────────────────────────────────────────────────

def call_anthropic(model: str, system: str, prompt: str, api_key: str, max_tokens: int = 2048) -> str:
    r = httpx.post(
        "https://api.anthropic.com/v1/messages",
        headers={
            "x-api-key": api_key,
            "anthropic-version": "2023-06-01",
            "content-type": "application/json",
        },
        json={
            "model": model,
            "max_tokens": max_tokens,
            "system": system,
            "messages": [{"role": "user", "content": prompt}],
        },
        timeout=120.0,
    )
    r.raise_for_status()
    data = r.json()
    return "".join(b.get("text", "") for b in data.get("content", []) if b.get("type") == "text")


def call_openai_compatible(base_url: str, model: str, system: str, prompt: str, api_key: str, max_tokens: int = 2048) -> str:
    """Works for OpenAI, OpenRouter, Together, Groq, any compatible /v1/chat/completions."""
    messages = []
    if system:
        messages.append({"role": "system", "content": system})
    messages.append({"role": "user", "content": prompt})
    r = httpx.post(
        f"{base_url.rstrip('/')}/chat/completions",
        headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
        json={"model": model, "messages": messages, "max_tokens": max_tokens},
        timeout=120.0,
    )
    r.raise_for_status()
    data = r.json()
    return data["choices"][0]["message"]["content"]


def call_llm(provider: str, model: str, system: str, prompt: str) -> str:
    p = provider.lower()
    if p == "anthropic":
        key = os.environ.get("ANTHROPIC_API_KEY") or _die("ANTHROPIC_API_KEY not set")
        return call_anthropic(model, system, prompt, key)
    if p == "openrouter":
        key = os.environ.get("OPENROUTER_API_KEY") or _die("OPENROUTER_API_KEY not set")
        return call_openai_compatible("https://openrouter.ai/api/v1", model, system, prompt, key)
    if p == "openai":
        key = os.environ.get("OPENAI_API_KEY") or _die("OPENAI_API_KEY not set")
        return call_openai_compatible("https://api.openai.com/v1", model, system, prompt, key)
    _die(f"unknown provider: {provider}. Supported: anthropic, openrouter, openai")


def _die(msg: str) -> Any:
    print(f"fsv-agent: {msg}", file=sys.stderr)
    sys.exit(2)


# ────────────────────────────────────────────────────────────
# Session recording
# ────────────────────────────────────────────────────────────

def record_session(client: httpx.Client, anon: str, bundle: dict[str, Any], provider: str, model: str, latency_ms: int) -> str | None:
    windows = bundle.get("data", {}).get("windows", [])
    window_ids = [w["windowId"] for w in windows if w.get("windowId")]
    if not window_ids:
        return None
    body = {
        "anonymousActorId": anon,
        "windowIds": window_ids,
        "llmProvider": provider,
        "llmModel": model,
        "latencyMs": latency_ms,
        "handshakeFilters": bundle.get("meta", {}).get("filtersApplied", {}),
    }
    try:
        r = client.post(f"{API_BASE}/api/v1/agent/session", json=body, timeout=10.0)
        r.raise_for_status()
        return r.json()["data"]["sessionId"]
    except Exception as e:
        print(f"fsv-agent: session record failed: {e}", file=sys.stderr)
        return None


def report_outcome(session_id: str, anon: str, outcome: str) -> None:
    body = {"anonymousActorId": anon, "outcome": outcome}
    r = httpx.post(f"{API_BASE}/api/v1/agent/session/{session_id}/outcome", json=body, timeout=10.0)
    if r.status_code >= 400:
        print(f"fsv-agent: outcome report failed ({r.status_code}): {r.text[:200]}", file=sys.stderr)
        sys.exit(1)
    print(f"recorded {outcome} for session {session_id}")


# ────────────────────────────────────────────────────────────
# CLI
# ────────────────────────────────────────────────────────────

def main() -> None:
    ap = argparse.ArgumentParser(prog="fsv-agent", description="Retrieval-aware agent runtime over the FullStackVibes Handshake API.")
    sub = ap.add_subparsers(dest="cmd")

    # Default subcommand: ask
    ask = sub.add_parser("ask", help="Run a query through the bundle+LLM loop (default).")
    ask.add_argument("prompt", nargs="+", help="The user query.")
    ask.add_argument("--space", action="append", default=[], help="Space slug filter (repeatable).")
    ask.add_argument("--window-type", action="append", default=[], help="Window type filter (repeatable).")
    ask.add_argument("--window-tag", action="append", default=[], help="Window tag filter (repeatable, AND).")
    ask.add_argument("--pattern-tag", action="append", default=[], help="Pattern tag filter as KIND:slug (repeatable).")
    ask.add_argument("--quality-min", type=float, default=None, help="Minimum parent-artifact quality (0.0–1.0).")
    ask.add_argument("--max-chars", type=int, default=6000, help="Bundle character budget (default 6000).")
    ask.add_argument("--max-windows", type=int, default=24, help="Max windows in bundle (default 24).")
    ask.add_argument("--include-owner-kept", action="store_true", help="Include OWNER_KEPT (preview) artifacts. Useful while corpus is small.")
    ask.add_argument("--provider", default="openrouter", choices=["anthropic", "openrouter", "openai"], help="LLM provider.")
    ask.add_argument("--model", default="google/gemma-4-26b-a4b-it", help="Model name (provider-specific).")
    ask.add_argument("--system", default=None, help="Extra system prompt appended after the bundle.")
    ask.add_argument("--no-handshake", action="store_true", help="Skip retrieval, send query straight to the LLM (baseline comparison).")
    ask.add_argument("--no-verify", action="store_true", help="Don't record a session back to FSV.")
    ask.add_argument("--show-bundle", action="store_true", help="Print the retrieved bundle to stderr before LLM call.")

    # Subcommand: rate
    rate = sub.add_parser("rate", help="Report SUCCESS or FAILURE for a previously-recorded session.")
    rate.add_argument("session_id")
    g = rate.add_mutually_exclusive_group(required=True)
    g.add_argument("--success", action="store_true")
    g.add_argument("--fail", action="store_true")

    # If first arg looks like a free-form prompt and no subcommand was given, default to "ask".
    if len(sys.argv) > 1 and sys.argv[1] not in ("ask", "rate", "-h", "--help"):
        sys.argv.insert(1, "ask")

    args = ap.parse_args()
    anon = load_or_create_actor_id()

    if args.cmd == "rate":
        outcome = "SUCCESS" if args.success else "FAILURE"
        report_outcome(args.session_id, anon, outcome)
        return

    if args.cmd != "ask":
        ap.print_help()
        sys.exit(1)

    prompt = " ".join(args.prompt).strip()
    if not prompt:
        _die("empty prompt")

    client = httpx.Client()

    bundle = {"data": {"windows": []}}
    if not args.no_handshake:
        print("» fetching Precision Bundle…", file=sys.stderr)
        try:
            bundle = fetch_bundle(args, client)
            n = bundle["data"]["count"]
            chars = bundle["data"]["totalChars"]
            print(f"» bundle: {n} windows, {chars} chars", file=sys.stderr)
            if args.show_bundle:
                print(json.dumps(bundle["data"]["windows"], indent=2), file=sys.stderr)
        except Exception as e:
            print(f"fsv-agent: handshake failed: {e}", file=sys.stderr)

    system = fold_bundle_into_system(bundle, args.system)

    print(f"» calling {args.provider}/{args.model}…", file=sys.stderr)
    started = time.monotonic()
    answer = call_llm(args.provider, args.model, system, prompt)
    latency_ms = int((time.monotonic() - started) * 1000)
    print(f"» {latency_ms} ms", file=sys.stderr)

    print(answer)

    if args.no_verify or args.no_handshake or not bundle["data"]["windows"]:
        return

    sid = record_session(client, anon, bundle, args.provider, args.model, latency_ms)
    if sid:
        print(f"\n[fsv-agent] session {sid}", file=sys.stderr)
        print(f"[fsv-agent] rate it later: fsv_agent.py rate {sid} --success", file=sys.stderr)


if __name__ == "__main__":
    main()
