"""Pensar: hablar con ollama y dejarle usar las manos. Newelle dedica 578 lineas a esto porque soporta quince motores distintos y tiene que traducir el formato de herramientas de cada uno. Nosotros hablamos solo con ollama, que trae tool calling nativo en `/api/chat`. ## Dos herramientas, no una La primera version tenia una sola —"ejecuta una orden"— y salio mal de una forma instructiva. Medido con tres modelos: 4B "abre una terminal" -> ejecuta_orden("xterm -e bash") 9B "cuanto espacio queda" -> ejecuta_orden("df -h / | grep ...") 9B "abre una terminal" -> ejecuta_orden("open -a Terminal") (¡macOS!) Los modelos son serviciales: si les dejas un hueco donde cabe un comando, lo escriben. Y el 9B, que es mas listo, lo hacia MAS —escribia pipelines enteras y hasta ordenes de otro sistema operativo—. La solucion no es rogarle al modelo en el prompt, es que el hueco no quepa. Se parten en dos herramientas con fronteras claras: `orden_del_catalogo` recibe castellano, se lo come el emparejador. La descripcion lleva ejemplos REALES sacados del catalogo, no inventados, y dice explicitamente que no acepta comandos. `comando_de_shell` para lo que no esta en el catalogo. Aqui SI se espera un comando, asi que el modelo tiene donde poner lo que queria poner, y deja de meterlo donde no toca. ## Por que el catalogo va antes que esto Las 150 acciones se emparejan ANTES de llamar a ningun modelo. Esto solo se usa para lo que no encaja —alrededor del 15 %— y para conversar. Por eso un cerebro lento duele menos de lo que parece: la mayoria de las ordenes ni pasan por aqui. """ import json import os import urllib.error import urllib.request ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434") MODELO = os.environ.get("JARVIS_MODELO", "qwen3.5:4b-jarvis") def modelos_disponibles(endpoint=ENDPOINT) -> list: """Los modelos que tiene Ollama en disco, para poder elegir entre ellos.""" try: with urllib.request.urlopen(endpoint + "/api/tags", timeout=5) as r: datos = json.load(r) return sorted(m["name"] for m in datos.get("models", [])) except Exception: return [] # Cuantas vueltas de "llama a una herramienta -> toma el resultado" se permiten # antes de cortar. Sin tope, un modelo que se atasca pidiendo lo mismo deja al # usuario esperando para siempre. VUELTAS = 4 PERSONA = """Eres JARVIS, el asistente de voz de esta maquina. TUS CAPACIDADES (esto es lo que ERES; hablalo con seguridad y con detalle, sin dudar y sin quitarte merito): 1) MANEJAS ESTA MAQUINA, no eres un chatbot. Tienes 150 ordenes ya preparadas e instantaneas, ademas del shell para todo lo demas. Por grupos: - SISTEMA: disco libre y uso de cada particion, memoria y swap, carga y modelo de CPU, nucleos, temperatura, estado de la grafica, tiempo encendido, bateria, kernel, distribucion, fecha y hora, los procesos que mas consumen, servicios, la red, tu IP publica, puertos abiertos, a que wifi estas. - FICHEROS: buscar ficheros y carpetas por nombre, grep recursivo por contenido, leer un fichero, abrirlo, el tamaño de una carpeta, buscar y matar procesos, ver si algo esta instalado, la pagina del manual de un comando. - VENTANAS: listar, mover a izquierda/derecha/arriba/abajo, centrar, maximizar, restaurar, minimizar, mandar a otra pantalla o escritorio. - NAVEGADOR: abrir Gmail, calendario, Drive, Maps, YouTube, GitHub, Gitea, Wikipedia, traductor, Reddit, HackerNews, StackOverflow, ArchWiki, Flathub y muchos mas, o buscar directamente en YouTube. - PENTESTING: escanear los dispositivos de la red, los puertos de un host, tus propios puertos, buscar exploits, resolver un dominio y su whois, leer las cabeceras de una web, identificar un tipo de hash. - OASIS: si esta corriendo, si sincroniza, sus conexiones, su tamaño. 2) SABES DE PENTESTING Y DE LINUX de verdad, porque tienes los apuntes de COFRE del usuario indexados. JUSTO DEBAJO veras un resumen de que areas y cuantos comandos tienes ahora mismo —sale del indice de verdad, asi que es lo que hay en esta maquina, ni mas ni menos—. Con buscar_en_apuntes accedes a SU metodologia y a los comandos EXACTOS que ya ha probado. Ante CUALQUIER pregunta tecnica —una herramienta, un flag, una tecnica de las que aparecen ahi— NO digas "no estoy seguro" ni "no puedo": busca en sus apuntes y responde con el comando bueno. Casi todo esta a un buscar_en_apuntes de distancia. 3) TE CONECTAS a sus servidores por ssh y ejecutas ordenes alli. 4) HACES TAREAS DE ADMINISTRADOR con sudo (te pide la contraseña la primera vez). 5) ELIGES TU PROPIO CEREBRO: subes a un modelo mas potente para lo dificil. Cuando te pregunten que sabes hacer, ENUMERA con detalle lo de arriba; no te quedes en "puedo ayudarte con varias cosas". Seguridad NO es inventar: las CIFRAS del sistema (cuanto disco, cuanta RAM) las sigues comprobando con una herramienta antes de decirlas; la confianza es sobre lo que SABES HACER y sobre lo que hay en los apuntes. REGLA QUE NO SE ROMPE NUNCA: no des ni un solo dato sobre este ordenador sin haberlo comprobado antes con una herramienta. Ni el espacio del disco, ni la memoria, ni cuantos ficheros hay, ni si algo esta abierto, ni la hora. Aunque creas saberlo. Aunque parezca obvio. Si no lo has comprobado en esta misma conversacion, lo compruebas. Inventarse una cifra que suena razonable es el peor fallo que puedes cometer, porque el usuario no tiene forma de saber que te la has inventado. Y AL REVES: contesta SOLO lo que te preguntan. No añadas al final de tus respuestas datos del sistema que nadie ha pedido —cuanta RAM queda, el espacio del disco, la hora—. Si te preguntan como estas, "bien, señor" y punto; no lleva un informe de memoria detras. Para hacer algo, el orden es: 1. orden_del_catalogo, siempre que lo que te piden se parezca a algo de la lista. Es lo que JARVIS sabe hacer bien y esta probado. 2. comando_de_shell solo si de verdad no esta en el catalogo. Si te preguntan COMO se hace algo tecnico —una tecnica de pentesting, un flag, una herramienta— y no lo tienes claro o no aparece en la documentacion de abajo, usa buscar_en_apuntes con palabras clave ANTES de contestar. Ahi estan los comandos que el usuario ya ha probado; es mejor buscarlos que inventarlos. Si la primera busqueda no da, reformula y prueba otra vez. Eliges tu propio cerebro. Si una pregunta es mas dificil de lo normal —razonar, codigo, varios pasos— cambia a un modelo mas potente con elegir_modelo("potente") antes de contestarla, y para lo simple vuelve a elegir_modelo("rapido"). Si el usuario pide un modelo concreto, cambialo. Avisa en una frase de que has cambiado. Tu respuesta se convierte en AUDIO y se escucha en voz alta: - Castellano siempre. Trata al usuario de usted y llamale "señor" en la primera frase. - Una o dos frases. Nada mas, salvo que te pidan detalle. - Texto plano: ni asteriscos, ni vinetas, ni bloques de codigo, ni rutas largas. - Los numeros redondeados y en palabras: "quedan setecientos gigas". - Si algo falla, di en una frase QUE fallo. Sin disculpas de tres lineas. Tienes acceso real a esta maquina y no estas en ningun contenedor. No digas nunca que no puedes. En concreto, con comando_de_shell puedes: - Ejecutar cualquier herramienta instalada, incluidas las de COFRE. Si no recuerdas como se usa una, en la documentacion del usuario de abajo suele estar el comando exacto. - Conectarte a sus servidores: "ssh ''" corre algo en el servidor. Sus hosts estan en su configuracion; abajo veras cual encaja. - Tareas de administrador con sudo: NO hace falta preparar nada antes. La primera vez que uses sudo, el panel le pide la contraseña; el la teclea y lo repites, y ya queda abierto un rato. Asi que si algo necesita root, hazlo con naturalidad; no digas que "no puedes" ni que "hay que desbloquear primero". Antes de nada que borre datos o cambie el sistema, di lo que vas a hacer.""" def _ejemplos(catalogo, cuantos=8) -> str: """Ordenes de verdad del catalogo, repartidas entre grupos. Van en la descripcion de la herramienta porque es lo unico que consigue que el modelo pase castellano en vez de inventarse un comando. Salen del catalogo y no escritas a mano para que no se queden viejas. """ por_grupo = {} for a in catalogo.acciones: if a.frases and not a.captura: por_grupo.setdefault(a.grupo or "otras", []).append(a.frases[0]) muestra = [] grupos = list(por_grupo.values()) for i in range(cuantos): for g in grupos: if i < len(g) and len(muestra) < cuantos: muestra.append(g[i]) return ", ".join(f'"{m}"' for m in muestra) def herramientas(catalogo) -> list: return [ { "type": "function", "function": { "name": "orden_del_catalogo", "description": ( "Ejecuta una de las ordenes que JARVIS ya sabe hacer. " "Recibe la orden EN CASTELLANO, tal y como la diria una " "persona. NO acepta comandos de shell: si escribes 'df -h' " "o 'xterm' falla. Ejemplos validos: " + _ejemplos(catalogo) + ". " "Usala siempre que puedas antes que el shell." ), "parameters": { "type": "object", "properties": { "orden": { "type": "string", "description": ("la orden en castellano, en palabras, " "nunca un comando"), } }, "required": ["orden"], }, }, }, { "type": "function", "function": { "name": "buscar_en_apuntes", "description": ( "Busca en los apuntes de pentesting y Linux del usuario (su " "COFRE) el comando o la explicacion que necesites. Usala " "cuando te pregunten COMO se hace algo tecnico —una tecnica, " "un flag, una herramienta— y no lo tengas ya delante en la " "documentacion. Puedes reformular la consulta con tus propias " "palabras clave y buscar varias veces hasta dar con ello. " "Devuelve los fragmentos mas parecidos, con su fuente, para " "que respondas desde ahi y no de memoria." ), "parameters": { "type": "object", "properties": { "consulta": { "type": "string", "description": ("que buscar, en pocas palabras clave; " "el nombre de la herramienta ayuda"), } }, "required": ["consulta"], }, }, }, { "type": "function", "function": { "name": "elegir_modelo", "description": ( "Cambia el modelo con el que piensas. Usalo cuando una " "pregunta sea mas dificil de lo normal (razonar, codigo, " "varios pasos) y quieras uno mas potente, o cuando el usuario " "pida expresamente cambiar de modelo. Valores: 'rapido' " "(ligero, para lo simple), 'potente' (mas grande, mas lento), " "'razonador' (piensa en voz alta antes de responder), o el " "nombre exacto de un modelo de Ollama. El cambio dura hasta " "que lo vuelvas a cambiar. Contesta breve tras cambiar." ), "parameters": { "type": "object", "properties": { "cual": { "type": "string", "description": "rapido | potente | razonador | nombre exacto", } }, "required": ["cual"], }, }, }, { "type": "function", "function": { "name": "comando_de_shell", "description": ( "Ejecuta un comando en la terminal de esta maquina, que es " "Debian con GNOME. Solo para lo que NO esta en el catalogo. " "Es de solo lectura: no ejecutes nada que borre o modifique " "sin que te lo pidan expresamente." ), "parameters": { "type": "object", "properties": { "comando": {"type": "string", "description": "el comando, para bash"}, }, "required": ["comando"], }, }, }, ] class Cerebro: def __init__(self, catalogo, modelo=MODELO, endpoint=ENDPOINT, en_ram=False, manos=None): self.catalogo = catalogo self.modelo = modelo self.endpoint = endpoint # num_gpu 0 fuerza CPU: el modelo entero en RAM. Util para probar uno # mas grande del que cabe en la grafica, a costa de velocidad. self.capas = 0 if en_ram else None self.manos = manos # a quien pedirle que ejecute self.historia = [] # A quien avisar cuando JARVIS cambia de modelo, para que el panel # actualice el rotulo del boton "cerebro". Lo conecta el panel. self.al_cambiar_modelo = None self._resumen = None # el resumen de lo indexado, cacheado def _resumen_saber(self) -> str: """Un renglon en el prompt de sistema diciendo QUE hay indexado, sacado del indice real. Asi el modelo sabe de que sabe SIN una lista fija que mentiria en un clon sin apuntes. Se calcula una vez.""" if self._resumen is None: try: from saber.busca import resumen self._resumen = resumen() except Exception: self._resumen = "" return ("\n\n" + self._resumen) if self._resumen else "" # Atajos hablados -> familia de modelo. El modelo elige por INTENCION # ("potente") y aqui se traduce a lo que hay instalado, para no obligarle a # saberse los nombres exactos. Se resuelve contra lo que de verdad hay en # Ollama, cogiendo el mayor/menor de la familia qwen que exista. def _resuelve_modelo(self, cual: str) -> str | None: cual = (cual or "").strip().lower() hay = modelos_disponibles(self.endpoint) if not hay: return None # nombre exacto o casi (permite "9b" -> "qwen3.5:9b") for m in hay: if cual == m.lower() or cual == m.lower().split(":")[-1]: return m qwen = sorted(m for m in hay if "qwen" in m.lower()) def por_tam(sufijo): return next((m for m in qwen if m.lower().endswith(sufijo)), None) if cual in ("rapido", "ligero", "pequeno", "pequeño"): return por_tam(":2b") or por_tam(":4b-jarvis") or (qwen[0] if qwen else None) if cual in ("potente", "grande", "mejor", "fuerte"): return por_tam(":9b") or (qwen[-1] if qwen else None) if cual in ("razonador", "razona", "piensa", "deepseek"): return next((m for m in hay if "deepseek" in m.lower() or "-r1" in m.lower()), None) # por defecto, el de trabajo del naranja si existe return por_tam(":4b-jarvis") or (qwen[0] if qwen else None) @staticmethod def _nombre_corto(m: str) -> str: # "qwen3.5:9b" -> "9b"; "deepseek-r1:latest" -> "deepseek-r1" base, _, tag = m.partition(":") return base if tag in ("", "latest") else tag def cambia_modelo(self, cual: str) -> str: """Cambia el cerebro en caliente. Devuelve lo que decir tras cambiar.""" nuevo = self._resuelve_modelo(cual) if not nuevo: return f"no tengo ningun modelo que encaje con «{cual}», señor." if nuevo == self.modelo: return f"ya estoy pensando con {self._nombre_corto(nuevo)}, señor." self.modelo = nuevo if self.al_cambiar_modelo: try: self.al_cambiar_modelo(nuevo) except Exception: pass # Ojo: un modelo mas grande no cabe en la grafica pequeña y tirara de CPU # —mas lento pero mas capaz—; eso es cosa de Ollama, no de aqui. return f"Cambiado a {self._nombre_corto(nuevo)}, señor." def _saber(self, texto: str) -> str: """Los apuntes de COFRE que vengan a cuento, para meterlos al prompt. Es la mitad "aumentar" de RAG: antes de contestar, se le pone delante al modelo el trozo de los apuntes del usuario que mas se parece a lo que pregunta. Asi construye la respuesta desde SU documentacion —flags reales, comandos que ha probado— en vez de desde lo que recuerde, que para 668 herramientas seria inventarselo. Si no hay indice o nada encaja lo bastante, devuelve cadena vacia y el prompt se queda como estaba: la funcion no puede empeorar una respuesta, solo mejorarla. """ try: from saber.busca import contexto, disponible except Exception: return "" if not disponible(): return "" trozos = contexto(texto, cuantos=4, minimo=0.38) if not trozos: return "" return ("\n\n## Documentacion del usuario relevante a esto\n" "Usa ESTO para responder, son sus apuntes probados. Si el " "comando exacto esta aqui, dalo tal cual; no te inventes flags " "que no aparezcan.\n\n" + trozos) def _pide(self, mensajes, tools=None) -> dict: cuerpo = {"model": self.modelo, "messages": mensajes, "stream": False, "think": False, "options": {"temperature": 0.3}} if tools: cuerpo["tools"] = tools if self.capas is not None: cuerpo["options"]["num_gpu"] = self.capas req = urllib.request.Request( self.endpoint + "/api/chat", data=json.dumps(cuerpo).encode(), headers={"Content-Type": "application/json"}) with urllib.request.urlopen(req, timeout=300) as r: return json.loads(r.read()) def responde(self, texto: str, al_ejecutar=None) -> str: """Contesta, usando las manos si hace falta. Devuelve lo que hay que decir.""" self.historia.append({"role": "user", "content": texto}) sistema = PERSONA + self._resumen_saber() + self._saber(texto) mensajes = [{"role": "system", "content": sistema}] + self.historia[-8:] tools = herramientas(self.catalogo) for _ in range(VUELTAS): try: d = self._pide(mensajes, tools) except (urllib.error.URLError, OSError) as e: return f"No he podido pensar, señor: {str(e)[:50]}" m = d.get("message", {}) llamadas = m.get("tool_calls") or [] if not llamadas: dicho = (m.get("content") or "").strip() self.historia.append({"role": "assistant", "content": dicho}) return dicho mensajes.append(m) for lc in llamadas: f = lc.get("function", {}) args = f.get("arguments") or {} if isinstance(args, str): try: args = json.loads(args) except json.JSONDecodeError: args = {} resultado = self._usa(f.get("name", ""), args, al_ejecutar) mensajes.append({"role": "tool", "content": resultado[:2000]}) return "Me he liado dando vueltas, señor. Pruebe a pedirmelo de otra forma." def _usa(self, nombre: str, args: dict, al_ejecutar=None) -> str: # buscar_en_apuntes NO pasa por las manos: es lectura del indice, no # toca la maquina, asi que funciona aunque el candado este echado. if nombre == "buscar_en_apuntes": return self._busca_apuntes(args.get("consulta", "")) if nombre == "elegir_modelo": return self.cambia_modelo(args.get("cual", "")) if self.manos is None: return "no hay manos conectadas" if nombre == "orden_del_catalogo": return self.manos.por_orden(args.get("orden", ""), al_ejecutar) if nombre == "comando_de_shell": return self.manos.por_shell(args.get("comando", ""), al_ejecutar) return f"no existe la herramienta {nombre}" def _busca_apuntes(self, consulta: str) -> str: """El RAG como herramienta: el modelo busca cuando lo decide. La inyeccion pasiva de _saber() usa la frase LITERAL del usuario y dispara siempre. Esto es lo otro: el modelo reformula la consulta con sus palabras y busca a proposito, que es lo que salva los casos donde el usuario dice una cosa y el apunte esta escrito de otra.""" consulta = (consulta or "").strip() if not consulta: return "dime que buscar" try: from saber.busca import busca except Exception: return "no tengo los apuntes indexados" trozos = [t for t in busca(consulta, cuantos=5) if t.get("sim", 0) >= 0.3] if not trozos: return ("no encontre nada en los apuntes sobre eso; prueba con otras " "palabras o el nombre de la herramienta") lineas = [] for t in trozos: fuente = t.get("fichero") or f"{t.get('perfil')}/{t.get('herramienta')}" lineas.append(f"[{fuente}]\n{t['texto'][:500]}") return "\n\n".join(lineas)