#!/usr/bin/env bash
# Deja el carril verde listo para usar: mide, configura y dictamina.
#
#   ./arrancar.sh               medir, configurar y dictaminar
#   ./arrancar.sh --solo-medir  medir sin tocar la configuracion
#   ./arrancar.sh --igual       no plantarse aunque dataset.py siga vivo
#
# ── Por que existe ─────────────────────────────────────────────────────────
#
# El primer arranque del verde topo con algo que no se anticipaba: el prompt
# fijo de Hermes son ~20.500 tokens (26 KB de sistema + 56 KB de esquemas de 19
# herramientas) y qwen3.5:4b-jarvis va con num_ctx 4096. Cinco veces por encima.
# No es que rinda mal: no entra la pregunta.
#
# Para eso existe qwen3.5:4b-verde. Pero cuanto contexto cabe en una GPU de
# 4 GB no se puede calcular sobre el papel —ollama no reporta head_count_kv—,
# hay que medirlo. Este script lo mide, escribe el resultado donde toca y
# contesta la unica pregunta que de verdad decide si esto va a funcionar.
#
# ── El suelo de 64k, que manda sobre todo lo anterior (16 ago) ──────────────
#
# La primera version de este script buscaba el mayor num_ctx que cupiera en la
# tarjeta y encontro 24576, con 32 capas en GPU y 3301 MiB. Todo correcto y
# todo inutil: al abrir Hermes salta
#
#   Model qwen3.5:4b-verde has a context window of 24,576 tokens, which is
#   below the minimum 64,000 required by Hermes Agent.
#
# Es un tope DURO del arnes, no un aviso: agent/model_metadata.py:405,
# MINIMUM_CONTEXT_LENGTH = 64_000, comprobado en agent/agent_init.py:2656.
#
# Hay una puerta de atras en ese mismo if —si provider es "lmstudio" y
# context_length esta puesto a mano, se salta el suelo— y NO se usa: el
# proveedor lmstudio llama a /api/v1/models, que es la API nativa de LM Studio
# y ollama no tiene, ademas de intentar precargar el modelo por su cuenta.
# Fingir ser otro servidor romperia mas adelante y de forma mas confusa.
#
# Tampoco se miente en el config.yaml poniendo 64000 con el modelo a 24576: el
# arnes creeria tener 64k, no comprimiria hasta acercarse, y ollama iria tirando
# los tokens mas viejos —que son el prompt de sistema y los esquemas de las
# herramientas—. El sintoma seria que el agente deja de llamar herramientas a
# mitad de sesion, sin un solo error en el log.
#
# Asi que el numero a buscar ya no es el contexto: es CUANTAS CAPAS caben en la
# tarjeta con num_ctx 65536 fijo. Medido el 16 ago:
#
#   num_gpu 32 -> cudaMalloc failed        num_gpu 16 -> 2795 MiB, CARGA
#   num_gpu 24 -> cudaMalloc failed        num_gpu  8 -> 1772 MiB, CARGA
#                                          num_gpu  0 -> 0 MiB, todo en RAM
#
# ── Se planta en vez de seguir ─────────────────────────────────────────────
#
# Cada paso comprueba lo suyo y aborta si no cuadra. Es a proposito: un fallo
# aqui es de una linea, y el mismo fallo tres pasos mas adelante sale como
# "respuestas raras" y cuesta una tarde.
set -uo pipefail

AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
RAIZ=$(cd -P "$AQUI/.." && pwd)
CONFIG=$HOME/.hermes/config.yaml
OLLAMA=${OLLAMA_URL:-http://127.0.0.1:11434}
MODELO=qwen3.5:4b-verde
NARANJA=qwen3.5:4b-jarvis

# El suelo duro de Hermes es 64.000 (agent/model_metadata.py:405). Se pide la
# potencia de dos justo por encima, que es lo que entiende ollama sin discutir.
CTX_MINIMO=65536

SOLO_MEDIR=no
IGUAL=no
for a in "$@"; do
    case "$a" in
        --solo-medir) SOLO_MEDIR=si ;;
        --igual)      IGUAL=si ;;
        -h|--help)    sed -n '2,10p' "$0"; exit 0 ;;
    esac
done

rojo()  { printf '\033[31m%s\033[0m\n' "$*"; }
verde() { printf '\033[32m%s\033[0m\n' "$*"; }
gris()  { printf '\033[90m%s\033[0m\n' "$*"; }
titulo(){ printf '\n\033[1m── %s ─────────────────────────────────────\033[0m\n' "$*"; }
abortar(){ rojo "  ✗ $*"; exit 1; }

vram() { nvidia-smi --query-gpu=memory.used --format=csv,noheader 2>/dev/null | tr -d ' MiB'; }

# ═══ 1. La tarjeta ═════════════════════════════════════════════════════════
titulo "1. Liberar la tarjeta"

if pgrep -f 'entrena/dataset.py' >/dev/null 2>&1; then
    if [ "$IGUAL" = no ]; then
        rojo "  ✗ dataset.py sigue corriendo."
        gris "    Es un trabajo de 7-10 h que usa $NARANJA en la grafica."
        gris "    Competir con el por la VRAM estropea las dos cosas: el verde"
        gris "    no cargara y el dataset ira mas lento."
        gris ""
        gris "    Por donde va:"
        [ -f "$RAIZ/entrena/dataset.log" ] && sed 's/^/      /' "$RAIZ/entrena/dataset.log" | tail -3
        gris ""
        gris "    Espera a que acabe, o --igual para seguir de todas formas."
        exit 1
    fi
    rojo "  ! dataset.py corriendo y --igual puesto: siguiendo bajo tu responsabilidad"
fi

# Restos del naranja. Se para el proceso; NO se toca ni un fichero suyo.
for p in 'nucleo/jarvis.py' 'whisper-server'; do
    if pgrep -f "$p" >/dev/null 2>&1; then
        gris "  cerrando $p"
        pkill -f "$p" 2>/dev/null
    fi
done
ollama stop "$NARANJA" >/dev/null 2>&1
sleep 3

LIBRE=$(vram)
if [ -z "$LIBRE" ]; then
    abortar "no hay nvidia-smi: esto necesita la grafica"
elif [ "$LIBRE" -gt 400 ]; then
    rojo "  ! la tarjeta sigue con $LIBRE MiB usados"
    nvidia-smi --query-compute-apps=pid,used_memory,name --format=csv,noheader | sed 's/^/    /'
    gris "    Algo la esta usando. Mira la lista de arriba antes de seguir."
    [ "$IGUAL" = no ] && exit 1
else
    verde "  ✓ tarjeta libre: $LIBRE MiB"
fi

# ═══ 2. Cuantas capas caben con el contexto que Hermes exige ═══════════════
titulo "2. Cuantas capas caben a 65536 (medido, no supuesto)"

gris "  El contexto NO se negocia: Hermes rechaza cualquier modelo por debajo de"
gris "  64.000 tokens. Lo que se busca es el mayor num_gpu que cargue a 65536:"
gris "  cada capa que baja a la CPU cuesta velocidad, asi que se cogen todas las"
gris "  que quepan."

TMP=$(mktemp --suffix=.Modelfile); trap 'rm -f "$TMP"' EXIT
GANADOR=""; GANADOR_VRAM=""

probar_ctx() {   # $1 = num_ctx   $2 = num_gpu
    printf 'FROM qwen3.5:4b\nPARAMETER num_gpu %s\nPARAMETER num_ctx %s\nPARAMETER num_predict 4096\nPARAMETER temperature 0.2\n' "$2" "$1" > "$TMP"
    ollama create "$MODELO" -f "$TMP" >/dev/null 2>&1 || return 1
    local r
    r=$(curl -s --max-time 240 "$OLLAMA/v1/chat/completions" -H 'Content-Type: application/json' \
        -d "{\"model\":\"$MODELO\",\"messages\":[{\"role\":\"user\",\"content\":\"ok\"}],\"max_tokens\":5}" 2>/dev/null)
    echo "$r" | grep -q '"choices"' || return 1
    return 0
}

for GPU in 32 24 16 8 0; do
    printf '  num_ctx %-6s num_gpu %-3s ... ' "$CTX_MINIMO" "$GPU"
    if probar_ctx "$CTX_MINIMO" "$GPU"; then
        V=$(curl -fsS --max-time 5 "$OLLAMA/api/ps" 2>/dev/null | python3 -c "
import json,sys
m=json.load(sys.stdin)['models']
print(m[0]['size_vram']//1048576 if m else 0)" 2>/dev/null)
        verde "CABE  (en tarjeta $V MiB, tarjeta $(vram) MiB)"
        GANADOR=$CTX_MINIMO; GANADOR_GPU=$GPU; GANADOR_VRAM=$V
        break
    fi
    rojo "no cabe"
    ollama stop "$MODELO" >/dev/null 2>&1; sleep 2
done

if [ -z "$GANADOR" ]; then
    rojo "  ✗ no carga a 65536 ni con TODAS las capas en la CPU."
    gris "    Eso ya no es la grafica: mira la RAM libre con free -h."
    exit 1
fi

verde "  ✓ num_ctx $GANADOR con $GANADOR_GPU de 32 capas en GPU, $GANADOR_VRAM MiB"

if [ "$GANADOR_GPU" -lt 32 ]; then
    gris ""
    gris "  $(( 32 - GANADOR_GPU )) capas van por CPU y eso se paga en el prefill: el prompt fijo"
    gris "  son ~20.500 tokens que hay que procesar en CADA vuelta del agente."
    gris ""
    gris "  Palanca para recuperar capas, y NO la aplico yo porque es del SERVICIO"
    gris "  de ollama, no del modelo: afectaria tambien al naranja la proxima vez"
    gris "  que arranque. Esa decision es tuya."
    gris ""
    gris "    sudo systemctl edit ollama"
    gris "      [Service]"
    gris "      Environment=\"OLLAMA_FLASH_ATTENTION=1\""
    gris "      Environment=\"OLLAMA_KV_CACHE_TYPE=q8_0\""
    gris "    sudo systemctl restart ollama"
    gris ""
    gris "  Parte el KV cache por la mitad. Si lo haces, anotalo en NOTAS.md y en"
    gris "  config/qwen3.5-4b-jarvis.Modelfile, o en tres semanas nadie sabra de"
    gris "  donde salio el cambio."
fi

HUECO=$(( GANADOR - 20500 ))
gris "  quedan ~$HUECO tokens de conversacion tras el prompt fijo"

# ═══ 3. Configurar Hermes ══════════════════════════════════════════════════
titulo "3. Decirle a Hermes la verdad"

if [ "$SOLO_MEDIR" = si ]; then
    gris "  --solo-medir: no toco $CONFIG"
    gris "  Para aplicarlo a mano: model.default=$MODELO y context_length=$GANADOR"
else
    cp "$CONFIG" "$CONFIG.antes-de-arrancar" 2>/dev/null
    python3 - "$CONFIG" "$MODELO" "$GANADOR" <<'PY'
import re, sys
ruta, modelo, ctx = sys.argv[1], sys.argv[2], int(sys.argv[3])
lineas = open(ruta).read().splitlines(True)

# Se edita por LINEAS, no reescribiendo el YAML: el fichero son 1890 lineas de
# documentacion propia y volcarlo con yaml.dump la perderia entera.
hecho_modelo = hecho_ctx = False
for i, l in enumerate(lineas):
    if not hecho_modelo and re.match(r'^  default: ', l):
        lineas[i] = f'  default: "{modelo}"\n'; hecho_modelo = True
    # context_length viene comentada de fabrica dentro del bloque model:.
    # La propia plantilla dice que es justo para "a local server with a custom
    # num_ctx", que es exactamente este caso.
    elif not hecho_ctx and re.match(r'^  # context_length: ', l):
        lineas[i] = (f'  # JARVIS verde: el num_ctx REAL del Modelfile. Sin esto Hermes\n'
                     f'  # se cree lo que dice /api/show, que son 262144 (el maximo del\n'
                     f'  # modelo) y no lo configurado.\n'
                     f'  context_length: {ctx}\n')
        hecho_ctx = True

if not hecho_modelo: sys.exit("no encontre la linea 'default:' en el bloque model")
if not hecho_ctx:    sys.exit("no encontre la linea '# context_length:' comentada")
open(ruta, 'w').writelines(lineas)
PY
    [ $? -ne 0 ] && abortar "no pude escribir la configuracion (copia en $CONFIG.antes-de-arrancar)"

    # Verificar PARSEANDO, nunca leyendo: este fichero declara provider y
    # base_url DOS veces dentro de model: y en YAML gana la ultima. Leyendolo
    # parecia configurado y el trafico salia por OpenRouter.
    python3 - "$CONFIG" "$MODELO" "$GANADOR" <<'PY'
import sys, yaml
ruta, modelo, ctx = sys.argv[1], sys.argv[2], int(sys.argv[3])
m = yaml.safe_load(open(ruta))['model']
fallos = []
if m.get('default') != modelo:            fallos.append(f"default={m.get('default')}")
if m.get('context_length') != ctx:        fallos.append(f"context_length={m.get('context_length')}")
if '127.0.0.1' not in str(m.get('base_url','')): fallos.append(f"base_url={m.get('base_url')}")
if fallos: sys.exit("la configuracion NO quedo como se pedia: " + ", ".join(fallos))
print(f"  modelo={m['default']}  context_length={m['context_length']}  base_url={m['base_url']}")
PY
    [ $? -ne 0 ] && abortar "verificacion fallida; restaura con: cp $CONFIG.antes-de-arrancar $CONFIG"
    verde "  ✓ configurado y verificado parseando"
fi

# ═══ 4. La prueba decisiva ═════════════════════════════════════════════════
titulo "4. ¿Sabe el modelo llamar herramientas?"

gris "  Esto va ANTES de abrir el TUI a proposito. Separa dos preguntas que"
gris "  juntas se contestan mal: ¿sabe el modelo emitir una llamada? y"
gris "  ¿funciona Hermes? Si falla aqui, no hay nada que depurar en Hermes."

R=$(curl -s --max-time 300 "$OLLAMA/v1/chat/completions" -H 'Content-Type: application/json' -d "{
  \"model\": \"$MODELO\",
  \"messages\": [{\"role\":\"user\",\"content\":\"¿Cuánto espacio libre queda en el disco?\"}],
  \"tools\": [{\"type\":\"function\",\"function\":{
      \"name\":\"ejecuta_comando\",
      \"description\":\"Ejecuta un comando de shell y devuelve su salida\",
      \"parameters\":{\"type\":\"object\",\"properties\":{\"comando\":{\"type\":\"string\"}},\"required\":[\"comando\"]}}}]
}" 2>/dev/null)

echo "$R" | python3 -c "
import json, sys
try: d = json.load(sys.stdin)
except Exception: print('  RESPUESTA ILEGIBLE'); sys.exit(2)
if 'error' in d: print('  ERROR:', str(d['error'])[:200]); sys.exit(2)
msg = d['choices'][0]['message']
tc = msg.get('tool_calls') or []
if not tc:
    print('  SIN tool_calls. Contesto en prosa:')
    print('   ', repr((msg.get('content') or '')[:200]))
    sys.exit(1)
f = tc[0]['function']
print('  herramienta :', f['name'])
print('  argumentos  :', f['arguments'])
try:
    json.loads(f['arguments']); print('  JSON valido')
except Exception: print('  JSON ROTO'); sys.exit(1)
sys.exit(0)
"
VEREDICTO=$?

titulo "Resumen"
printf '  contexto     %s tokens (%s capas en GPU, %s MiB)\n' "$GANADOR" "$GANADOR_GPU" "$GANADOR_VRAM"
printf '  prompt fijo  ~20.500 tokens -> quedan ~%s de conversacion\n' "$HUECO"

if [ "$VEREDICTO" -eq 0 ]; then
    verde "  ✓ el modelo llama herramientas. Lo que falle a partir de aqui es de Hermes."
    gris ""
    gris "  Siguiente:"
    gris "    $AQUI/pruebas/ejecutar.sh     las comprobaciones, ninguna abre el TUI"
    gris "    $AQUI/verde-launcher.sh       el TUI en verde -> sigue ESCALERA.md"
else
    rojo "  ✗ el modelo NO emite llamadas usables en este formato."
    gris ""
    gris "  Abrir el TUI ahora solo daria un fallo mas confuso. Dos salidas:"
    gris "    · modelo mayor por CPU: hay 49 GB de RAM y 8 nucleos fisicos."
    gris "      qwen3.5:9b esta en disco. Lento pero es un banco de pruebas."
    gris "    · recortar herramientas en platform_toolsets del config.yaml:"
    gris "      cli: [hermes-cli] -> una lista corta. Menos esquema, menos lio."
    exit 1
fi
