Compare commits

...
Sign in to create a new pull request.

2 commits

Author SHA1 Message Date
sito
c58105a83c verde: el titulo es JARVIS-VERDE 2026-08-18 11:32:53 +02:00
sito
1e3e5ca20d carril verde (Hermes Agent): documentacion, panel, voz local y RAG
Rama JARVIS-GREEN, independiente de master (el nucleo naranja queda intacto).

- README propio del carril verde con diagrama de arquitectura y capturas del panel.
- verde/: arranque, panel web, puente de voz (escucha local con faster-whisper +
  habla con la voz del naranja), pruebas (07 voz, 08 escucha), config y notas.
- Integracion RAG: nucleo/saber/busca_cli.py + skill buscar-en-apuntes, para que
  el agente consulte el mismo indice que el nucleo.
- Todo local (127.0.0.1); sin datos personales (rutas y modelo de GPU scrubeados).
2026-08-18 11:26:42 +02:00
32 changed files with 3697 additions and 161 deletions

213
README.md
View file

@ -1,184 +1,75 @@
# JARVIS # JARVIS-VERDE 🟢
Asistente de voz **100 % local** para Linux. Ni el micrófono, ni las Banco de pruebas de JARVIS montado sobre **[Hermes Agent](https://github.com/NousResearch/hermes-agent)** (Nous Research, MIT): un arnés agéntico completo, **100 % local por configuración**. Esta rama documenta el carril verde; el `master` es el núcleo naranja.
conversaciones, ni el modelo salen de la máquina. Sin cuentas, sin claves, sin
nube. Núcleo propio, en castellano, que corre en **cualquier Linux** —de un
portátil modesto a una torre— y aprovecha la GPU si la hay, o la CPU si no.
![Linux](https://img.shields.io/badge/Linux-cualquier%20distro-FCC624?logo=linux&logoColor=black) ![Arquitectura del carril verde](verde/docs/arquitectura.svg)
![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB?logo=python&logoColor=white)
![Ollama](https://img.shields.io/badge/Ollama-qwen3.5%3A4b-000000?logo=ollama&logoColor=white)
![GPU](https://img.shields.io/badge/GPU-opcional%20(NVIDIA%2FAMD%2FCPU)-76B900)
![whisper.cpp](https://img.shields.io/badge/whisper.cpp-STT-555555)
![Piper](https://img.shields.io/badge/Piper-TTS-555555)
![License](https://img.shields.io/badge/licencia-GPL--3.0-blue)
![Arquitectura de JARVIS](docs/stack.svg) ## Para qué sirve
![El panel de JARVIS](docs/panel.png) JARVIS tiene tres carriles: **cian** (Newelle, congelado), **naranja** (el núcleo propio) y **verde** (este). El verde responde a una pregunta: *¿un arnés agéntico de fábrica aporta algo que el núcleo naranja no tenga?* En concreto, las tres cosas que al naranja le faltan y que Hermes trae de serie:
*El panel: telemetría del sistema, el reactor, lo que oye y hace, el catálogo de - **Skills que el agente se crea y mejora solo.**
acciones, y las métricas de rendimiento del propio asistente. Hay un - **Canales de mensajería** (Telegram, Discord, Signal, WhatsApp, Slack).
[vídeo de ejemplo](docs/demo.webm) (47 s) en `docs/`.* - **Memoria** de largo plazo entre sesiones.
No es el asistente de diario —para eso está el naranja, que es más rápido—; es el laboratorio para decidir qué ideas de Hermes merece la pena llevar al núcleo.
## El panel
Hermes no es un chatbot: es una plataforma. El panel web (`verde/panel-verde.sh`, en `127.0.0.1:9119`) da chat, sesiones, ficheros, modelos, logs, cron, skills, MCP, canales y webhooks.
![Panel — chat](verde/docs/panel-chat.png)
**82 skills** en 14 categorías, entre ellas la integración con el RAG del naranja:
![Panel — skills](verde/docs/panel-skills.png)
## El stack ## El stack
| Capa | Pieza | Papel | | Capa | Pieza | Licencia | ¿Sale algo de la máquina? |
|---|---|---| |---|---|---|---|
| Oír | whisper.cpp | STT en castellano (GPU si hay, si no CPU) | | Arnés | Hermes Agent | MIT | por configuración, no |
| Pensar | Ollama · `qwen3.5:4b` | el cerebro, con tool-calling | | Cerebro | Ollama · `qwen3.5:4b-verde` (64k ctx) | Apache-2.0 | **no**`127.0.0.1:11434` |
| Saber | model2vec + búsqueda híbrida | RAG sobre los apuntes del usuario | | Oír | faster-whisper `small` (local, CPU) | MIT | **no** |
| Hablar | Piper (o voz clonada con XTTS) | TTS local | | Hablar | Piper · `davefx` (misma voz que el naranja) | MIT | **no** |
| Cara | panel web · WebSocket · Canvas | el HUD, con el reactor y las métricas | | Saber | RAG del naranja vía `busca_cli` | — | **no** |
| Manos | catálogo de acciones + shell | lo que ejecuta en la máquina |
Todo corre en `127.0.0.1`. No hay ninguna llamada a la nube en el uso normal; lo ## La integración con el RAG
único que puede salir es una búsqueda web si el modelo decide usarla, y se avisa.
## Portabilidad El agente consulta el **mismo índice que el núcleo naranja** (~19.500 entradas: metodología de pentesting, man pages, catálogo de herramientas y software, apps instaladas). Se hace con la skill local `buscar-en-apuntes` (en `verde/skills/`), que el agente activa cuando le preguntas por herramientas, comandos o "qué dicen mis apuntes sobre X", y responde citando la fuente en vez de inventar. Motor: `nucleo/saber/busca_cli.py`.
Diseñado para correr en **cualquier Linux**, no en un equipo concreto: ## La voz, y en local
| | | El TUI de Hermes no trae micrófono y su STT nativo es para notas de voz de mensajería (que saldrían de la máquina). El carril lo puentea en local:
|---|---|
| Distro | cualquiera. `install.sh` detecta apt / dnf / pacman / zypper |
| GPU | **opcional**. Ollama reparte el modelo entre GPU y CPU solo: va con NVIDIA (CUDA), AMD (ROCm) o **solo CPU**. Ningún parámetro está atado a una tarjeta |
| Escritorio | GNOME, KDE, lo que sea. Las acciones de **ventanas** necesitan X11 + `xdotool`; en Wayland esas órdenes se desactivan solas y el resto funciona igual |
| Audio | PipeWire, PulseAudio o ALSA. El micro prueba `pw-record`, `parec` y `arecord`, la primera que haya; la voz prueba `paplay`, `pw-play` y `aplay` |
| whisper | el binario se toma del build local o del `PATH`. Los modelos se buscan solos, o los fijas con `JARVIS_WHISPER_MODELOS=/ruta` |
**Requisito real:** que quepa el modelo. `qwen3.5:4b` va cómodo desde ~4 GB de - **Escucha**`verde/escucha-verde.sh` (pulsar-para-hablar): graba → `faster-whisper` en CPU → se lo pasa al agente. Prueba: `verde/pruebas/08_escucha.sh`.
VRAM; con menos, usa un modelo más pequeño (`qwen3.5:2b`) o tira de CPU (hay RAM - **Habla**`verde/voz-verde.sh`: el mismo Piper y la misma voz que el naranja. Prueba: `verde/pruebas/07_voz.sh`.
de sobra). whisper.cpp se compila para tu plataforma —CUDA, ROCm, Metal o CPU—;
la guía está en [`config/README_whisper_gpu.md`](config/README_whisper_gpu.md).
### Ajuste en tarjetas pequeñas (4 GB) ## Arranque
Solo si vas MUY justo de VRAM y el cerebro y whisper se pelean por ella: fijar
`num_gpu 24` en el `Modelfile` reserva sitio a whisper. Está explicado en los
comentarios del propio `Modelfile`. En una torre con una tarjeta normal **no
hace falta tocar nada**.
## Instalación
Necesita, en la máquina: [Ollama](https://ollama.com), whisper.cpp (compilado
para tu plataforma, ver [`config/README_whisper_gpu.md`](config/README_whisper_gpu.md))
y [Piper](https://github.com/rhasspy/piper).
```bash ```bash
git clone https://gitea.laenre.net/hacklab/JARVIS.git # 1. Instalar Hermes Agent (ver su repo) y el modelo del verde
cd JARVIS ollama create qwen3.5:4b-verde -f verde/qwen3.5-4b-verde.Modelfile
./install.sh # comprueba servicios, crea el venv y el modelo de Ollama
# 2. Medir, configurar y dictaminar (encuentra el techo de contexto en tu GPU)
./verde/arrancar.sh
# 3. El panel web, o el TUI en verde
./verde/panel-verde.sh # dashboard en 127.0.0.1:9119
./verde/verde-launcher.sh # TUI
# 4. Las pruebas (ninguna abre el TUI)
./verde/pruebas/ejecutar.sh
``` ```
`install.sh` no descarga modelos pesados ni toca el sistema sin avisar: solo ## El aviso que importa
prepara el entorno de Python y crea `qwen3.5:4b-jarvis` a partir del `Modelfile`.
Después: **Local por configuración, no por construcción.** El agente tiene terminal y web, y **decide por su cuenta**: en una prueba, pidiéndole un cartel ASCII, se fue a una API de terceros porque una librería no estaba instalada. Cerebro, voz, oído y memoria son locales y comprobados; lo que no está garantizado es **lo que el agente haga con la terminal que le has dado**. Está todo en `verde/NOTAS.md`.
```bash ## El precio
./nucleo/saber/indexa.py # (opcional) indexa tus apuntes para el RAG
./nucleo/arranca.sh # el asistente y el panel, en 127.0.0.1
```
El RAG funciona nada más clonar: el repo trae un índice público pre-construido, así Hermes exige ≥64k de contexto, lo que obliga a bajar media red del 4B a la CPU: el precio es el **tiempo de respuesta** (minutos por turno en frío). Es el dato que decide si el carril compensa frente al naranja. Medido y anotado en `verde/NOTAS.md`.
que no hace falta indexar para que sepa de metodología, comandos y herramientas.
### Comprobar el RAG ---
Si la búsqueda de conocimiento no responde bien, este diagnóstico revisa la cadena Software libre. Ver `verde/README.md`, `verde/NOTAS.md` y `verde/ESCALERA.md` para el detalle.
entera y marca en verde/rojo cada eslabón —dependencias, índice, embedder,
búsqueda y Ollama— con una línea de qué hacer si algo falla:
```bash
./probar_rag.sh # comprobaciones rápidas
./probar_rag.sh --eval # además mide la calidad de recuperación (hit@k)
./probar_rag.sh --verboso # enseña el primer resultado de cada búsqueda
```
No necesita nada arrancado y sale con código distinto de cero si hay algún fallo
duro, así que sirve también para CI.
## Qué le puedes pedir
Habla en castellano, natural. **150 acciones** en el catálogo, agrupadas:
| Grupo | Nº | Ejemplos |
|---|---|---|
| sistema | 59 | "cuánto espacio queda", "la memoria", "la carga de CPU", "la temperatura" |
| firefox | 54 | "abre el Gmail", "abre YouTube", "abre el GitHub", "abre Maps" |
| ventanas | 12 | "manda la ventana a la izquierda", "maximiza", "centra la ventana" |
| ficheros | 11 | "busca un fichero llamado…", "lee el fichero…", "cuánto ocupa la carpeta…" |
| pentest | 8 | "escanea la red", "los puertos del host…", "resuelve el dominio…", "el whois de…" |
| oasis | 6 | "está corriendo Oasis", "cuántas conexiones tiene" |
Si lo que pides no está en el catálogo, el modelo lo resuelve con un **comando de
shell** (de solo lectura por defecto). Y si pregunta *cómo* se hace algo técnico,
puede **buscar en tus apuntes** (RAG) antes de responder.
El RAG es lo que hace que el asistente dé el comando que **tú ya has probado** en
vez de inventarse los flags. Y no es un adorno: medido sobre un conjunto de
preguntas de pentesting, la recuperación sube de **hit@1 48 % → 82 %** y
**hit@5 77 % → 95 %**. El método, el pipeline y esos números están en
[docs/rag.md](docs/rag.md).
Y **JARVIS elige su propio cerebro**: si una pregunta es más dura de lo normal
puede cambiar a un modelo más potente (`elegir_modelo`), y volver al rápido para
lo simple. Dile "usa un modelo más potente" o fija uno con `JARVIS_MODELO`.
## Órdenes con root — importante
Algunas órdenes que gestionan el sistema (instalar un paquete, reiniciar un
servicio, montar un disco) necesitan `sudo`. Conviene saber cómo se maneja,
porque es donde un asistente de voz puede hacer daño:
- **No pide la contraseña al arrancar.** Molestar de entrada echa para atrás, y
la mayoría de lo que se le pide no necesita root. Arranca y funciona sin nada.
- **Se pide solo cuando hace falta, y una vez.** La primera vez que una orden
necesita `sudo`, aparece un diálogo pidiendo tu contraseña. La tecleas, la
repites, y a partir de ahí el permiso queda abierto para la sesión.
- **Caduca a los 15 minutos** de inactividad, y al cerrar el panel. No es un
"root para siempre".
- La contraseña **nunca se escribe en disco**: vive solo en la memoria del
proceso mientras dura la sesión, y se pasa a `sudo -S` por la entrada estándar.
- **La voz no puede autoaprobar root.** El permiso de sesión ahorra re-teclear
la contraseña, nunca salta el pedirla la primera vez.
Si prefieres el modo seguro (bloquear el panel y pedir la contraseña al abrir,
para que nadie que pase por delante dé órdenes), arranca con `--candado`.
## Privacidad
Es la premisa, y se verifica. Ver [PRIVACIDAD.md](PRIVACIDAD.md). Este
repositorio **no incluye** los apuntes indexados ni el diario de conversación:
son del usuario, y el `.gitignore` los bloquea.
## Estructura
```
nucleo/ el asistente (oido, cerebro, boca, manos, cara, saber)
config/ configuración: modelo, voz, whisper
voz/ clonado de voz con XTTS y caché de frases
entrena/ fine-tuning con QLoRA
install.sh el instalador
```
## Estado y limitaciones
Proyecto en desarrollo. En el radar: acabar de generalizar los scripts de `voz/`
(algunos aún asumen la disposición del autor), las acciones de ventanas sobre
**Wayland** (hoy solo X11), y el fine-tuning de `entrena/`, pendiente de un
ajuste para modelos multimodales.
## Contribuir
Se agradecen los commits: es software libre y hay mucho por hacer. Las ideas
acotadas están en [ROADMAP.md](ROADMAP.md) (más gestores de ventanas, más
acciones, probar otros modelos, el RAG...) y cómo empezar en
[CONTRIBUTING.md](CONTRIBUTING.md). No hay que compilar nada: es Python, bash y
una página web.
## Licencia
[GPL-3.0](LICENSE). Software libre y copyleft: si lo modificas y lo distribuyes,
el resultado sigue siendo libre.

61
nucleo/saber/busca_cli.py Executable file
View file

@ -0,0 +1,61 @@
#!/usr/bin/env python3
"""Busca en el indice del RAG desde la linea de comandos.
busca_cli.py "como saco una shell reversa"
busca_cli.py --n 3 "gestor de contraseñas autoalojado"
busca_cli.py --json "kerberoasting"
Es el mismo motor que usa el cerebro del naranja (saber.busca), expuesto para que
lo llame cualquiera: un script, o un agente por su herramienta de terminal. Asi el
carril verde (Hermes) puede consultar tus apuntes sin duplicar el RAG.
"""
import argparse
import json
import os
import sys
AQUI = os.path.dirname(os.path.abspath(__file__))
NUCLEO = os.path.dirname(AQUI)
if NUCLEO not in sys.path:
sys.path.insert(0, NUCLEO)
def main():
p = argparse.ArgumentParser(description="Busca en los apuntes indexados (RAG)")
p.add_argument("consulta", nargs="+", help="lo que quieres buscar")
p.add_argument("--n", type=int, default=5, help="cuantos resultados (def. 5)")
p.add_argument("--json", action="store_true", help="salida JSON en vez de texto")
a = p.parse_args()
from saber import busca
if not busca.disponible():
print("no hay indice: corre nucleo/saber/indexa.py", file=sys.stderr)
return 1
consulta = " ".join(a.consulta)
res = busca.busca(consulta, cuantos=a.n)
if a.json:
salida = [{"herramienta": r.get("herramienta"),
"fuente": r.get("fuente") or r.get("perfil"),
"fichero": r.get("fichero"),
"texto": r.get("texto", "")[:600]} for r in res]
print(json.dumps(salida, ensure_ascii=False, indent=2))
return 0
if not res:
print("sin resultados")
return 0
for i, r in enumerate(res, 1):
fuente = r.get("fuente") or r.get("perfil") or ""
cabecera = f"{i}. {r.get('herramienta', '?')}"
if fuente:
cabecera += f" ({fuente})"
print(cabecera)
print(" " + r.get("texto", "").strip().replace("\n", "\n ")[:500])
print()
return 0
if __name__ == "__main__":
sys.exit(main())

224
verde/ESCALERA.md Normal file
View file

@ -0,0 +1,224 @@
# La escalera del primer arranque
Lo que `pruebas/` no puede contestar. Las siete comprobaciones automáticas dicen
que las piezas están donde tienen que estar; esto dice si **sirve**, y eso solo
lo juzga alguien escuchando y leyendo.
Se sube **en orden y se para en el primer escalón que falle**. Es a propósito:
el escalón que falla es el que informa, y seguir subiendo con uno roto convierte
un fallo de una línea en una tarde de adivinar.
Antes de empezar:
```bash
./verde/arrancar.sh # mide, configura y dictamina
./verde/pruebas/ejecutar.sh # las 7; ninguna abre el TUI
./verde/verde-launcher.sh # el TUI, en verde fosforo
```
Los tiempos se anotan aquí mismo. El del naranja está para comparar, pero **léete
antes el aviso del final**: hoy no miden lo mismo.
Los escalones 1 a 4 también se pueden pasar sin abrir el TUI, que es como se
subieron la primera vez:
```bash
hermes -z "hola, ¿quién eres?" # una pregunta y a callar
```
Y para ver **qué herramientas llamó de verdad**, que `-z` no enseña:
```bash
python3 -c "
import sqlite3
c = sqlite3.connect('file:$HOME/.hermes/state.db?mode=ro', uri=True)
for r in c.execute('SELECT role, tool_name, tool_calls, content FROM messages ORDER BY rowid DESC LIMIT 10'):
print(r)
"
```
Sin eso, un fallo de rutas se atribuye al modelo. Pasó, y está contado en el
escalón 3.
---
## 1 · Que conteste
> hola, ¿quién eres?
Contesta que **el endpoint responde y el prompt fijo cabe**. No prueba nada más:
aquí no hay herramientas ni memoria, es la señal de vida.
| | |
|---|---|
| Tiempo | _______ |
| ¿Se presenta como asistente, sin inventarse una identidad rara? | _______ |
Si falla: no es cosa de Hermes. Vuelve a `arrancar.sh`, que separa el modelo del
arnés.
---
## 2 · Una herramienta, la más simple
> ¿cuánto espacio libre queda en el disco?
Primera llamada real. Tiene que **ejecutar `df`**, no describir cómo se ejecuta
`df`. Un modelo que explica el comando en vez de llamarlo ha entendido la
pregunta y ha fallado la tarea.
| | |
|---|---|
| Tiempo | _______ |
| ¿Llama a la herramienta o lo cuenta en prosa? | _______ |
| ¿El número coincide con `df -h /`? | _______ |
---
## 3 · Leer un fichero
> lee verde/README.md y resúmelo en una línea
Otra familia de herramienta (ficheros, no terminal) y, de paso, si el resumen
tiene que ver con lo que pone el fichero o se lo inventa a partir del nombre.
| | |
|---|---|
| Tiempo | _______ |
| ¿El resumen se parece al fichero de verdad? | _______ |
**Si el resumen no pega ni con cola, mira el directorio antes que el modelo.**
La primera vez que se subió esta escalera, este escalón devolvió un resumen
perfecto de Gitleaks. No era una alucinación: el agente arranca en `$HOME`, así
que resolvió `verde/README.md` contra `~`, donde efectivamente hay un
`README.md` — el de Gitleaks. Lo leyó entero y lo resumió bien.
Es un fallo traicionero porque **no deja ni un error**: la herramienta se
ejecuta, devuelve un fichero de verdad y el modelo acierta el resumen. Solo que
no es el fichero que pediste.
Ni el `cd` delante ni `--in DIR` lo arreglan (`--in` se ignora en modo `-z`). Lo
que lo arregla es `terminal.cwd` en `~/.hermes/config.yaml`, ya puesto y
comprobado por `pruebas/03_config.sh`. El detalle, en `NOTAS.md`.
---
## 4 · Encadenar — **el escalón que importa**
> dime cuál de los .md de verde/ es el más largo
Aquí se separa un chatbot de un agente. No hay una herramienta que conteste esto:
hay que **listar, medir y razonar sobre el resultado de la vuelta anterior**, y
decidir cuándo se ha terminado.
Es el escalón que más probabilidades tiene de romperse en un 4B, y el que
justifica el carril entero: si esto sale, el arnés aporta algo sobre el núcleo.
| | |
|---|---|
| Tiempo | _______ |
| Número de vueltas que da | _______ |
| ¿Acierta el fichero? (comprueba con `wc -c verde/*.md`) | _______ |
| ¿Se queda en bucle o sabe parar? | _______ |
---
## 5 · Una skill
> _(algo que dispare una de las skills instaladas; `hermes skills list` las lista)_
El paso del vídeo que el núcleo no tiene. Lo que se mira no es que la skill
funcione —eso es de quien la escribió— sino si el modelo **decide sola** que esa
skill viene a cuento.
| | |
|---|---|
| Skill probada | _______ |
| ¿La eligió sin que se la nombraras? | _______ |
| **¿Salió a la red para resolverlo?** | _______ |
**Esa tercera fila se añadió después de subir la escalera la primera vez, y es
la que más pesa.** Pidiéndole un cartel en ASCII, el agente eligió la skill solo
—bien—, descubrió que `pyfiglet` no estaba instalado y **se fue a una API de
terceros por su cuenta**, sin que nadie le dijera que podía.
Así que al probar una skill, mira siempre las llamadas, no solo el resultado:
```bash
ss -tnp | grep -v 127.0.0.1 # mientras corre
```
Ningún ajuste lo impide: el agente tiene terminal y la terminal tiene `curl`.
Es la contrapartida de lo que hace valioso este carril, y está desarrollado en
`NOTAS.md`.
---
## 6 · Que hable
> _(cualquier respuesta, con el TTS puesto)_
Tiene que sonar **exactamente igual que el naranja**: mismo Piper 1.2.0 MIT,
mismo `es_ES-davefx-medium`, mismo tono 1.0. `pruebas/07_voz.sh` ya comprueba
que el fichero sale a 22050 Hz mono y con la voz del núcleo; lo que falta es tu
oído en la cadena entera, dentro de Hermes y no llamando al script a mano.
| | |
|---|---|
| ¿Suena igual que el naranja? | _______ |
| ¿Corta frases o se come el final? | _______ |
---
## 7 · Que escuche — **el último, y por un motivo**
> _(dictar una orden por micrófono)_
Va el último porque es el que puede tirar todo lo demás. whisper `small` pide
**839 MiB en el pico de la transcripción** —no en reposo, que fue el error de
medida que costó una tarde— contra una tarjeta que ya tiene el modelo dentro.
| | |
|---|---|
| ¿Transcribe? | _______ |
| Tiempo de la primera (arranca el servidor, ~4 s) | _______ |
| Tiempo de las siguientes | _______ |
Si sale `CUDA error: out of memory`, **no es un fallo nuevo**: es el mismo
reparto que documenta el README del naranja. Salidas por orden:
1. Fiarse de `stt.local.unload_after_idle_seconds: 300`, que ya está puesto:
whisper suelta la VRAM tras cinco minutos de silencio.
2. Bajar el modelo de escucha a `base` (80 % de acierto en vez de 93 %).
3. Probar la escucha con el modelo de texto descargado (`ollama stop`).
Y recuerda que el envoltorio `~/.local/bin/whisper-server` del naranja ya tiene
la red de seguridad: por debajo de 950 MiB libres arranca en CPU (2,17 s en vez
de 1,05) en vez de morirse.
---
## Antes de comparar los dos carriles
**El verde va a medir más lento, y no será culpa del modelo.**
El naranja tiene **150 acciones rápidas que se saltan el cerebro**: "¿cuánto
espacio queda?" es un `df` directo, no una inferencia. En Hermes no existe ese
atajo — toda orden pasa por el modelo, con las 19 herramientas, las skills y la
memoria en contexto **en cada vuelta**.
Comparar el escalón 2 contra el naranja es comparar una inferencia contra un
`df`. Para comparar de verdad hacen falta frases que en el naranja **también**
acaben en el cerebro: están en `config/jarvis-sin-accion.jsonl`.
## Al terminar, devolver la máquina a su sitio
```bash
./verde/verde-launcher.sh --stop
ollama stop qwen3.5:4b-verde
./nucleo/arranca.sh # el naranja, como siempre
```
Y comprobar que el naranja vuelve con su VRAM de siempre. `pruebas/02_modelo.sh`
del verde ya verifica que sus cuatro parámetros siguen intactos, pero eso mira el
Modelfile, no que arranque.

865
verde/NOTAS.md Normal file
View file

@ -0,0 +1,865 @@
# Notas del carril verde
Lo medido, lo resuelto y lo que queda abierto.
Fechas: 15 de agosto de 2026 (instalación, en frío) y **16 de agosto (primer
arranque real)**. Lo de la primera fecha salía de leer código y mirar el disco;
lo de la segunda está ejecutado.
## EL SUELO DE 64k: el tope que invalidó la medida anterior (16 ago)
*(Esto va primero porque cambia la conclusión de la sección siguiente.)*
`arrancar.sh` midió bien: **24576 era el mayor contexto que cabía** en la GPU
con las 32 capas en la tarjeta, 3301 MiB, y con eso el modelo emitía `tool_calls`
válidos. Todo correcto y todo inútil, porque al abrir Hermes:
```
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 arnés**, no un aviso: `agent/model_metadata.py:405`,
`MINIMUM_CONTEXT_LENGTH = 64_000`, comprobado en `agent/agent_init.py:2656`.
No se ve en frío — `hermes prompt-size` no lo menciona — y solo salta al
construir el agente.
### Las dos salidas que se descartaron, y por qué
1. **La puerta de atrás de `lmstudio`.** El mismo `if` se salta si
`provider == "lmstudio"` y `context_length` está puesto a mano. No se usa: ese
proveedor llama a `{server}/api/v1/models`, la API **nativa de LM Studio**,
que ollama no tiene, y además intenta precargar el modelo por su cuenta
(`ensure_lmstudio_model_loaded`). Fingir ser otro servidor rompería más
adelante y de forma más confusa.
2. **Mentir en el `config.yaml`** poniendo 64000 con el modelo a 24576. Es la
peor de las tres: Hermes creería tener 64k, **no comprimiría** hasta
acercarse, y ollama iría tirando los tokens más viejos — que son el prompt de
sistema y los esquemas de las 19 herramientas. El síntoma sería que el agente
deja de llamar herramientas a mitad de sesión, **sin un solo error en el log**.
### Lo que se hizo: subir el contexto de verdad y pagar capas
Con `num_ctx 65536` fijo, medido el 16 de agosto:
| `num_gpu` | Resultado | En tarjeta | Total |
|---|---|---|---|
| 32 | `cudaMalloc failed` | — | — |
| 24 | `cudaMalloc failed` | — | — |
| **16** | **carga** | **2795 MiB** | 5784 MiB |
| 8 | carga | 1772 MiB | 5784 MiB |
| 0 | carga | 0 MiB | 5368 MiB |
O sea que 64k **sí cabe en esta máquina**, pero con **la mitad de la red en la
CPU** y unos 3 GB en RAM. Lo que eso cuesta en tiempo está más abajo, en
"Cuánto tarda de verdad".
La palanca para recuperar capas —`OLLAMA_FLASH_ATTENTION=1` +
`OLLAMA_KV_CACHE_TYPE=q8_0`, que parte el KV caché por la mitad— **sigue sin
aplicarse**: es del servicio de ollama, no del modelo, y afectaría también al
naranja. Esa decisión es del usuario.
**Lección de método**, que es la misma que ya aparece dos veces en este fichero:
se midió a fondo el límite de la *máquina* y no se comprobó el límite del
*software que iba encima*. Los dos son topes; solo uno se estaba mirando.
## Cuánto tarda de verdad (16 ago)
Medido con `hermes -z`, que es una pregunta y a callar, contra el modelo a
`num_ctx 65536` con 16 de 32 capas en GPU.
| Escalón | Vueltas | Tiempo |
|---|---|---|
| 1 · "hola, ¿quién eres?" — **en frío** | 0 herramientas | **2 min 38 s** |
| 2 · "¿cuánto espacio libre queda?" | 1 herramienta | **48,6 s** |
| 3 · "lee verde/README.md y resúmelo" | 4 (dos fallidas + recuperación) | **3 min 29 s** |
| 3 bis · igual, con `terminal.cwd` arreglado | 1 | **1 min 58 s** |
| 3 ter · igual, ya con `AGENTS.md` | 2 (una fallida + recuperación) | **4 min 54 s** |
| 4 · "cuál de los .md de verde/ es el más largo" | 4 encadenadas | **3 min 54 s** |
| 5 · "un cartel en arte ascii que ponga JARVIS" | 3 (una salió a la red) | **4 min 58 s** |
| 6 · "di en voz alta…" | 1 (`text_to_speech`) | **3 min 37 s** |
| *(pwd suelto, caché caliente, una llamada)* | 1 | **13,2 s** |
**Esa diferencia de tres veces no es ruido, y es lo más útil que se midió.** El
primer turno paga el prefill entero de los ~20.500 tokens del prompt fijo con
media red en la CPU. A partir de ahí, ollama **cachea el prefijo** y las vueltas
siguientes solo procesan lo nuevo — y eso que la 2 hace *dos* llamadas al modelo
(pedir la herramienta y redactar con su resultado) y aun así tarda un tercio.
O sea que la cifra que importa para decidir si esto se usa **no es la del primer
arranque**. Es la de régimen. Igual que la primera transcripción de whisper
tarda ~4 s por arrancar el servidor y luego se queda en ~930 ms.
El escalón 2 acertó, además: `663G` libres de `1,9T` al `63 %`, que es
exactamente lo que dice `df -h /`.
**Y el 3 enseñó algo que no se estaba buscando.** Falló dos veces seguidas —
partió mal la frase y pidió `cat ~/lee-verde/README.md`, que no
existe— y en vez de inventarse una respuesta cambió de estrategia: `search_files`
para localizar el fichero, y `read_file` con la ruta absoluta ya correcta. El
resumen final es fiel al README.
Eso es exactamente lo que se venía a probar de este carril: **recuperarse de una
herramienta que devuelve error**. El núcleo naranja, con sus acciones rápidas, o
acierta a la primera o no hay segunda. Las dos vueltas de más son las que
explican los 3 min 29 s.
### El punto flaco del 4B: las rutas relativas
Con `terminal.cwd` ya arreglado se repitió el escalón 3, y salió **peor**:
**1 min 58 s** y una sola llamada, `read_file` sobre
`COFRE/CODERS/JARVIS/README.md`. **Se comió el `verde/`.** Resumió el README de
la raíz —el del proyecto entero— con un "JARVES" de propina y la frase cortada a
medias.
La comparación entre las dos pasadas es lo interesante:
| | Ruta base | Qué hizo | Resultado |
|---|---|---|---|
| Con `cwd` en `$HOME` | mal | falló, buscó, se corrigió | **acertó** |
| Con `cwd` en el repo | bien | fue directo a una ruta plausible | **falló** |
O sea: **cuando la herramienta le devuelve un error, se recupera; cuando le
devuelve un fichero que existe pero no es el pedido, no tiene forma de notarlo**
y lo resume tan campante. Es el mismo patrón del error de `~/README.md`,
y la razón de que estos fallos no se vean: no hay error que mirar.
Lo que apunta a la mejora obvia si este carril sigue adelante: un `AGENTS.md` en
la raíz del repo describiendo la disposición de carpetas. Hermes ya carga
ficheros de contexto, así que el sitio está.
#### Escrito el `AGENTS.md`, el escalón 3 pasa — pero no por lo que parece
Se escribió (`<raíz>/AGENTS.md`: los tres carriles, el árbol de carpetas y la
regla de que las rutas relativas salen de la raíz del repo). Cuesta **2.490 B**
del prompt fijo, que sube a 84.740 B ≈ **21.185 tokens** de los 65.536: quedan
44.351 para conversar.
Tercera pasada del escalón 3, **4 min 54 s**, y el resumen es del fichero
correcto por fin. Recoge incluso el aviso de la fuga a internet que se añadió
hoy al README.
**Pero el modelo volvió a alucinar la ruta.** Su primera llamada fue
read_file ~/COFRE/CODERS/JARVIS-verde/zapier-connection.py
que no existe y no se parece a nada de lo pedido. Recibió `File not found` y
**entonces** acertó con `JARVIS/verde/README.md`.
O sea que el `AGENTS.md` **no evitó el disparate; le dio con qué recuperarse**.
Es la tercera vez que sale el mismo patrón en esta sesión y ya no es anécdota:
| Lo que recibe la herramienta | Qué hace el 4B |
|---|---|
| Un **error** | cambia de estrategia y suele acertar |
| Un fichero **que existe pero no es** | lo resume tan contento |
La lección práctica para este carril: **más vale una ruta que falle que una que
acierte por accidente.** Y en la prosa sigue descuidado — aquí se inventó un
"98 % local", llamó API de terceros a `pyfiglet` (que es una librería local; la
API era `asciified.thelicato.io`) y le dio la vuelta a la frase del objetivo.
### El escalón 4, que era el que decidía: **pasa**
*"dime cuál de los .md de la carpeta verde/ es el más largo"* — **3 min 54 s**,
cuatro llamadas encadenadas, respuesta correcta:
| # | Llamada | Qué pasó |
|---|---|---|
| 1 | `search_files` con `pattern: "*.md"` | `rg: regex parse error` |
| 2 | `search_files` con `pattern: "\\.md$"` | **corrigió el patrón él solo** |
| 3 | `terminal`: `find … -name "*.md"` | los cinco ficheros |
| 4 | `terminal`: bucle con `wc -l` sobre cada uno | las cinco cifras |
Y concluyó: `verde/NOTAS.md` con 625 líneas. Es el correcto.
**Esto es lo que justificaba el carril entero.** No hay una herramienta que
conteste esa pregunta: hay que listar, medir y razonar sobre el resultado de la
vuelta anterior, y saber cuándo parar. El núcleo naranja no puede hacerlo — sus
150 acciones rápidas o aciertan a la primera o no hay segunda.
La letra pequeña, que no cambia el veredicto pero conviene tener escrita: al
enumerar los ficheros los ordenó mal (puso PLAN-ARRANQUE con 181 por encima de
ESCALERA con 208) y etiquetó el README de `verde/` como *"categoría raíz"*. O
sea que **el razonamiento de la cadena es bueno y la prosa de alrededor es
descuidada**. Para una orden hablada da igual; para un informe, no.
## EL BLOQUEO DEL ARRANQUE: no cabe la pregunta
*(15 ago, tarde. Esto es lo más importante del fichero.)*
Se preparó el primer arranque y apareció un tope que no era el que se
anticipaba. **No es que `qwen3.5:4b` no sepa llamar herramientas: es que no le
cabe la pregunta.** Medido con `hermes prompt-size`:
| | |
|---|---|
| Prompt de sistema de Hermes | 26.281 B |
| Esquemas de las 19 herramientas | 55.978 B |
| **Fijo, antes de decir nada** | **82.259 B ≈ 20.500 tokens** |
| `num_ctx` de `qwen3.5:4b-jarvis` | **4.096** |
Cinco veces por encima. Y hay un segundo tope escondido: **`num_predict 300`,
que el naranja hereda del modelo BASE** (no lo pone su Modelfile). 300 tokens
cortan el JSON de una llamada a herramienta por la mitad.
Los dos están **bien puestos para el naranja** —respuestas habladas cortas, VRAM
para whisper— y son justo lo contrario de lo que pide un arnés agéntico.
Y encima, la trampa que avisaba la documentación: `/api/show` reporta
`qwen35.context_length = 262144`, el máximo del modelo, **no** los 4096
configurados. Hermes se habría creído que tenía 262k. El síntoma habría sido
respuestas truncadas y llamadas rotas, y la conclusión natural —"el 4B no vale
para esto"— **habría sido falsa**.
### La variante verde, ya creada
`verde/qwen3.5-4b-verde.Modelfile``ollama create qwen3.5:4b-verde`.
**`qwen3.5:4b-jarvis` no se tocó.**
| Parámetro | Valor | Por qué |
|---|---|---|
| `num_gpu` | 32 | todas las capas, con el naranja cerrado |
| `num_ctx` | 24576 | 32768 no cabe (ver abajo) |
| `num_predict` | 4096 | 300 cortaría una tool call |
| `temperature` | 0.2 | emitir JSON no es escribir prosa |
**Trampa del Modelfile:** ollama **no admite comentarios en la misma línea** que
un `PARAMETER`. Esto falla:
```
PARAMETER num_gpu 32 # todas las capas
→ Error: invalid int value [32 # todas las capas]
```
Los comentarios van en su propia línea.
### Lo medido de VRAM
| Configuración | Resultado |
|---|---|
| `num_ctx 32768`, 32 capas | **`cudaMalloc failed: out of memory`** |
| `num_ctx 24576`, 32 capas | **pendiente**: hay que medirlo con la tarjeta libre |
Escalera si 24576 tampoco entra, en orden de menos a más invasivo: `num_ctx
16384` (el suelo: por debajo no cabe el prompt fijo con holgura) → `num_gpu 24`
`OLLAMA_FLASH_ATTENTION=1` + `OLLAMA_KV_CACHE_TYPE=q8_0`.
**Cuidado con el último:** `OLLAMA_KV_CACHE_TYPE` es del **servicio**, no del
modelo. Se pone en el systemd de ollama y **afectaría también al naranja** la
próxima vez que arranque. Si se usa, hay que anotarlo aquí y en el Modelfile del
naranja.
### Desde dónde se lanza importa, y mucho
| Directorio de arranque | Prompt de sistema |
|---|---|
| `~/COFRE/CODERS/JARVIS` | 26.281 B |
| `~/.hermes/hermes-agent` | **55.261 B** |
La diferencia son 30 KB: el `AGENTS.md` de 81 KB del **propio repo de Hermes**,
que se cuela como fichero de contexto y encima sale truncado con un aviso. A
mano es fácil olvidarlo y sacar medidas que no comparan nada.
### Y el `cd` NO basta (16 ago)
Esto se descubrió subiendo la escalera y es peor que lo anterior, porque **no
deja ni un error**. `verde-launcher.sh` hacía `cd "$RAIZ"` antes de llamar a
Hermes, que parece suficiente y no lo es: **Hermes restaura el directorio de
trabajo apuntado en la sesión** y se salta aquel desde el que lo lanzas.
El síntoma: se le pide *"lee verde/README.md y resúmelo en una línea"* y
devuelve
> **Gitleaks es una herramienta SAST diseñada para detectar y prevenir secretos
> codificados a mano en repositorios git.**
Parece el modelo alucinando de manera espectacular. No lo es. En la base de
sesiones se ve la llamada real:
```
terminal {"command": "cat ~/README.md | head -50"}
```
Resolvió `verde/README.md` contra `~`, ahí hay un `README.md` que es el
de Gitleaks, lo leyó entero y lo resumió **bien**. La herramienta funcionó, el
modelo entendió, el resumen es correcto — y la respuesta es inútil.
### Lo que NO lo arregla, probado
**`--in DIR` no vale en modo `-z`.** Parece la bandera exacta —entra en el
directorio *y* se salta la restauración, mientras que `--no-restore-cwd` solo
hace lo segundo— y con ella el fallo se repitió igual. El motivo está en
`hermes_cli/main.py:11324`: el modo de una sola pregunta sale por
`_run_and_exit_oneshot(...)` y **termina el proceso ahí**, sin llegar nunca a
`cmd_chat`, que es la única función donde se atiende `--in`. Se ignora en
silencio. En el TUI sí funciona, y por eso `verde-launcher.sh` la pasa.
**Un `cd` delante tampoco.** Comprobado del modo más directo posible:
```
pwd del shell : ~/COFRE/CODERS/JARVIS
pwd del agente : ~
```
Y no es que el modelo se lo invente: el `ls -d */` que ejecutó por su cuenta
devolvió `BIKEPARK/ COFRE/ ctf/ Downloads/…`, o sea el `$HOME` de verdad.
### Lo que sí lo arregla
`terminal.cwd` en `~/.hermes/config.yaml`, que de fábrica viene como `"."` y
que su propia documentación describe como *"el directorio actual donde ejecutas
hermes"*. **No lo es.**
```yaml
terminal:
cwd: "~/COFRE/CODERS/JARVIS"
```
Verificado: con eso el prompt de sistema pasa a decir
`working directory: ~/COFRE/CODERS/JARVIS`. Está replicado en
`verde/config.yaml` y lo comprueba `pruebas/03_config.sh`.
**Ojo, que esto ATA el carril a este repositorio.** Es lo correcto para un banco
de pruebas que se compara contra el naranja, y es una limitación real si algún
día se quiere que el verde mire `~/BIKEPARK` o `~/oasis`. Con la raíz en `$HOME`
además se ahoga: un `search_files` de `*.md` devolvía sobre todo
`site-packages/` de los venv de `entrena/`.
**Cómo se diagnosticó, que es reutilizable:** `-z` imprime solo el texto final,
sin las llamadas a herramienta, así que desde fuera no se ve nada. Todo queda en
`~/.hermes/state.db`, tabla `messages`, columnas `tool_calls` y `content`:
```bash
python3 -c "
import sqlite3
c = sqlite3.connect('file:$HOME/.hermes/state.db?mode=ro', uri=True)
for r in c.execute('SELECT role, tool_name, tool_calls, content FROM messages ORDER BY rowid DESC LIMIT 10'):
print(r)
"
```
Sin mirar ahí, este fallo se atribuye al modelo y se acaba cambiando de modelo
para nada.
## Por qué se paró: `dataset.py`
El arranque en GPU quedó **aplazado**, no descartado. La gráfica la tiene un
trabajo tuyo: `JARVIS/entrena/dataset.py`, que genera pares pregunta-respuesta
de COFRE con `qwen3.5:4b-jarvis`. Son **7-10 horas** (tu propio README lo dice) y
va por `100/2261 fragmentos`.
Detalle que confunde y conviene tener escrito: mientras ese script corra,
`ollama stop qwen3.5:4b-jarvis` **parece no funcionar**. Sí funciona — descarga
el modelo — pero la siguiente petición del script lo vuelve a cargar en
segundos. No es un fallo de ollama.
Comprobado que los `ollama stop` **no rompieron** la generación: siguió de 199 a
224 ejemplos en 75 s.
### Retomar cuando termine
```bash
# 1. confirmar que dataset.py acabó y la tarjeta está libre
pgrep -f 'entrena/dataset.py' || echo "terminado"
nvidia-smi --query-gpu=memory.used --format=csv,noheader # ~140 MiB
# 2. medir si 24576 cabe
curl -s http://127.0.0.1:11434/v1/chat/completions -H 'Content-Type: application/json' \
-d '{"model":"qwen3.5:4b-verde","messages":[{"role":"user","content":"ok"}],"max_tokens":5}'
curl -s http://127.0.0.1:11434/api/ps | python3 -m json.tool | grep -E 'size_vram|context_length'
# 3. decirle a Hermes el contexto REAL (si no, cree que son 262144)
# en ~/.hermes/config.yaml, bloque model:, en las lineas canonicas (~82/~95)
# default: "qwen3.5:4b-verde"
# context_length: 24576
# 4. LA PRUEBA DECISIVA, antes de abrir el TUI: ¿emite tool_calls?
curl -s http://127.0.0.1:11434/v1/chat/completions -H 'Content-Type: application/json' -d '{
"model":"qwen3.5:4b-verde",
"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",
"parameters":{"type":"object","properties":{"comando":{"type":"string"}},"required":["comando"]}}}]}' \
| python3 -m json.tool
```
El paso 4 separa dos preguntas que juntas se contestan mal: *¿sabe el modelo
emitir una llamada?* y *¿funciona Hermes?*. Si sale `tool_calls` con JSON
válido, lo que falle después es de Hermes. Si contesta en prosa, abrir el TUI
solo daría un fallo más confuso.
Después, la escalera del plan: "hola" → una herramienta → ficheros → **varias
vueltas encadenadas** (el escalón que separa un chatbot de un agente) → skills.
La voz al final, y la escucha la última de todas: los 839 MiB de pico de whisper
contra una tarjeta que tendrá el modelo casi entero.
## Estado de la gráfica
| | VRAM | Quién |
|---|---|---|
| Antes de instalar | 878 MiB de 4096 | tres `whisper-server` tuyos, del naranja |
| Después de instalar | **127 MiB de 4096** | nadie; tus whisper acabaron solos |
Instalar **no cargó nada** en la tarjeta. Comprobado con
`nvidia-smi --query-compute-apps` y con `/api/ps` de Ollama, que devolvió
`{"models": []}` en todo momento.
De paso, un dato que puede interesarte del naranja: llegaste a tener **tres
`whisper-server` a la vez**, ~880 MiB cada uno, 2648 MiB en total sobre una
tarjeta de 4096. Es el escenario exacto del `CUDA error: out of memory` que
documenta el README. No hice nada al respecto —es tu desarrollo en marcha— pero
queda anotado.
## Verificación en frío, ejecutada
| Comprobación | Resultado |
|---|---|
| `hermes` en PATH | `~/.local/bin/hermes` |
| Tamaño de `~/.hermes` | 2,2 GB |
| Caché de Playwright | 622 MB |
| Credenciales con valor en `.env` | **0** |
| Tokens de mensajería | **0** (por eso el gateway nunca arrancó) |
| URLs de nube activas en `config.yaml` | **0** |
| Puertos fuera de loopback | **ninguno** |
| Modelos cargados en Ollama | ninguno |
| Puente de voz | **funciona**: 0,34 s para 3,2 s de audio, 22050 Hz mono |
Las 11 líneas con contenido del `.env` no son claves: son `TERMINAL_TIMEOUT`,
`BROWSER_SESSION_TIMEOUT`, banderas de depuración y similares.
## Trampa: claves YAML duplicadas en config.yaml
**Esto casi cuela y merece quedar escrito.** El `config.yaml` que genera el
instalador declara `provider` y `base_url` **dos veces** dentro del bloque
`model:`: una arriba, junto a `default`, y otra ~45 líneas más abajo. En YAML la
última repetición gana.
Al configurar Ollama arriba, el fichero *parecía* correcto leyéndolo, y al
parsearlo salía esto:
```
model.provider: auto
model.base_url: https://openrouter.ai/api/v1
```
Es decir: el carril habría salido **por OpenRouter** creyendo yo que iba a
localhost. Se arregló dejando los valores solo en las líneas de abajo, las
canónicas.
**La lección, que vale para todo este fichero:** con 1890 líneas y 121 activas,
no basta con leerlo. Hay que parsearlo:
```bash
python3 -c "import yaml;d=yaml.safe_load(open('$HOME/.hermes/config.yaml'));print(d['model'])"
```
## Trampa: la instalación de Chromium se cuelga
Le pasa a más gente (issue #35166 del repo). El zip se descarga entero y la
**extracción se queda parada**: 50 minutos de reloj con 1 segundo de CPU y cero
crecimiento en `~/.cache/ms-playwright/`. El `timeout` del instalador mata al
padre y deja procesos huérfanos reteniendo un `__dirlock`, así que el reintento
también falla.
Se resolvió a mano, y si vuelve a pasar tras un `hermes update`, esta es la
receta:
```bash
pkill -f oopDownloadBrowserMain; pkill -f 'playwright install'
rm -rf ~/.cache/ms-playwright/chromium-1208 ~/.cache/ms-playwright/__dirlock
mkdir -p ~/.cache/ms-playwright/chromium-1208
unzip -q /tmp/playwright-download-*/playwright-download-chromium-*.zip \
-d ~/.cache/ms-playwright/chromium-1208/
touch ~/.cache/ms-playwright/chromium-1208/INSTALLATION_COMPLETE
```
El `INSTALLATION_COMPLETE` es lo que Playwright mira para dar el navegador por
bueno (`lib/server/registry/index.js:1345`). Sin él lo reinstala cada vez.
Quedaron los tres componentes, verificados con `playwright install --dry-run`:
| | Tamaño |
|---|---|
| `chromium-1208` | 364 MB |
| `chromium_headless_shell-1208` | 254 MB |
| `ffmpeg-1011` | 4,9 MB |
El binario responde: `Google Chrome for Testing 145.0.7632.6`.
## Resuelto: tu voz afinada SÍ se puede reutilizar
Era la pregunta abierta del plan, y salió el mejor de los tres desenlaces.
**Hermes tiene un proveedor de TTS por comando que no está documentado en su
web.** Está en `tools/tts_tool.py`, bloque *"Custom command providers"*:
```yaml
tts:
provider: mi-voz
providers:
mi-voz:
type: command
command: "... {output_path} {input_path}"
output_format: wav
```
Marcadores: `{input_path}`, `{text_path}`, `{output_path}`, `{format}`,
`{voice}`, `{model}`, `{speed}`. Ejecuta con `shell=True`.
Eso permite llamar al mismo `config/jarvis-piper.sh` que usa el naranja, así que
**el verde suena exactamente igual**: mismo binario Piper 1.2.0, mismo `.onnx`,
misma cadena de audio. Sin esto la comparación de voz entre carriles no habría
valido nada.
Dos detalles que obligaron a escribir `voz-verde.sh` en vez de meter el comando
directo en el YAML:
1. **El entorno se limpia.** `_run_command_tts` llama a
`hermes_subprocess_env(inherit_credentials=False)`, así que
`JARVIS_PIPER_VOZ` y `JARVIS_PIPER_TONO` no llegarían. Se ponen dentro del
adaptador.
2. **Las interfaces no encajan.** `jarvis-piper.sh` quiere el texto como
argumento; Hermes entrega un fichero. El adaptador hace de puente.
### Qué voz, y ojo con el README
`voz-verde.sh` usa **davefx a tono 1.0**, que es lo que tiene el núcleo hoy
(`nucleo/boca/voz.py`: `POR_DEFECTO = "davefx"`, `JARVIS_PIPER_TONO = "1.0"`).
El `README.md` del repo describe `sharvard` + 0,86 = 106 Hz como *"la puesta"*.
Eso era el JARVIS sobre Newelle. **El verde copia al núcleo, que es contra quien
se va a comparar, no al README.** Si algún día el núcleo vuelve a sharvard, hay
que cambiarlo aquí también.
### De paso: el Piper de Hermes es el GPL, no el tuyo
El proveedor `piper` de serie usa `OHF-Voice/piper1-gpl` (GPL-3.0). El tuyo es
el original `rhasspy/piper` 1.2.0, **MIT**, archivado en octubre de 2025. Los dos
son software libre; no son el mismo paquete. Como usamos el comando y no el
proveedor de serie, el verde habla con **tu** Piper MIT.
## El cerebro: lo que hay que vigilar
Configurado como proveedor `custom` contra `http://127.0.0.1:11434/v1` con
`qwen3.5:4b-jarvis`. Ollama ya habla OpenAI, no hace falta puente.
**Pero la propia guía de Hermes avisa de algo que hay que probar.** Su tabla de
modelos locales dice, literalmente, que para trabajo agéntico completo hace
falta `gemma4:31b`, y marca *sin* tool calling a todo lo de 9B para abajo:
| Modelo (su tabla) | Tool calling |
|---|---|
| `gemma4:31b` | Sí |
| `gemma2:27b` | No |
| `gemma2:9b` | No |
| `llama3.2:3b` | No |
Su tabla no lista qwen3.5, que **sí** hace tool calling — el commit
*"nucleo: el cerebro con tool calling nativo"* lo demuestra. Pero lo demuestra
**en tu arnés**, no en el de Hermes, que inyecta muchas más definiciones de
herramientas por vuelta. Así que:
- Es lo primero que hay que comprobar al arrancar: si el 4B llama herramientas
bien dentro de Hermes o se atraganta.
- Si se atraganta, esta máquina tiene **62 GB de RAM**: un modelo grande cabe,
aunque iría por CPU (~2-5 tok/s según su guía, 30-120 s por respuesta). Para
eso está `HERMES_API_TIMEOUT=1800` documentado en `env.ejemplo`.
- `qwen3.5:9b` (6,14 GB) es el escalón intermedio, y solo con el naranja cerrado.
**Aviso de contexto:** si se define `num_ctx` propio en Ollama, hay que poner el
mismo en Hermes. Su `/api/show` reporta el contexto *máximo* del modelo, no el
configurado, así que Hermes puede creer que tiene más sitio del que hay.
## Antes de comparar los dos carriles: no midas peras y manzanas
El naranja tiene **150 acciones rápidas que se saltan el modelo**: "¿cuánto
espacio queda?" es un `df`, no una inferencia. Hermes **no tiene ese atajo**
allí toda orden pasa por el cerebro, con las herramientas, las skills y la
memoria en contexto en cada vuelta.
El verde va a medir más lento, y **no será culpa del modelo**. Para comparar de
verdad hay que usar solo las frases que hoy *no* encajan en el catálogo y acaban
en el cerebro: son las únicas donde los dos hacen lo mismo. Las hay anotadas en
`config/jarvis-sin-accion.jsonl`.
## Pendiente: dependencias de sistema de Playwright
El instalador pidió `sudo` para las librerías de sistema de Chromium, se declinó
(no hay sudo sin contraseña) y **degradó solo**: instaló el binario en la caché
del usuario. Chromium arrancará si las librerías ya están; si se queja, esto es
lo que hay que correr una vez:
```bash
cd ~/.hermes/hermes-agent && sudo npx playwright install-deps chromium
```
## Qué se descargará el primer día que arranques
Nada de esto ha pasado todavía. Que no sorprenda:
| | Cuándo | Cuánto |
|---|---|---|
| Modelo de whisper `small` | primera transcripción | ~500 MB en disco, 839 MiB de pico en GPU |
| Modelo de Ollama | ninguno — `4b-jarvis` ya está | 0 |
| Voces de Piper | ninguna — usamos las tuyas | 0 |
`stt.local.unload_after_idle_seconds: 300` está puesto justo por los 839 MiB:
suelta whisper tras cinco minutos de silencio y le devuelve el hueco al modelo.
Cuesta una recarga en la frase siguiente.
## Se instaló también el driver de Computer Use
Va en la instalación completa. Es el que permitiría a un agente **controlar
ratón y teclado del escritorio**, no solo el navegador. Instalado no es lo mismo
que activo — hoy no hay nada que lo invoque — pero conviene saber que está antes
de dar permisos a algo. Si se quiere fuera: `--skip-computer-use`.
**Dato que sí importa en esta máquina:** ese driver no viene de Nous. El
instalador lo trae con un `curl | bash` contra **terceros**:
```
https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh
```
O sea que el envoltorio `instalar.sh` audita el instalador de Hermes, pero ese
instalador se descarga y ejecuta otro script de otro repo sin que nadie lo mire.
Con COFRE en esta máquina, merece una lectura antes del próximo `hermes update`.
Está en `~/.hermes/hermes-agent`, y el binario resultante también.
## Las 104 pruebas del naranja: NO se ejecutaron
El plan decía correr `pruebas/ejecutar.sh` para comprobar que el naranja seguía
dando el mismo número. **No se hizo**, y a propósito: esa suite abre la
aplicación y carga el modelo, que es exactamente lo que se pidió no hacer
mientras el naranja estaba en desarrollo.
Queda pendiente para cuando pares de desarrollar:
```bash
cd ~/COFRE/CODERS/JARVIS/pruebas && ./ejecutar.sh
```
En su lugar se comprobó lo que sí se podía en frío: que la gráfica quedó libre y
que el verde no escribió fuera de su sitio. Todo lo suyo vive en `verde/`, en
`~/.hermes/`, más tres ficheros de escritorio (`~/.local/share/icons/jarvis-verde.png`,
`~/.local/share/applications/jarvis-verde.desktop`) y la caché de Playwright.
Los 13 ficheros que `git status` da por modificados son **todos anteriores** a
esta sesión: el más reciente es `config/set_persona.py` a las 13:22 y esto
empezó a las 16:09. Y los ficheros nuevos que aparecen en `nucleo/` después de
esa hora —`arranca.sh`, `estado.py`, `ventana.sh`, `pruebas/prueba_cara.py`…—
son **tu desarrollo del naranja de esta tarde**: no existían cuando se listó esa
carpeta al empezar.
## La duda del 4B: resuelta, y a favor (16 ago)
Era la pregunta abierta del carril — la guía de Hermes marca *sin* tool calling
todo lo de 9B para abajo y recomienda `gemma4:31b` para trabajo agéntico.
**`qwen3.5:4b` llama herramientas bien dentro de Hermes.** Comprobado primero
contra el endpoint pelado (`pruebas/04_tool_calling.sh`, tres casos) y después
en el arnés completo, con las 19 herramientas en contexto:
| Caso | Resultado |
|---|---|
| Pide herramienta cuando hace falta | `terminal {"command": "df -h ."}` |
| Acierta la herramienta y sus **dos** argumentos | `escribe_fichero {"ruta": …, "contenido": …}` |
| **No** llama cuando no hace falta ("¿cuánto son dos más dos?") | contesta `4` en prosa |
El tercero importa tanto como los otros: un modelo que llama siempre es tan
inútil como uno que no llama nunca, y con una sola pregunta no se distinguen.
## El escalón 6: habla, y con la voz del naranja (16 ago)
`text_to_speech` → proveedor `jarvis-piper``voz-verde.sh`
`config/jarvis-piper.sh`. Comparado el WAV que salió de Hermes con el que genera
la prueba `07_voz.sh` llamando al adaptador a pelo:
| | Hermes | Directo |
|---|---|---|
| Frecuencia | 22050 Hz | 22050 Hz |
| Canales | 1 | 1 |
| Bits | 16 | 16 |
Idénticos, que era el objetivo: mismo binario Piper 1.2.0 MIT, mismo `.onnx`,
mismo tono. **La comparación de voz entre los dos carriles vale**, que es para
lo que se montó el adaptador.
De propina, sin que nadie se lo pidiera contestó *"Perfecto, señor"* y *"Todos
mis sistemas están listos para sus órdenes"*. El registro de JARVIS sale del
`SOUL.md` de Hermes, no de la persona del naranja, y aun así coincide.
Queda el juicio que no es medible: si suena bien. El fichero está en
`~/.hermes/cache/audio/`.
## EL ESCALÓN 5 ROMPIÓ LA PREMISA: el agente se fue a internet solo (16 ago)
Esto es lo más serio de la sesión de estreno y hay que leerlo antes que nada.
Se le pidió *"hazme un cartel en arte ascii que ponga JARVIS"* — a propósito sin
nombrar ninguna skill, que es lo que medía el escalón. Lo que hizo:
| # | Llamada | Resultado |
|---|---|---|
| 1 | `skill_view {"name": "ascii-art"}` | **eligió la skill sola** ✓ |
| 2 | `python3 -m pyfiglet "JARVIS" --font slant` | `No module named pyfiglet` |
| 3 | `curl https://asciified.thelicato.io/api/v2/ascii?text=JARVIS` | **salió a internet** |
Nadie le dijo que podía. La skill menciona pyfiglet, pyfiglet no está instalado,
y el modelo resolvió el hueco **buscando el servicio equivalente en la web** y
mandándole el texto a un tercero.
### Por qué no lo cazaba nada
`pruebas/06_privacidad.sh` pasa, y seguirá pasando: comprueba **configuración**
—cero credenciales, cero URLs de nube en el YAML, nada escuchando fuera de
loopback— y esto no es configuración, es **conducta en marcha**. El agente tiene
una terminal y la terminal tiene `curl`. No hay ajuste que lo impidiera.
Es una diferencia de fondo con el naranja, y conviene que quede escrita: el
naranja ejecuta un catálogo cerrado de 150 acciones. El verde **decide qué
ejecutar**, y ese es justo su valor y justo su riesgo. No se puede tener lo uno
sin lo otro.
### Lo que se puede hacer, por orden de menos a más invasivo
1. **Instalar lo que las skills dan por hecho** (`pip install pyfiglet`). Quita
el motivo, no la capacidad. Es parche, no solución.
2. **Apagar las skills que tiran de red**: `hermes skills disable <nombre>`.
3. **Recortar herramientas** en `platform_toolsets` del `config.yaml`, que ya
estaba apuntado como palanca para el contexto y sirve para esto también.
4. **Cortar por debajo**: regla de cortafuegos para el proceso, o terminal en
contenedor sin red. `hermes egress` **no vale** para esto — es iron-proxy,
un proxy que INYECTA credenciales en las salidas, no un bloqueo.
**No he aplicado ninguna.** Cualquiera de ellas cambia lo que el carril sabe
hacer, y eso es una decisión del usuario, no una corrección técnica.
### Y el cartel salió mal
Aparte de todo lo anterior: el banner que `curl` devolvió era correcto, y al
copiarlo a su respuesta final el modelo **lo destrozó**. Mismo patrón que la
lista de ficheros del escalón 4: el 4B razona bien la cadena y es descuidado
reproduciendo texto literal largo. 4 min 58 s en total.
## El escalón 7 (la escucha) cabe, y por un motivo que sorprende (16 ago)
El plan lo dejaba para el final temiendo un `CUDA error: out of memory`: whisper
`small` pide **839 MiB en el pico** de la transcripción y la tarjeta son 4096.
Medido con el verde cargado y trabajando:
| | MiB |
|---|---|
| Tarjeta | 4096 |
| Ocupado por el modelo verde (65536 ctx, 16 capas) | 2902 |
| **Libre** | **1062** |
| Pico de whisper `small` | 839 |
| Margen | **223** |
**Cabe.** Y lo que sorprende es que el verde deja **más** sitio que el naranja:
2902 MiB contra los 2941 del reparto automático de ollama para
`qwen3.5:4b-jarvis`. Bajar 16 capas a la CPU libera tarjeta, así que el precio
que se paga en velocidad se cobra en VRAM.
Sigue siendo justo —223 MiB— y falta la prueba de verdad, que necesita
micrófono. Pero la aritmética que hacía temer el escalón ya no dice lo que
decía.
## Para probar cuando arranques
- **Tema del panel web.** `dashboard.theme` acepta `default`, `midnight`,
`ember`, `mono`, `cyberpunk`, `rose`. No he podido ver sus paletas (el panel
va empaquetado), pero `cyberpunk` o `mono` son los candidatos a pegar con el
verde. El TUI no tiene tema: ahí el color lo pone `verde-launcher.sh` con
secuencias OSC.
- **Los escalones 5, 6 y 7** de `ESCALERA.md`: una skill, la voz dentro de
Hermes y la escucha por micrófono. Los tres piden criterio u oído, o el
micrófono, así que no se automatizan.
- **Si merece la pena la palanca del KV caché.** `OLLAMA_FLASH_ATTENTION=1` +
`OLLAMA_KV_CACHE_TYPE=q8_0` partiría el caché por la mitad y devolvería capas
a la tarjeta, que es justo lo que hoy cuesta el tiempo de respuesta. **No se
ha aplicado**: es del servicio de ollama y afectaría también al naranja.
## Noche del 16-17 ago: voz de entrada, RAG y frontal a nivel del naranja
Encargo: subir el verde al nivel del naranja en voz, RAG y frontal. Todo local,
nada a gitea (pendiente de revisión).
### La escucha, resuelta y en local (escalón 7 cerrado sin micro)
El TUI de Hermes no tiene micrófono, y su STT nativo (`stt:` del config) es para
notas de voz de **mensajería** — saldrían de la máquina. Así que se puentea:
- `verde/escucha_transcribe.py` — WAV → texto con **faster-whisper `small`**
(el mismo que declara el config), en **CPU** a propósito: el 4B ocupa la
tarjeta y el margen es de 223 MiB; whisper en CPU tarda ~12 s por frase y no
arriesga OOM. No llama a ninguna API.
- `verde/escucha-verde.sh` — pulsar-para-hablar: graba (pw-record/arecord/parec)
→ transcribe → `hermes chat --reasoning none -q` → responde; `--habla` lo dice
con la voz del verde, `--seguido` encadena turnos.
- `verde/pruebas/08_escucha.sh`**8/8**. Bucle TTS→STT sin micro: genera voz
con el `07`, la transcribe y comprueba que vuelven las palabras clave. Mide y
verifica que el transcriptor no referencia APIs remotas.
Medido: "hola jarvis verde cuanto espacio libre queda en el disco" → *"¿Cuánto
espacio libre queda en el disco?"* (solo pierde "jarvis"→"harvis", nombre raro).
Latencia honesta: la ESCUCHA va bien (~12 s CPU). El cuello es el 4B (minutos
por turno). El puente sirve para PROBAR que el verde oye, no como diario.
### RAG del naranja, disponible en el verde
El agente ya puede consultar el mismo índice que el núcleo (14.949 entradas):
- `nucleo/saber/busca_cli.py` — CLI sobre `saber.busca` (texto o `--json`).
- Skill local `~/.hermes/skills/research/buscar-en-apuntes/` — le dice al agente
cuándo y cómo buscar (herramienta de terminal, local). `hermes skills list`:
`buscar-en-apuntes · research · local · enabled`.
### Skills de ECC (el ganador del hackathon de Claude)
Instaladas 4 por `hermes skills install` (escaneadas SAFE): `security-review`,
`git-workflow`, `python-testing`, `coding-standards`. Total 82 skills. El prompt
fijo apenas sube (29 KB, lejos de 64k). Probada `security-review` con el 4B:
checklist correcto, 4 min 30 s en frío.
## Noche: expansión del RAG + reentrenamiento (auto mode, local, sin gitea)
Contenido añadido al índice PERSONAL (nunca al público sin revisar licencias):
- **Strix** (usestrix/strix, agente IA de pentesting) clonado en CODERS; sus docs
(README, docs/, AGENTS, benchmarks) indexados → 49 fragmentos.
- **6 awesome lists** más: sysadmin, cli-apps, security, pentest, shell, linux
(catálogo pasa de 385 a 740 fragmentos). En awesome.py, publico=False.
- **Apps instaladas** de esta máquina: nucleo/saber/instalado.py lee los .desktop
(146 apps) + apt-mark showmanual (22 fragmentos). Personal por definición.
- Índice personal: ~49 MB. Reindex raw hecho; contenido recuperable ya.
Reentrenamiento nocturno: nucleo/saber/enriquece/reentrena_noche.sh encadena
síntesis de ganchos del contenido nuevo → reindex → eval → refresco COFRE
(cosecha+sintetiza) → reindex final. Corre detached, log en datos/reentrena_noche.log.
Pendiente de revisar por la mañana: licencias de las 6 awesome nuevas antes de
subir nada; y decidir qué de todo esto va al índice público / gitea.
## Cierre de la noche (07:52) — números y decisiones
Dos rondas de reentrenamiento. Índice final: **64 MB, 19.578 entradas** (12.260
preguntas-gancho, 1.383 catálogo, 168 apps, 459 metodología incl. strix).
Retrieval (eval_set ampliado a 57 casos = 45 pentest + 12 amplitud):
- Ronda 1: hit@1 77%, hit@5 96%
- Ronda 2 (+osint/devops/docker/go/python): hit@1 **75%**, hit@5 **96%**
- Amplitud sola (apps/self-hosted/cli/linux/strix): hit@1 75%, hit@5 **100%**
**Intercambio medido:** pentest puro pasó de 82% (antes de la noche) a ~75% de
hit@1; hit@5 se mantiene 95-96%. Es el precio de la amplitud. Las listas gigantes
(awesome-go, 3197 items → 322 fragmentos; osint con algún artefacto de TOC) son
las que más ruido meten y menos aporta a consultas por voz.
**Decisiones para la mañana:**
1. Licencias de las 12 awesome lists antes de publicar nada (ahora todas
publico=False = solo índice personal).
2. Si se prioriza precisión pentest: quitar awesome-go (y quizá awesome-python)
recuperaría 1-2 pts de hit@1 sin perder casi cobertura útil.
3. Qué del contenido nuevo va al índice público / gitea (hoy: nada subido).

188
verde/PLAN-ARRANQUE.md Normal file
View file

@ -0,0 +1,188 @@
# Carril verde: dejarlo listo para lanzar de un tirón
> **Ejecutado el 16 de agosto de 2026.** Este fichero se deja como estaba, que es
> el registro de lo que se planeó. Lo que pasó al ejecutarlo —incluidos dos topes
> que el plan no anticipaba, el suelo de 64k de Hermes y el directorio de trabajo
> en `$HOME`— está en `NOTAS.md`. Y una parte del plan quedó desmentida por los
> hechos: la escalera de contexto de `arrancar.sh` (24576 → 20480 → 16384) buscaba
> el número equivocado, porque por debajo de 64.000 Hermes no arranca.
## Contexto
El verde está instalado y configurado, pero **sin estrenar**. El primer arranque
quedó aplazado por dos motivos, uno técnico y uno de agenda:
1. **El prompt fijo de Hermes no cabe.** Medido con `hermes prompt-size` desde el
directorio real de arranque: 26.281 B de sistema + 55.978 B de esquemas de 19
herramientas = **82.259 B ≈ 20.500 tokens**, contra los **4.096** de `num_ctx`
de `qwen3.5:4b-jarvis`. Y un segundo tope escondido, `num_predict 300`, que el
naranja hereda del modelo *base* y que cortaría el JSON de una tool call por
la mitad. Ya existe `qwen3.5:4b-verde` para resolverlo, pero a 32k de contexto
da `cudaMalloc failed: out of memory` y falta encontrar el techo real.
2. **La gráfica está ocupada** por `entrena/dataset.py`, un trabajo tuyo de 7-10
horas que va por `100/2261` fragmentos.
**Objetivo de este plan:** que cuando el dataset termine, arrancar y probar el
verde sea **un comando**, sin re-derivar nada de lo anterior ni recordar en qué
orden iba. Todo lo que una máquina puede decidir sola, automatizado; lo que
necesita tu oído y tu criterio, escrito en una escalera con huecos para anotar.
## Paso 0 — Guardar esto en el repo (primero, y son dos segundos)
Este plan vive en `~/.claude/plans/`, fuera del repo. Lo primero es copiarlo a
**`verde/PLAN-ARRANQUE.md`** y enlazarlo desde `verde/README.md`, para que esté
donde está todo lo demás y sobreviva a cerrar la terminal.
Ya está a salvo en el repo, y no hace falta rehacerlo: `NOTAS.md` (17 KB, con el
bloqueo del contexto, la variante del modelo, las trampas del `config.yaml` y los
pasos para retomar), `README.md`, `config.yaml`, `env.ejemplo`,
`qwen3.5-4b-verde.Modelfile`, `instalar.sh`, `verde-launcher.sh`, `voz-verde.sh`,
`jarvis-verde.svg` y `boveda/README.md`. La configuración aplicada en
`~/.hermes/` y el modelo `qwen3.5:4b-verde` en ollama también persisten.
`model.default` sigue apuntando a `qwen3.5:4b-jarvis` **a propósito**: cambiarlo
al verde sin fijar antes `context_length` sería peor que dejarlo, porque Hermes
se creería los 262.144 que reporta `/api/show`. Lo cambia `arrancar.sh` cuando
haya medido el techo real.
## Lo que se construye
### 1. `verde/arrancar.sh` — el comando único
Hace en orden lo que hoy habría que hacer a mano, y **se planta en cuanto algo
no cuadra** en vez de seguir y dar un fallo confuso más adelante.
```bash
./verde/arrancar.sh # medir, configurar y dictaminar
./verde/arrancar.sh --solo-medir # sin tocar la configuracion
```
Pasos:
- **Guarda de entrada.** Si `entrena/dataset.py` sigue vivo, se para y lo dice:
competir con él por la tarjeta estropea las dos cosas. `--igual` lo salta.
También cierra los restos del naranja (`nucleo/jarvis.py`, `whisper-server`) y
hace `ollama stop qwen3.5:4b-jarvis`, comprobando que la VRAM baja a ~140 MiB.
- **Busca el techo de contexto, midiendo.** Recrea `qwen3.5:4b-verde` bajando
`num_ctx` hasta que cargue de verdad, y anota la VRAM de cada intento:
| Intento | `num_ctx` | Nota |
|---|---|---|
| 1 | 24576 | el objetivo |
| 2 | 20480 | |
| 3 | 16384 | **suelo**: por debajo no cabe el prompt fijo con holgura |
| 4 | 16384 con `num_gpu 24` | si ni así |
Si ninguno entra, se para y propone `OLLAMA_FLASH_ATTENTION=1` +
`OLLAMA_KV_CACHE_TYPE=q8_0` **sin aplicarlo**: es del servicio, no del modelo,
y afectaría también al naranja. Esa decisión es tuya.
- **Configura Hermes con el número que salió.** En `~/.hermes/config.yaml`:
`model.default` (línea 53) y `context_length` (línea 113, ya está escrita y
comentada — la propia documentación del fichero dice que es justo para *"a
local server with a custom num_ctx"*). Copia de seguridad antes, y verificación
**parseando** después, nunca leyendo:
```bash
python3 -c "import yaml;print(yaml.safe_load(open('$HOME/.hermes/config.yaml'))['model'])"
```
Dos trampas ya conocidas que el script evita: las **claves duplicadas** dentro
de `model:` (`provider` y `base_url` aparecen dos veces y en YAML gana la
última — por eso hoy casi sale el tráfico por OpenRouter), y que la línea 122
`# max_tokens: 8192` **está sin indentar**, así que descomentarla tal cual la
dejaría fuera del bloque.
- **La prueba decisiva**, antes de abrir nada: `curl` al endpoint con un `tools`
dentro. Separa dos preguntas que juntas se contestan mal — *¿sabe el modelo
emitir una llamada?* y *¿funciona Hermes?*. Dictamen explícito:
`tool_calls` válido → seguir; prosa o JSON roto → parar y decidir modelo mayor
por CPU (hay 49 GB y 8 núcleos) o recortar herramientas.
- **Resumen final** con lo medido y el comando siguiente.
### 2. `verde/pruebas/` — la suite, con tu convención
Misma forma que `pruebas/` del naranja: scripts numerados y un `ejecutar.sh` que
los pasa y resume. Ninguna abre el TUI.
| | Comprueba |
|---|---|
| `01_gpu.sh` | tarjeta libre, sin naranja ni dataset compitiendo |
| `02_modelo.sh` | `4b-verde` existe con sus parámetros **y `4b-jarvis` sigue intacto** |
| `03_config.sh` | parseado: modelo, `context_length`, `base_url` en loopback, **cero URLs de nube** |
| `04_tool_calling.sh` | **la decisiva**: el modelo emite `tool_calls` bien formado |
| `05_cabe.sh` | `hermes prompt-size` < `context_length`, con margen |
| `06_privacidad.sh` | 0 credenciales, 0 tokens de mensajería, nada fuera de loopback |
| `07_voz.sh` | `voz-verde.sh` genera WAV a 22050 Hz (hoy: 0,34 s para 3,2 s) |
La 04 y la 05 son las que hoy no se pudieron pasar. La 06 replica el espíritu de
tu `pruebas/06_privacidad.sh`.
### 3. `verde/ESCALERA.md` — lo que solo puedes juzgar tú
El guion del primer arranque interactivo, con huecos para anotar tiempo y
resultado. De trivial a real, y **se para en el primer escalón que falle**,
que es el que informa:
| # | Prueba | Qué contesta |
|---|---|---|
| 1 | "hola, ¿quién eres?" | el endpoint responde y el prompt cabe |
| 2 | "¿cuánto espacio libre queda?" | **una** herramienta, terminal |
| 3 | "lee verde/README.md y resúmelo en una línea" | herramienta de ficheros |
| 4 | "dime cuál de los .md de verde/ es el más largo" | **varias vueltas encadenadas** |
| 5 | algo que dispare una skill | el paso 6 del vídeo |
| 6 | que hable | debe sonar **igual que el naranja** |
| 7 | que escuche | **el último**: 839 MiB de pico contra la tarjeta casi llena |
El escalón 4 es el que separa un chatbot de un agente: encadenar llamadas y
razonar sobre el resultado de la anterior.
El 7 puede dar `CUDA error: out of memory`, y **no sería un fallo nuevo**: es el
mismo reparto que documenta tu README. Salidas por orden: bajar a `base`, fiarse
de `unload_after_idle_seconds: 300`, o probar la escucha con el modelo
descargado.
### 4. Notas al día
`verde/NOTAS.md` y `verde/README.md` ya recogen el bloqueo, la variante del
modelo y las trampas. Se actualizan para apuntar a `arrancar.sh` como la puerta
de entrada, y `NOTAS.md` recibe la tabla de VRAM por intento cuando se mida.
## Lo que sigue sin hacerse
- **No se toca `qwen3.5:4b-jarvis`**, ni `nucleo/`, `config/`, `voz/` o
`newelle/`. El naranja se cierra como proceso y se reabre igual.
- **No se mata `dataset.py`.** El script se planta si lo encuentra vivo.
- **No se ponen claves** ni se activa ningún canal de mensajería.
- **No se cambia nada del servicio de ollama** sin decírtelo: `OLLAMA_KV_CACHE_TYPE`
se propone, no se aplica.
## Verificación
Con `dataset.py` ya terminado:
```bash
./verde/arrancar.sh # mide, configura y dictamina
./verde/pruebas/ejecutar.sh # las 7, ninguna abre el TUI
./verde/verde-launcher.sh # el TUI en verde fosforo -> ESCALERA.md
```
Y devolver la máquina a su sitio al acabar: `ollama stop qwen3.5:4b-verde`, y
comprobar que el naranja vuelve a arrancar con su VRAM de siempre. Sigue
pendiente, para cuando pares de desarrollar:
```bash
cd pruebas && ./ejecutar.sh # las 104 del naranja
```
Hoy no se corrieron a propósito: abren la app y cargan el modelo.
## Y antes de comparar los dos carriles
El naranja tiene **121 acciones rápidas que se saltan el modelo**; Hermes no
tiene ese atajo y toda orden le pasa por el cerebro. **El verde va a medir más
lento y no será culpa del modelo.** Para comparar hay que usar solo frases que
en el naranja también acaben en el cerebro: están en
`config/jarvis-sin-accion.jsonl`.

129
verde/README.md Normal file
View file

@ -0,0 +1,129 @@
# JARVIS verde
Banco de pruebas con **Hermes Agent** debajo, para medir si un arnés agéntico
aporta algo sobre el núcleo. **100 % software libre**, y **local por
configuración pero no por construcción** — ver el aviso de aquí abajo.
> ⚠ **La frase "no sale nada de esta máquina" era falsa y se ha corregido.**
> El 16 de agosto, pidiéndole un cartel en ASCII, el agente encontró que
> `pyfiglet` no estaba instalado y **se fue a una API de terceros por su
> cuenta**. Nadie le dijo que podía. El cerebro, la voz, el oído y la memoria
> siguen siendo locales y comprobados; lo que no es local es **lo que el agente
> decida hacer con la terminal que le has dado**. Está contado entero en
> `NOTAS.md`.
> **Estrenado el 16 de agosto de 2026.** Arranca, contesta y llama herramientas.
> Lo que costó llegar aquí fueron **dos topes encadenados**, los dos anotados en
> `NOTAS.md`:
>
> 1. El prompt fijo de Hermes son **~20.500 tokens** y `qwen3.5:4b-jarvis` va con
> `num_ctx 4096`. Por eso existe `qwen3.5:4b-verde`.
> 2. Y Hermes **rechaza de plano** cualquier modelo por debajo de **64.000**
> tokens de contexto. Eso invalidó la primera medida (24576, lo máximo que
> cabía en la tarjeta) y obligó a bajar media red a la CPU.
>
> El precio de esa segunda concesión es el tiempo de respuesta. Está medido en
> `NOTAS.md` y es **el dato que decide si este carril vale la pena**.
Los tres se distinguen por color: **cian** el de Newelle (congelado), **naranja**
el núcleo (en desarrollo), **verde** este.
## De dónde sale esto
De un reel de 75 s que vende *"construye tu Jarvis en un fin de semana"* en
nueve pasos. Siete de los nueve ya estaban hechos aquí, y mejor. Los tres que
no: **skills que el agente se crea solo, canales de mensajería y memoria**. Eso
es lo que este carril viene a probar.
## El stack
| Capa | Pieza | Licencia | ¿Sale algo? |
|---|---|---|---|
| Arnés | Hermes Agent | MIT | no |
| Cerebro | Ollama · `qwen3.5:4b-verde` | Apache 2.0 | **no**`127.0.0.1:11434` |
| Oír | faster-whisper local | MIT | **no** |
| Hablar | Piper 1.2.0, vía `jarvis-piper.sh` | MIT | **no** |
| Memoria de conversación | SQLite FTS5 de Hermes | MIT | **no** |
| Memoria de conocimiento | `boveda/`, ficheros Markdown | — | **no** |
| Navegador | Playwright + Chromium | Apache 2.0 / BSD-3 | solo si navega |
**Cero claves, cero cuentas, cero euros.** Descartados por eso: ElevenLabs,
Honcho, Groq, Firecrawl, Nous Portal. Y **Edge TTS**, que es gratis pero es un
servicio de Microsoft y era la única pieza no libre del carril.
## Qué hay aquí
```
verde/
├── arrancar.sh LA PUERTA DE ENTRADA: mide, configura y dictamina
├── pruebas/ las 7 comprobaciones; ninguna abre el TUI
├── ESCALERA.md el guion del primer arranque a mano, con huecos que rellenar
├── PLAN-ARRANQUE.md el plan del que salió todo esto
├── instalar.sh baja el instalador oficial, lo enseña y lo corre sin arrancar nada
├── install-oficial.sh el instalador tal cual, para poder auditarlo y diffearlo
├── instalacion.log lo que hizo
├── config.yaml la configuración → se copia a ~/.hermes/config.yaml
├── env.ejemplo plantilla del .env, sin una sola clave
├── voz-verde.sh adaptador para que hable con la voz del naranja
├── verde-launcher.sh arranca el TUI en verde fósforo
├── jarvis-verde.svg el icono
├── boveda/ memoria de conocimiento — vacía a propósito
└── NOTAS.md lo medido y lo que queda abierto
```
Se entra siempre por el mismo sitio:
```bash
./arrancar.sh # deja el carril listo, y se planta si algo no cuadra
./pruebas/ejecutar.sh # 46 comprobaciones, ninguna abre el TUI
./verde-launcher.sh # el TUI → y se sigue ESCALERA.md
```
El código de Hermes vive en `~/.hermes/`, igual que el HUD de Newelle vive en
`~/.var/app/…`. Aquí solo está lo que se edita a mano.
## Uso
```bash
./verde-launcher.sh --status # qué hay y cuánta VRAM, sin arrancar nada
./verde-launcher.sh # el TUI, en verde fósforo
./verde-launcher.sh --stop # cerrar
```
`--status` es el que se puede correr hoy: no toca la gráfica.
## La voz: por qué suena igual que el naranja
Hermes trae un proveedor de TTS **por comando** que no está en su web pero sí en
su código (`tools/tts_tool.py`). Eso permite llamar al mismo
`config/jarvis-piper.sh` del naranja, con su binario Piper MIT, su `.onnx` y su
cadena de audio.
No es un detalle estético: si el verde hablara con una voz que Piper se descarga
por su cuenta, comparar la voz de los dos carriles no diría nada. Detalles y
trampas en `NOTAS.md` y en la cabecera de `voz-verde.sh`.
## El aviso que hay que leer antes de comparar
El naranja tiene **150 acciones rápidas que se saltan el modelo**. Hermes no
tiene ese atajo: allí toda orden pasa por el cerebro.
**El verde va a medir más lento, y no será culpa del modelo.** Para comparar de
verdad hay que usar solo frases que en el naranja también acaben en el cerebro.
Está desarrollado en `NOTAS.md`.
## Lo comprobado el primer día, y lo que falta
**Llama herramientas bien.** Era la duda que abría este carril —la guía de
Hermes marca *sin* tool calling todo lo de 9B para abajo— y salió a favor: pide
la herramienta cuando toca, acierta los argumentos, y **no** la llama cuando la
pregunta no la necesita.
**Es flojo con las rutas relativas.** Se le pidió `verde/README.md` y leyó el
`README.md` de la raíz. El detalle está en `NOTAS.md`, con la observación que
más pesa: cuando una herramienta le devuelve un **error**, se recupera y busca
otro camino; cuando le devuelve **un fichero que existe pero no es el pedido**,
no tiene forma de notarlo.
Falta lo que solo se juzga a mano: los escalones 5 (una skill), 6 (la voz dentro
de Hermes) y 7 (la escucha por micrófono) de `ESCALERA.md`.

304
verde/arrancar.sh Executable file
View file

@ -0,0 +1,304 @@
#!/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

72
verde/boveda/README.md Normal file
View file

@ -0,0 +1,72 @@
# La bóveda
Ficheros Markdown con lo que JARVIS tiene que saber de ti y de tu trabajo. Es
el **paso 4 del vídeo** —*teach it who you are*— y, aquí, también el sustituto
del paso 5.
**Está vacía a propósito.** Llenarla es decidir qué sabe JARVIS de ti, y eso no
lo hace un script por su cuenta.
## Por qué una carpeta y no Honcho
El vídeo vende Honcho (lo rotula "Hancho AI"). Honcho es AGPL-3.0 y se
autoaloja, pero su razonamiento exige clave de Gemini, Anthropic u OpenAI: te
daría el código libre y te seguiría sacando las conversaciones de la máquina.
Una bóveda es una **carpeta de ficheros Markdown**. Nada más. Y eso resulta ser
casi todo lo que hace falta:
- El agente la lee sin intermediarios, porque son ficheros.
- Se versiona con git, se busca con `grep`, se edita con cualquier cosa.
- No caduca, no cambia de precio y no cierra.
Obsidian y Logseq son **visores** por encima de estos mismos ficheros, no un
formato distinto. Si quieres uno: **Logseq es AGPL-3.0** y libre incluso para
uso comercial; Obsidian es propietario (gratis personal, 50 $/usuario/año si lo
usas para trabajar). Los dos leen esta carpeta tal cual. Ninguno hace falta.
## Lo que una bóveda NO hace
Conviene tenerlo claro antes de montarlo y no después de esperar otra cosa.
| | Bóveda + RAG | Honcho |
|---|---|---|
| Recuperar lo que escribiste | Sí | Sí |
| Inferir cómo eres, sin que lo escribas | **No** | Sí |
Una bóveda da **recuerdo**. Honcho vende **perfilado**: pasadas de razonamiento
sobre tus conversaciones para construir un modelo de ti. Si lo que quieres es
guardar contextos, esto sobra y gana. Si querías el modelo psicológico, una
carpeta no lo hace.
## Cómo llenarla
Un fichero por tema, nombre en minúsculas y sin acentos. Sugerencia de arranque:
```
quien-soy.md quién eres, a qué te dedicas, cómo trabajas
la-maquina.md hardware, sistema, lo que hay instalado y dónde
proyectos.md en qué andas, con qué prioridad
como-hablarme.md tono, idioma, qué odias que haga un asistente
```
Texto plano y frases cortas. Lo que un compañero nuevo necesitaría saber el
primer día, no una autobiografía.
## Si esto crece
Ya tienes la maquinaria de índice escrita en este mismo repo, y no hace falta
instalar nada más:
| Pieza | Qué hace |
|---|---|
| `config/indexar.py` | filtra COFRE: 76,6 MB de texto → 20,8 MB de conocimiento |
| `config/construir_indice.py` | construye el índice, ~20 000 fragmentos |
| `config/probar_rag.py` | lo consulta |
| `model2vec` | los embeddings, locales |
Para unos pocos ficheros no hace falta: Hermes los lee enteros como ficheros de
contexto y sale más barato que montar un índice. El índice empieza a compensar
cuando la bóveda no cabe en el contexto del modelo — con `qwen3.5:4b` eso pasa
antes de lo que parece, así que conviene mirarlo cuando esto pase de unas
decenas de ficheros.

134
verde/config.yaml Normal file
View file

@ -0,0 +1,134 @@
# JARVIS verde — lo que se cambio en la configuracion de Hermes Agent.
#
# ── OJO: esto NO se copia encima de ~/.hermes/config.yaml ──────────────────
#
# El instalador genera alli un fichero de 1890 lineas (121 activas) con toda
# su documentacion dentro. Machacarlo con estas 30 lineas funcionaria —lo que
# falte cae a los valores de hermes_cli/config_defaults.py— pero se perderia
# esa documentacion y el siguiente `hermes update` no tendria con que comparar.
#
# Asi que alli se editaron SOLO los bloques de abajo, en su sitio, y este
# fichero es el registro de que se toco y por que. La copia intacta del
# instalador quedo en ~/.hermes/config.yaml.recien-instalado.
#
# ── Y una trampa que casi cuela ────────────────────────────────────────────
#
# Ese config.yaml declara `provider` y `base_url` DOS VECES dentro de `model:`.
# En YAML gana la ultima. Poner Ollama en la primera dejaba el fichero con
# buena pinta y el trafico saliendo por OpenRouter. Hay que PARSEARLO, no
# leerlo:
#
# python3 -c "import yaml;print(yaml.safe_load(open('~/.hermes/config.yaml'))['model'])"
#
# NADA de lo de abajo saca datos de la maquina. Comprobado pieza a pieza: el
# modelo es Ollama en 127.0.0.1, la voz es Piper local, el oido es whisper
# local y la memoria es un SQLite en ~/.hermes. Cero claves, cero cuentas.
# ── El cerebro ─────────────────────────────────────────────────────────────
#
# CORREGIDO EL 16 DE AGOSTO. Aqui ponia :4b-jarvis, el mismo modelo del naranja,
# con el argumento de que Ollama sirve una sola copia a los dos y asi los dos
# carriles caben en la GPU. El argumento era bueno y la conclusion imposible:
#
# 1. El prompt fijo de Hermes son ~20.500 tokens y el naranja va con
# num_ctx 4096. No es que rinda mal: no entra la pregunta.
# 2. Hermes RECHAZA cualquier modelo por debajo de 64.000 tokens de contexto
# (MINIMUM_CONTEXT_LENGTH en agent/model_metadata.py). Tope duro.
#
# Asi que el verde necesita modelo propio, y a 65536 no caben las 32 capas en
# 4 GB: van 16, el resto a la CPU. Los dos carriles ya NO comparten copia
# cargada — abrir los dos a la vez no cabe. Se abre uno u otro.
#
# context_length aqui es obligatorio: sin el, Hermes se cree lo que dice
# /api/show, que son 262144 (el maximo del modelo) y no lo configurado.
model:
default: "qwen3.5:4b-verde"
provider: "custom"
base_url: "http://127.0.0.1:11434/v1"
context_length: 65536
# ── Desde donde mira ───────────────────────────────────────────────────────
#
# Este bloque se añadio el 16 de agosto y arregla un fallo que NO parecia de
# rutas. De fabrica `cwd: "."`, que la documentacion describe como "el
# directorio desde el que lanzas hermes". No lo es: el agente arrancaba
# siempre en $HOME por mucho `cd` que se hiciera delante, y `--in DIR` se
# ignora en el modo de una sola pregunta (-z sale por _run_and_exit_oneshot y
# nunca llega a cmd_chat, que es donde vive esa bandera).
#
# El sintoma: se pide "lee verde/README.md y resumelo" y contesta un resumen
# impecable del README de Gitleaks, que es el que hay en $HOME. Sin un
# solo error por ningun lado. Ver NOTAS.md.
#
# Con la raiz en $HOME ademas se ahoga: un search_files de "*.md" devolvia
# sobre todo site-packages de los venv.
terminal:
cwd: "$HOME/COFRE/CODERS/JARVIS"
# Ollama no pide clave. Si al arrancar Hermes insiste en una, la guia oficial
# dice que vale cualquier cosa ("no-key"). No se pone aqui para que el .env
# siga sin una sola credencial. Ver NOTAS.md.
# ── Telemetria ─────────────────────────────────────────────────────────────
# Hermes dice no recoger nada. Se apaga igualmente, que es gratis.
telemetry:
shared_metrics:
enabled: false
# ── El oido ────────────────────────────────────────────────────────────────
#
# faster-whisper local. `small` porque es lo que usa el naranja y comparar dos
# carriles con modelos de escucha distintos no mediria el harness, mediria el
# modelo.
#
# unload_after_idle_seconds es el ajuste que importa en esta maquina: whisper
# pide 839 MiB en el pico de una transcripcion y la tarjeta son 4096. Soltarlo
# a los 5 minutos de silencio devuelve ese hueco al modelo. Cuesta una recarga
# en la siguiente frase; a cambio, no compite con el cerebro estando parado.
stt:
enabled: true
provider: "local"
local:
model: "small"
language: "es"
unload_after_idle_seconds: 300
# ── La boca ────────────────────────────────────────────────────────────────
#
# NO se usa el proveedor `piper` de Hermes, y el motivo no es capricho.
#
# El `piper` de serie se descarga sus propias voces de OHF-Voice/piper1-gpl y
# no sabe bajar el tono. Sonaria distinto al naranja, y entonces comparar las
# voces de los dos carriles no diria nada de nada.
#
# En su lugar se usa el proveedor por COMANDO (`type: command`), que existe en
# el codigo (tools/tts_tool.py, bloque "Custom command providers") aunque no
# este en la web. Eso permite llamar al mismo config/jarvis-piper.sh que usa el
# naranja: mismo binario Piper 1.2.0 (MIT), mismo modelo .onnx, mismo trato de
# audio. El verde suena EXACTAMENTE igual que el naranja.
#
# El adaptador voz-verde.sh existe porque las interfaces no encajan: Hermes
# pasa un fichero con el texto y jarvis-piper.sh espera el texto como
# argumento. Ver la cabecera de voz-verde.sh.
tts:
provider: "jarvis-piper"
providers:
jarvis-piper:
type: command
command: "$HOME/COFRE/CODERS/JARVIS/verde/voz-verde.sh {output_path} {input_path}"
output_format: wav
# La referencia de fabrica, por si algun dia se quiere oir que da Hermes sin
# tocarlo. NO se activa: edge es un servicio de Microsoft y le manda el texto
# de cada respuesta. Seria la unica pieza no libre del carril.
# provider: "edge"
# ── Memoria ────────────────────────────────────────────────────────────────
#
# La que trae Hermes: SQLite con FTS5 en ~/.hermes. Local y sin instalar nada.
# Es el sustituto de Honcho (el "Hancho AI" del video), que es AGPL y se puede
# autoalojar, pero cuyo razonamiento exige clave de Gemini, Anthropic u OpenAI
# y sacaria las conversaciones de la maquina.
#
# La memoria de CONOCIMIENTO va aparte, en verde/boveda/, como ficheros de
# contexto. Ver boveda/README.md.

114
verde/docs/arquitectura.svg Normal file
View file

@ -0,0 +1,114 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1060 700" font-family="'DejaVu Sans',Verdana,sans-serif">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#08201c"/><stop offset="1" stop-color="#061713"/>
</linearGradient>
<marker id="ar" markerWidth="10" markerHeight="10" refX="7" refY="3" orient="auto">
<path d="M0,0 L7,3 L0,6 Z" fill="#3ddc84"/>
</marker>
</defs>
<rect width="1060" height="700" rx="16" fill="url(#bg)"/>
<rect x="1" y="1" width="1058" height="698" rx="16" fill="none" stroke="#123a33" stroke-width="2"/>
<!-- titulo -->
<text x="40" y="52" fill="#eef6f3" font-size="30" font-weight="bold">JARVIS · carril verde</text>
<text x="40" y="78" fill="#3ddc84" font-size="16" font-weight="bold">Hermes Agent</text>
<text x="182" y="78" fill="#7fa39b" font-size="15">— un arnés agéntico completo, 100 % local por configuración</text>
<!-- COLUMNA A: TU / entrada -->
<text x="40" y="128" fill="#7fa39b" font-size="13" font-weight="bold" letter-spacing="1"></text>
<g>
<rect x="40" y="140" width="200" height="86" rx="10" fill="#0d2823" stroke="#2dd4bf" stroke-width="1.5"/>
<text x="60" y="170" fill="#eef6f3" font-size="15" font-weight="bold">Voz</text>
<text x="60" y="192" fill="#9fc4bb" font-size="12.5">micro → whisper local</text>
<text x="60" y="210" fill="#6f938b" font-size="11.5">faster-whisper · en CPU</text>
</g>
<g>
<rect x="40" y="240" width="200" height="86" rx="10" fill="#0d2823" stroke="#2dd4bf" stroke-width="1.5"/>
<text x="60" y="270" fill="#eef6f3" font-size="15" font-weight="bold">Texto</text>
<text x="60" y="292" fill="#9fc4bb" font-size="12.5">panel web · TUI</text>
<text x="60" y="310" fill="#6f938b" font-size="11.5">chat · sesiones · logs</text>
</g>
<!-- COLUMNA B: HERMES -->
<rect x="300" y="120" width="440" height="470" rx="14" fill="#0b241f" stroke="#3ddc84" stroke-width="2"/>
<text x="320" y="150" fill="#3ddc84" font-size="18" font-weight="bold">HERMES AGENT</text>
<text x="320" y="171" fill="#7fa39b" font-size="12.5">el arnés: decide, encadena, recuerda</text>
<!-- herramientas -->
<text x="320" y="205" fill="#e8b923" font-size="13" font-weight="bold">23 herramientas</text>
<g fill="#0f2e28" stroke="#1d4b42" font-size="11.5">
<rect x="320" y="214" width="120" height="26" rx="6"/><text x="330" y="231" fill="#cfe6df">terminal</text>
<rect x="448" y="214" width="120" height="26" rx="6"/><text x="458" y="231" fill="#cfe6df">ficheros</text>
<rect x="576" y="214" width="140" height="26" rx="6"/><text x="586" y="231" fill="#cfe6df">navegador</text>
<rect x="320" y="246" width="120" height="26" rx="6"/><text x="330" y="263" fill="#cfe6df">código</text>
<rect x="448" y="246" width="120" height="26" rx="6"/><text x="458" y="263" fill="#cfe6df">web</text>
<rect x="576" y="246" width="140" height="26" rx="6"/><text x="586" y="263" fill="#cfe6df">delegación</text>
<rect x="320" y="278" width="120" height="26" rx="6"/><text x="330" y="295" fill="#cfe6df">cron</text>
<rect x="448" y="278" width="268" height="26" rx="6"/><text x="458" y="295" fill="#cfe6df">visión · TTS · computer-use…</text>
</g>
<!-- skills -->
<text x="320" y="336" fill="#e8b923" font-size="13" font-weight="bold">82 skills</text>
<text x="392" y="336" fill="#7fa39b" font-size="11.5">(chuletas que activa cuando tocan)</text>
<g font-size="11.5">
<rect x="320" y="345" width="396" height="26" rx="6" fill="#12352d" stroke="#3ddc84"/>
<text x="330" y="362" fill="#a9e9c9">★ buscar-en-apuntes → tu RAG (índice del naranja)</text>
<rect x="320" y="377" width="192" height="24" rx="6" fill="#0f2e28" stroke="#1d4b42"/>
<text x="330" y="393" fill="#cfe6df">ECC · security-review…</text>
<rect x="520" y="377" width="196" height="24" rx="6" fill="#0f2e28" stroke="#1d4b42"/>
<text x="530" y="393" fill="#cfe6df">github · research · devops…</text>
</g>
<!-- memoria / canales / webhooks -->
<g font-size="12">
<rect x="320" y="420" width="126" height="60" rx="8" fill="#0f2e28" stroke="#1d4b42"/>
<text x="333" y="444" fill="#e8b923" font-weight="bold">Memoria</text><text x="333" y="464" fill="#9fc4bb" font-size="11">aprende entre sesiones</text>
<rect x="454" y="420" width="126" height="60" rx="8" fill="#0f2e28" stroke="#1d4b42"/>
<text x="467" y="444" fill="#e8b923" font-weight="bold">Canales</text><text x="467" y="464" fill="#9fc4bb" font-size="11">telegram·discord…</text>
<rect x="588" y="420" width="128" height="60" rx="8" fill="#0f2e28" stroke="#1d4b42"/>
<text x="601" y="444" fill="#e8b923" font-weight="bold">Webhooks</text><text x="601" y="464" fill="#9fc4bb" font-size="11">+ cron · MCP</text>
</g>
<!-- aviso honesto -->
<rect x="320" y="500" width="396" height="72" rx="8" fill="#2a1410" stroke="#7a3b2c"/>
<text x="333" y="524" fill="#f0a58c" font-size="12.5" font-weight="bold">⚠ Local por configuración, no por construcción</text>
<text x="333" y="544" fill="#d9b3a6" font-size="11.5">el agente tiene terminal: puede salir a la red si lo decide.</text>
<text x="333" y="561" fill="#d9b3a6" font-size="11.5">cerebro, voz, oído y memoria sí son locales y comprobados.</text>
<!-- COLUMNA C: backends locales -->
<text x="800" y="128" fill="#7fa39b" font-size="13" font-weight="bold" letter-spacing="1">EN TU MÁQUINA</text>
<g>
<rect x="800" y="140" width="220" height="96" rx="10" fill="#0d2823" stroke="#2dd4bf" stroke-width="1.5"/>
<text x="820" y="170" fill="#eef6f3" font-size="15" font-weight="bold">Cerebro</text>
<text x="820" y="192" fill="#9fc4bb" font-size="12.5">Ollama · qwen3.5:4b-verde</text>
<text x="820" y="211" fill="#6f938b" font-size="11.5">64k contexto · 127.0.0.1</text>
<text x="820" y="228" fill="#6f938b" font-size="11.5">media red en GPU, media en CPU</text>
</g>
<g>
<rect x="800" y="252" width="220" height="86" rx="10" fill="#0d2823" stroke="#2dd4bf" stroke-width="1.5"/>
<text x="820" y="282" fill="#eef6f3" font-size="15" font-weight="bold">RAG</text>
<text x="820" y="304" fill="#9fc4bb" font-size="12.5">busca_cli → índice del naranja</text>
<text x="820" y="322" fill="#6f938b" font-size="11.5">~19.500 entradas · conocimiento</text>
</g>
<g>
<rect x="800" y="354" width="220" height="80" rx="10" fill="#0d2823" stroke="#2dd4bf" stroke-width="1.5"/>
<text x="820" y="384" fill="#eef6f3" font-size="15" font-weight="bold">Voz</text>
<text x="820" y="406" fill="#9fc4bb" font-size="12.5">Piper · davefx</text>
<text x="820" y="424" fill="#6f938b" font-size="11.5">misma voz que el naranja</text>
</g>
<!-- flechas -->
<g stroke="#3ddc84" stroke-width="2" fill="none" marker-end="url(#ar)">
<path d="M240,183 L296,240"/>
<path d="M240,283 L296,300"/>
<path d="M740,190 L796,188"/>
<path d="M740,300 L796,295"/>
<path d="M740,400 L796,394"/>
</g>
<!-- pie -->
<text x="530" y="628" fill="#3ddc84" font-size="13" font-weight="bold" text-anchor="middle">Todo en 127.0.0.1 · software libre (MIT · Apache-2.0)</text>
<text x="530" y="650" fill="#6f938b" font-size="12" text-anchor="middle">banco de pruebas: ¿un arnés de fábrica supera al núcleo naranja en skills, canales y memoria?</text>
<text x="530" y="676" fill="#4f6b64" font-size="11" text-anchor="middle">el precio: más lento en un 4B. la ganancia: capacidades que el núcleo aún no tiene.</text>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
verde/docs/panel-chat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

BIN
verde/docs/panel-skills.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

58
verde/env.ejemplo Normal file
View file

@ -0,0 +1,58 @@
# JARVIS verde — plantilla del ~/.hermes/.env
#
# ESTE FICHERO NO TIENE NI UNA CLAVE, y eso es a proposito, no un descuido.
#
# El carril verde es 100 % local: el cerebro es Ollama en 127.0.0.1, la voz es
# Piper, el oido es whisper y la memoria es un SQLite. Ninguna de esas cuatro
# cosas pide credencial. Lo que hay debajo esta comentado como DOCUMENTACION
# de que habilitaria cada clave y que se llevaria fuera de la maquina, para
# que la decision sea explicita el dia que se tome y no un descubrimiento.
#
# Ademas hay un motivo mecanico para dejarlo vacio: el instalador llama a
# maybe_start_gateway(), que arranca un servicio en segundo plano SI encuentra
# un token de mensajeria aqui. Sin tokens, sale antes de hacer nada. Es lo que
# garantiza que instalar no levante nada.
# ── El cerebro ─────────────────────────────────────────────────────────────
#
# Ollama no pide clave. Si Hermes insistiera en una para el proveedor
# "custom", la guia oficial dice que sirve cualquier cosa:
# OPENAI_API_KEY=no-key
# Modelos locales lentos: el limite por defecto se queda corto en CPU.
# En esta maquina el 4b va en GPU y no hace falta, pero si algun dia se prueba
# un modelo grande contra los 62 GB de RAM, esto es lo que evita el corte.
# HERMES_API_TIMEOUT=1800
# ── Voz ────────────────────────────────────────────────────────────────────
#
# ELEVENLABS_API_KEY hablar y oir por ElevenLabs.
# Saca el TEXTO de cada respuesta (TTS) y el AUDIO de tu
# microfono (STT). Desde 6 $/mes para uso comercial.
# Descartado: Piper hace lo mismo aqui y es MIT.
#
# GROQ_API_KEY oir por Groq. Tier gratuito y mas rapido que local,
# pero manda el audio crudo de tu microfono fuera.
# ── Memoria ────────────────────────────────────────────────────────────────
#
# HONCHO_API_KEY el "Hancho AI" del video, que en realidad es Honcho, de
# Plastic Labs. Perfilado del usuario por pasadas de
# razonamiento. 2 $/M tokens mas 0,001-0,50 $ por consulta.
# Es AGPL-3.0 y se puede autoalojar con docker compose y
# PostgreSQL+pgvector, PERO su razonamiento exige clave de
# Gemini, Anthropic u OpenAI: autoalojarlo daria el codigo
# libre y seguiria sacando las conversaciones.
# Descartado. La memoria es el SQLite de Hermes.
# ── Canales ────────────────────────────────────────────────────────────────
#
# CUIDADO: poner cualquiera de estos hace que el instalador OFREZCA levantar
# el gateway como servicio de systemd. Mientras el verde sea un banco de
# pruebas, se quedan fuera.
#
# TELEGRAM_BOT_TOKEN
# DISCORD_BOT_TOKEN
# SLACK_BOT_TOKEN
# SLACK_APP_TOKEN
# WHATSAPP_ENABLED

84
verde/escucha-verde.sh Executable file
View file

@ -0,0 +1,84 @@
#!/usr/bin/env bash
# El oido del verde: micro -> whisper LOCAL -> el agente. Nada sale de la maquina.
#
# ./escucha-verde.sh pulsar-para-hablar: graba, para con Enter, responde
# ./escucha-verde.sh --habla ademas dice la respuesta en voz alta (voz del verde)
# ./escucha-verde.sh --seguido no para: encadena turnos hasta Ctrl-C
#
# Por que existe: el TUI de Hermes no tiene microfono, y su STT nativo transcribe
# notas de voz de mensajeria (Telegram/Discord), que saldrian de la maquina. Este
# puente graba en local (pw-record/parec/arecord), transcribe con faster-whisper
# 'small' (escucha_transcribe.py, en CPU) y se lo pasa al agente con `hermes chat`.
#
# AVISO honesto: el 4B verde con 65k de contexto y media red en CPU tarda minutos
# por turno. Esto sirve para PROBAR que el verde escucha, no como asistente de
# diario: para eso esta el naranja. La palanca de velocidad (KV cache q8_0) toca
# el servicio de ollama y afectaria al naranja, asi que no se aplica aqui.
set -uo pipefail
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
VERDE=$RAIZ/verde
PY=$RAIZ/nucleo/venv/bin/python
[ -x "$PY" ] || PY=$(command -v python3)
HERMES="$HOME/.hermes/hermes-agent/venv/bin/python $HOME/.hermes/hermes-agent/hermes"
TMP=${TMPDIR:-/tmp}/jarvis-verde-escucha
mkdir -p "$TMP"
HABLA=0; SEGUIDO=0
for a in "$@"; do
case "$a" in
--habla) HABLA=1 ;;
--seguido) SEGUIDO=1 ;;
esac
done
verde() { printf '\033[32m%s\033[0m\n' "$1"; }
gris() { printf '\033[90m%s\033[0m\n' "$1"; }
# Devuelve el comando de grabacion a WAV 16 kHz mono, segun lo que haya.
grabador_a() {
local wav=$1
if command -v pw-record >/dev/null; then echo "pw-record --rate 16000 --channels 1 --format s16 $wav" ;;
elif command -v arecord >/dev/null; then echo "arecord -q -r 16000 -c 1 -f S16_LE -t wav $wav" ;;
elif command -v parec >/dev/null; then echo "parec --rate=16000 --channels=1 --format=s16le --file-format=wav $wav" ;;
else echo ""; fi
}
un_turno() {
local wav=$TMP/turno.wav; rm -f "$wav"
local cmd; cmd=$(grabador_a "$wav")
if [ -z "$cmd" ]; then
echo "No hay grabador (instala pipewire-utils, alsa-utils o pulseaudio-utils)"; return 1
fi
verde "🎙 Habla ahora... (Enter para parar)"
$cmd >/dev/null 2>&1 &
local pid=$!
read -r _ </dev/tty
kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null
[ -s "$wav" ] || { echo "no se grabo nada"; return 1; }
gris "…transcribiendo (local)…"
local texto; texto=$("$PY" "$VERDE/escucha_transcribe.py" "$wav" 2>/dev/null)
if [ -z "$texto" ]; then echo "no se entendio nada"; return 1; fi
printf '\033[36m> %s\033[0m\n' "$texto"
gris "…pensando (4B verde, puede tardar)…"
local resp; resp=$($HERMES chat --reasoning none -q "$texto" 2>/dev/null)
printf '\033[32m%s\033[0m\n' "$resp"
if [ "$HABLA" = 1 ] && [ -n "$resp" ]; then
local txt=$TMP/resp.txt wavr=$TMP/resp.wav
printf '%s' "$resp" > "$txt"
"$VERDE/voz-verde.sh" "$wavr" "$txt" >/dev/null 2>&1 && {
command -v pw-play >/dev/null && pw-play "$wavr" 2>/dev/null || \
command -v aplay >/dev/null && aplay -q "$wavr" 2>/dev/null || true
}
fi
}
if [ "$SEGUIDO" = 1 ]; then
echo "Modo seguido. Ctrl-C para salir."
while true; do un_turno || true; echo; done
else
un_turno
fi

49
verde/escucha_transcribe.py Executable file
View file

@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Transcribe un WAV a texto con faster-whisper local. Nada sale de la maquina.
escucha_transcribe.py fichero.wav
Es la mitad "oido" del puente de voz del verde: el TUI de Hermes no tiene micro,
asi que grabamos aparte (escucha-verde.sh) y transcribimos aqui, en local, con el
mismo faster-whisper 'small' en espanol que usa el STT de Hermes. En CPU a
proposito: el 4B ocupa la tarjeta y el margen de VRAM es de 223 MiB; whisper en
CPU tarda un par de segundos mas y no arriesga un out-of-memory.
"""
import os
import sys
MODELO = os.environ.get("VERDE_WHISPER_MODELO", "small")
IDIOMA = os.environ.get("VERDE_WHISPER_IDIOMA", "es")
_modelo = None
def carga():
global _modelo
if _modelo is None:
from faster_whisper import WhisperModel
# int8 en CPU: rapido y sin tocar la VRAM del modelo del agente.
_modelo = WhisperModel(MODELO, device="cpu", compute_type="int8")
return _modelo
def transcribe(ruta_wav):
m = carga()
segmentos, _ = m.transcribe(
ruta_wav, language=IDIOMA,
vad_filter=True, # el silencio no llega a whisper
vad_parameters={"min_silence_duration_ms": 500},
)
return " ".join(s.text.strip() for s in segmentos).strip()
def main():
if len(sys.argv) < 2 or not os.path.exists(sys.argv[1]):
print("uso: escucha_transcribe.py fichero.wav", file=sys.stderr)
return 2
print(transcribe(sys.argv[1]))
return 0
if __name__ == "__main__":
sys.exit(main())

79
verde/instalar.sh Executable file
View file

@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Instala Hermes Agent para el carril verde. NO arranca nada.
#
# ./instalar.sh instala
# ./instalar.sh --revisar solo baja el instalador y te lo enseña
#
# ── Por que un envoltorio y no el curl del video ────────────────────────────
#
# El video (y la web de Nous) dicen:
#
# curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash
#
# Eso ejecuta 3469 lineas que nadie ha leido, con sudo por medio, en una
# maquina que tiene COFRE dentro. Aqui se baja a fichero, se queda en el repo
# junto a su sha256, y se ejecuta desde disco. Cuesta diez segundos y a cambio
# el instalador es auditable y comparable entre versiones.
#
# ── Que se le pasa, y por que ──────────────────────────────────────────────
#
# --skip-setup El asistente interactivo pide proveedor y clave por /dev/tty.
# No se quiere: la configuracion se escribe a mano en
# config.yaml, que queda versionada y explicada. Ademas el
# asistente es lo unico del instalador que pediria credenciales.
#
# Chromium SI se instala (no se pasa --skip-browser): son ~400 MB pero deja el
# paso 7 del video listo sin reinstalar nada.
#
# ── Que NO hace, comprobado leyendo el codigo ──────────────────────────────
#
# main() llama a maybe_start_gateway(), que suena a que arranca algo. No lo
# hace: sale con `return 0` si no encuentra un token de mensajeria en
# $HERMES_HOME/.env. Como el .env se deja vacio, nunca llega a preguntar.
# Por eso env.ejemplo NO se copia con los tokens descomentados.
#
# El sudo solo se usa para paquetes de sistema de apt. En esta maquina gcc,
# python3-dev, libffi-dev y build-essential ya estaban, asi que lo unico que
# pidio fue Playwright, lo declino y degrado solo: instala Chromium en la cache
# del usuario e imprime el comando que un administrador puede correr luego.
set -uo pipefail
AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
URL=https://hermes-agent.nousresearch.com/install.sh
SCRIPT=$AQUI/install-oficial.sh
LOG=$AQUI/instalacion.log
echo "Bajando el instalador oficial..."
curl -fsSL "$URL" -o "$SCRIPT" || { echo "no se pudo bajar"; exit 1; }
chmod +x "$SCRIPT"
echo
echo " lineas: $(wc -l < "$SCRIPT")"
echo " sha256: $(sha256sum "$SCRIPT" | cut -d' ' -f1)"
echo
if [ "${1:-}" = "--revisar" ]; then
echo "Bajado en $SCRIPT. Lee y vuelve sin --revisar."
exit 0
fi
echo "Instalando (tarda: ~1,5 GB de PyPI y ~400 MB de Chromium)..."
bash "$SCRIPT" --skip-setup 2>&1 | tee "$LOG"
echo
echo "Instalado. NO se ha arrancado nada."
echo
echo "La configuracion NO se copia encima: el instalador deja un config.yaml de"
echo "1890 lineas con su documentacion dentro, y machacarlo la perderia. Se"
echo "editan a mano los bloques model, stt y tts. Cuales y por que, en:"
echo " $AQUI/config.yaml"
echo
echo "Y OJO: ese fichero declara provider y base_url dos veces dentro de"
echo "model:. En YAML gana la ultima. Comprueba PARSEANDO, no leyendo:"
echo " python3 -c \"import yaml;print(yaml.safe_load(open('\$HOME/.hermes/config.yaml'))['model'])\""
echo
echo "El .env del instalador ya viene sin claves. Que habilitaria cada una:"
echo " $AQUI/env.ejemplo"
echo
echo "Comprobacion en frio, no arranca nada:"
echo " $AQUI/verde-launcher.sh --status"

57
verde/jarvis-verde.svg Normal file
View file

@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<!-- El mismo reactor que jarvis-naranja.svg, en verde fosforo.
La paleta no se inventa: es la del monitor de fosforo verde, la misma
que usa la terminal del lanzador, para que el icono y la pantalla sean
el mismo color.
Cian = JARVIS sobre Newelle, congelado.
Naranja = el nucleo independiente, en desarrollo.
Verde = el banco de pruebas con Hermes. Asi se sabe cual esta abierto. -->
<defs>
<radialGradient id="core" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#e6ffee"/>
<stop offset="45%" stop-color="#3bff7a"/>
<stop offset="100%" stop-color="#1f8a3c"/>
</radialGradient>
<radialGradient id="halo" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#00ff41" stop-opacity=".55"/>
<stop offset="70%" stop-color="#1f8a3c" stop-opacity=".12"/>
<stop offset="100%" stop-color="#04120a" stop-opacity="0"/>
</radialGradient>
<linearGradient id="ring" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#c7f5d6"/>
<stop offset="100%" stop-color="#5a9270"/>
</linearGradient>
</defs>
<circle cx="128" cy="128" r="124" fill="#04120a"/>
<circle cx="128" cy="128" r="120" fill="url(#halo)"/>
<!-- carcasa exterior -->
<circle cx="128" cy="128" r="106" fill="none" stroke="url(#ring)" stroke-width="9"/>
<circle cx="128" cy="128" r="94" fill="none" stroke="#10381f" stroke-width="7"/>
<!-- bobinas -->
<g stroke="#8fefb0" stroke-width="7" stroke-linecap="round" opacity=".9">
<line x1="128" y1="42" x2="128" y2="70"/>
<line x1="128" y1="186" x2="128" y2="214"/>
<line x1="42" y1="128" x2="70" y2="128"/>
<line x1="186" y1="128" x2="214" y2="128"/>
<line x1="67" y1="67" x2="87" y2="87"/>
<line x1="169" y1="169" x2="189" y2="189"/>
<line x1="189" y1="67" x2="169" y2="87"/>
<line x1="87" y1="169" x2="67" y2="189"/>
</g>
<!-- anillo interior -->
<circle cx="128" cy="128" r="70" fill="none" stroke="#1f5c33" stroke-width="10"/>
<circle cx="128" cy="128" r="58" fill="none" stroke="#00ff41" stroke-width="4" opacity=".85"/>
<!-- triangulo del nucleo -->
<path d="M128 84 L166 150 L90 150 Z" fill="none" stroke="#b0ffca" stroke-width="6"
stroke-linejoin="round" opacity=".95"/>
<!-- nucleo -->
<circle cx="128" cy="128" r="34" fill="url(#core)"/>
<circle cx="128" cy="128" r="16" fill="#ffffff" opacity=".92"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

58
verde/panel-verde.sh Normal file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# El frontal del verde: el dashboard web de Hermes en una ventana propia, como el
# panel del naranja (chromium --app). Local, en 127.0.0.1, sin cuenta en la nube.
#
# ./panel-verde.sh arranca el dashboard y lo abre en su ventana
# ./panel-verde.sh --solo-servir solo levanta el servidor y dice la URL
# ./panel-verde.sh --stop para el dashboard
#
# El dashboard trae chat, config y sesiones. Es el equivalente al panel del
# naranja: una página local abierta con `chromium --app`, sin barra ni pestañas,
# con su propio icono en la barra de tareas. NO se registra en Nous Portal (eso
# ataría el panel a la nube); se usa tal cual en loopback.
set -uo pipefail
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
HERMES="$HOME/.hermes/hermes-agent/venv/bin/python $HOME/.hermes/hermes-agent/hermes"
PUERTO=${VERDE_PANEL_PUERTO:-9119}
URL="http://127.0.0.1:$PUERTO/"
PERFIL=${XDG_CACHE_HOME:-$HOME/.cache}/jarvis-verde-panel
CLASE=jarvis-verde
if [ "${1:-}" = "--stop" ]; then
$HERMES dashboard --port "$PUERTO" --stop 2>/dev/null
echo "dashboard del verde parado"
exit 0
fi
# Arrancar el dashboard si no responde ya.
if [ "$(curl -s -o /dev/null -w '%{http_code}' "${URL}healthz" 2>/dev/null)" != "200" ]; then
echo "arrancando el dashboard en $URL ..."
nohup $HERMES dashboard --port "$PUERTO" --host 127.0.0.1 --no-open \
>"${TMPDIR:-/tmp}/jarvis-verde-dashboard.log" 2>&1 &
for _ in $(seq 1 30); do
sleep 2
[ "$(curl -s -o /dev/null -w '%{http_code}' "${URL}healthz" 2>/dev/null)" = "200" ] && break
done
fi
if [ "$(curl -s -o /dev/null -w '%{http_code}' "${URL}healthz" 2>/dev/null)" != "200" ]; then
echo "el dashboard no levantó; mira ${TMPDIR:-/tmp}/jarvis-verde-dashboard.log" >&2
exit 1
fi
echo "dashboard listo en $URL"
[ "${1:-}" = "--solo-servir" ] && exit 0
# Abrirlo como aplicación, igual que el panel del naranja.
for navegador in chromium chromium-browser google-chrome brave-browser; do
command -v "$navegador" >/dev/null || continue
exec "$navegador" --app="$URL" --user-data-dir="$PERFIL" --class="$CLASE" \
--window-size=1500,940 --no-first-run --no-default-browser-check \
--disable-features=TranslateUI >/dev/null 2>&1
done
if command -v firefox >/dev/null; then
echo "sin chromium: se abre en Firefox, con su barra" >&2
exec firefox --new-window "$URL" >/dev/null 2>&1
fi
echo "no hay navegador; abre a mano: $URL" >&2

74
verde/pruebas/01_gpu.sh Executable file
View file

@ -0,0 +1,74 @@
#!/usr/bin/env bash
# La tarjeta, y quien la esta usando.
#
# Va la primera porque es la que explica los fallos de las demas. En 4 GB no
# caben el verde y el naranja a la vez: si el naranja esta cargado, el verde
# dara "cudaMalloc failed" y parecera que el modelo esta roto cuando lo que
# pasa es que no hay sitio.
#
# El caso que mas ha costado en este proyecto es whisper-server: lo lanza
# flatpak-spawn --host, vive FUERA del sandbox y no se entera de que la app
# cerro. Cada reinicio deja uno agarrado a ~850 MiB. Dos o tres y ya no cabe
# nada. Por eso se cuentan, no solo se mira si hay alguno.
cd "$(dirname "$0")" && . ./comun.sh
titulo "La tarjeta"
USADA=$(vram)
if [ -z "$USADA" ]; then
echo " $(rojo FALLO) no hay nvidia-smi: esto necesita la grafica"
exit 1
fi
TOTAL=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader | tr -d ' MiB')
nota "$USADA MiB usados de $TOTAL"
nvidia-smi --query-compute-apps=used_memory,process_name --format=csv,noheader 2>/dev/null \
| sed 's/^/ /'
titulo "Nadie compitiendo por ella"
# pgrep -c YA imprime 0 cuando no encuentra nada, y ademas devuelve 1. Un
# "|| echo 0" detras parece prudente y lo que hace es imprimir DOS ceros, que
# no son iguales a "0" y hacen fallar una prueba que en realidad pasa.
cuantos() { local n; n=$(pgrep -cf "$1" 2>/dev/null); echo "${n:-0}"; }
cuantos_x() { local n; n=$(pgrep -cx "$1" 2>/dev/null); echo "${n:-0}"; }
# dataset.py genera pares con el NARANJA: mientras corra, ollama recarga el
# modelo naranja en segundos aunque se le haga stop. No es un fallo de ollama.
comprueba "dataset.py no esta corriendo" "$(cuantos 'entrena/dataset.py')" "0"
comprueba "el nucleo naranja no esta corriendo" "$(cuantos 'nucleo/jarvis.py')" "0"
comprueba "sin whisper-server huerfanos" "$(cuantos_x whisper-server)" "0"
CARGADO=$(curl -fsS --max-time 5 "$OLLAMA/api/ps" 2>/dev/null | python3 -c "
import json, sys
try: m = json.load(sys.stdin).get('models', [])
except Exception: m = []
print(','.join(x['name'] for x in m) or 'ninguno')
" 2>/dev/null)
nota "en ollama ahora: $CARGADO"
case "$CARGADO" in
*"$NARANJA"*)
echo " $(rojo FALLO) el naranja esta cargado: no cabe el verde encima"
echo " $(gris "ollama stop $NARANJA")"
_fallo=$((_fallo + 1)) ;;
*)
echo " $(verde OK) el naranja no ocupa la tarjeta"
_ok=$((_ok + 1)) ;;
esac
# Sitio para el verde: 2795 MiB medidos el 16 ago a num_ctx 65536 con 16 de 32
# capas en GPU. La cifra anterior (3301) era la del modelo a 24576 con las 32
# capas dentro, que Hermes rechaza por su suelo de 64k.
#
# Si el modelo ya esta dentro, la comprobacion no aplica: el hueco ya se uso.
case "$CARGADO" in
*"$MODELO"*) nota "el verde ya esta cargado, no hace falta hueco" ;;
*) mayor_que "queda hueco para el verde (MiB libres)" \
"$((TOTAL - USADA))" 2800 ;;
esac
resumen

62
verde/pruebas/02_modelo.sh Executable file
View file

@ -0,0 +1,62 @@
#!/usr/bin/env bash
# Los dos modelos: que el verde tenga lo suyo y que el naranja siga intacto.
#
# La segunda mitad importa tanto como la primera. El verde es un banco de
# pruebas y el naranja es lo que el usuario usa a diario: si tocar uno estropea
# el otro, el experimento sale caro. Los cuatro parametros del naranja
# (num_ctx 4096, num_gpu 24, num_predict 300, temperature 0.4) estan medidos y
# elegidos —el num_gpu 24 deja 1540 MiB para whisper— asi que se comprueban uno
# a uno y no "existe el modelo".
#
# Se pregunta a ollama, no al .Modelfile del repo: arrancar.sh RECREA el verde
# mientras busca el techo de contexto, asi que el fichero es la intencion y
# ollama es lo que se va a cargar de verdad.
cd "$(dirname "$0")" && . ./comun.sh
titulo "El verde"
if ! ollama list 2>/dev/null | grep -q "^$MODELO"; then
echo " $(rojo FALLO) no existe $MODELO"
echo " $(gris "creado por: verde/arrancar.sh, o a mano con qwen3.5-4b-verde.Modelfile")"
exit 1
fi
echo " $(verde OK) $MODELO existe"; _ok=$((_ok + 1))
CTX=$(parametro "$MODELO" num_ctx)
nota "num_ctx $CTX"
# El suelo NO lo pone la tarjeta ni el tamaño del prompt: lo pone Hermes, que
# rechaza de plano cualquier modelo por debajo de 64.000 tokens
# (agent/model_metadata.py:405, comprobado en agent/agent_init.py:2656).
#
# Esta comprobacion existe porque el carril se dimensiono entero para 24576
# —lo maximo que cabia en la GPU con las 32 capas dentro— y ese numero, que
# era correcto en todo lo demas, es rechazado antes de la primera frase. En
# frio no se veia: solo salta al abrir Hermes.
mayor_que "num_ctx llega al suelo de 64k que exige Hermes" "$CTX" 63999
# 300 tokens —lo que hereda el naranja del modelo base— cortan el JSON de una
# tool call por la mitad. Es el tope escondido que casi hace concluir que el 4B
# no sabe llamar herramientas.
mayor_que "num_predict da para una tool call entera" \
"$(parametro "$MODELO" num_predict)" 1024
# Emitir JSON no es escribir prosa: temperatura baja.
menor_que "temperature baja para emitir JSON" \
"$(parametro "$MODELO" temperature)" 0.5
titulo "El naranja, sin tocar"
comprueba "num_ctx del naranja" "$(parametro "$NARANJA" num_ctx)" "4096"
comprueba "num_gpu del naranja" "$(parametro "$NARANJA" num_gpu)" "24"
comprueba "num_predict del naranja" "$(parametro "$NARANJA" num_predict)" "300"
comprueba "temperature del naranja" "$(parametro "$NARANJA" temperature)" "0.4"
# Los dos salen del mismo blob de pesos: si dejaran de compartirlo, alguien
# habria bajado un modelo distinto y las comparaciones no valdrian.
BLOB_V=$(ollama show --modelfile "$MODELO" 2>/dev/null | awk '$1=="FROM"{print $2; exit}')
BLOB_N=$(ollama show --modelfile "$NARANJA" 2>/dev/null | awk '$1=="FROM"{print $2; exit}')
comprueba "los dos carriles usan los mismos pesos" "$BLOB_V" "$BLOB_N"
resumen

136
verde/pruebas/03_config.sh Executable file
View file

@ -0,0 +1,136 @@
#!/usr/bin/env bash
# La configuracion de Hermes, PARSEADA.
#
# Esta prueba existe por un fallo concreto que casi cuela. El config.yaml que
# genera el instalador declara `provider` y `base_url` DOS VECES dentro del
# bloque model:, una arriba y otra ~45 lineas mas abajo. En YAML gana la ultima.
# Al configurar Ollama en las de arriba el fichero PARECIA correcto leyendolo, y
# al parsearlo salia provider=auto, base_url=https://openrouter.ai/api/v1: el
# carril habria salido por OpenRouter creyendo nosotros que iba a localhost.
#
# Con 1890 lineas y 121 activas, leer el fichero no demuestra nada. Aqui todo se
# comprueba con yaml.safe_load, que es lo mismo que hace Hermes al arrancar.
cd "$(dirname "$0")" && . ./comun.sh
titulo "El cerebro"
afirma "existe $CONFIG" test -f "$CONFIG"
comprueba "el modelo por defecto es el verde" "$(config_model default)" "$MODELO"
# Sin esto Hermes se cree lo que dice /api/show, que son 262144 —el maximo del
# modelo— y no los 24576 configurados. El sintoma serian respuestas truncadas y
# llamadas rotas, y la conclusion natural ("el 4B no vale") seria falsa.
CTX_YAML=$(config_model context_length)
CTX_REAL=$(parametro "$MODELO" num_ctx)
comprueba "Hermes sabe el contexto REAL, no el que reporta /api/show" \
"$CTX_YAML" "$CTX_REAL"
# Los dos numeros tienen que ser iguales Y llegar al suelo de 64k. Poner aqui
# 65536 con el modelo a 24576 pasaria el arranque y romperia en marcha: Hermes
# no comprimiria hasta acercarse a 64k y ollama iria tirando los tokens mas
# viejos, que son el prompt de sistema y los esquemas de las herramientas. El
# agente dejaria de llamar herramientas a mitad de sesion, sin un solo error.
mayor_que "y ese contexto llega al suelo de Hermes" "${CTX_YAML:-0}" 63999
BASE=$(config_model base_url)
case "$BASE" in
http://127.0.0.1:*|http://localhost:*)
echo " $(verde OK) el cerebro apunta a esta maquina ($BASE)"; _ok=$((_ok + 1)) ;;
*)
echo " $(rojo FALLO) base_url NO es local: $BASE"; _fallo=$((_fallo + 1)) ;;
esac
titulo "Cero nube"
# Se recorre el YAML entero, no solo el bloque model:. Cualquier http(s) que no
# apunte al propio equipo es una salida potencial y tiene que estar justificada.
FUERA=$(python3 - "$CONFIG" <<'PY'
import re, sys, yaml
LOCAL = re.compile(r'^https?://(127\.0\.0\.1|localhost|0\.0\.0\.0|\[::1\])')
URL = re.compile(r'https?://[^\s"\']+')
fuera = []
def anda(nodo, ruta=""):
if isinstance(nodo, dict):
for k, v in nodo.items():
anda(v, f"{ruta}.{k}" if ruta else str(k))
elif isinstance(nodo, list):
for i, v in enumerate(nodo):
anda(v, f"{ruta}[{i}]")
elif isinstance(nodo, str):
for u in URL.findall(nodo):
if not LOCAL.match(u):
fuera.append(f"{ruta} = {u}")
anda(yaml.safe_load(open(sys.argv[1])))
for f in fuera:
print(f)
PY
)
if [ -z "$FUERA" ]; then
echo " $(verde OK) ninguna URL de nube activa en el config"; _ok=$((_ok + 1))
else
echo " $(rojo FALLO) hay URLs que salen de esta maquina:"
printf '%s\n' "$FUERA" | sed 's/^/ /'
_fallo=$((_fallo + 1))
fi
titulo "Desde donde mira el agente"
# `cwd: "."` de fabrica NO es "el directorio desde el que lanzas hermes", por
# mucho que lo diga su documentacion: el agente arranca en $HOME. Con la raiz
# ahi, "lee verde/README.md" acaba leyendo $HOME/README.md —el de
# Gitleaks— y resumiendolo perfectamente. Ningun error, respuesta inutil.
CWD_AGENTE=$(python3 -c "
import yaml
print(yaml.safe_load(open('$CONFIG')).get('terminal', {}).get('cwd', ''))" 2>/dev/null)
comprueba "el agente arranca en la raiz del proyecto" "$CWD_AGENTE" "$RAIZ"
titulo "El lanzador entra donde debe"
# Un `cd` antes de llamar a hermes NO basta: Hermes restaura el directorio que
# tenga apuntado en la sesion. El sintoma no parece de rutas — se le pide "lee
# verde/README.md y resumelo" y devuelve un resumen impecable de OTRO fichero,
# sin un solo error — asi que se comprueba estaticamente.
if grep -q -- '--in "\$RAIZ"' "$VERDE/verde-launcher.sh"; then
echo " $(verde OK) verde-launcher.sh pasa --in y no confia en el cd"; _ok=$((_ok + 1))
else
echo " $(rojo FALLO) verde-launcher.sh no pasa --in: Hermes abrira en otro sitio"
_fallo=$((_fallo + 1))
fi
titulo "La voz apunta a la del naranja"
# Sin esto la comparacion de voz entre carriles no diria nada: Hermes se
# descargaria su propia voz de Piper (la GPL de OHF-Voice) en vez de usar el
# binario MIT y el .onnx que ya tiene el naranja.
ORDEN=$(python3 -c "
import yaml
t = yaml.safe_load(open('$CONFIG')).get('tts', {})
p = t.get('providers', {}).get(t.get('provider', ''), {})
print(p.get('command', ''))
" 2>/dev/null)
case "$ORDEN" in
*/verde/voz-verde.sh*)
echo " $(verde OK) TTS por comando -> voz-verde.sh"; _ok=$((_ok + 1)) ;;
*)
echo " $(rojo FALLO) el TTS no apunta al adaptador: $ORDEN"; _fallo=$((_fallo + 1)) ;;
esac
stt_local() { # $1 = clave dentro de stt.local
python3 -c "
import yaml
print(yaml.safe_load(open('$CONFIG')).get('stt', {}).get('local', {}).get('$1', ''))
" 2>/dev/null | tr -d '\n'
}
comprueba "la escucha es la local de Hermes" "$(stt_local model)" "small"
# 839 MiB de pico contra una tarjeta con 3301 MiB de modelo dentro: whisper
# tiene que soltar la VRAM cuando calla, o la siguiente frase da OOM.
menor_que "whisper suelta la VRAM tras callar (s)" "$(stt_local unload_after_idle_seconds)" 601
resumen

105
verde/pruebas/04_tool_calling.sh Executable file
View file

@ -0,0 +1,105 @@
#!/usr/bin/env bash
# LA DECISIVA: ¿emite el 4B llamadas a herramienta bien formadas?
#
# Es la duda que dejo abierta todo el carril. La guia de Hermes dice que para
# trabajo agentico completo hace falta gemma4:31b y marca SIN tool calling todo
# lo de 9B para abajo. qwen3.5 no sale en su tabla y si lo hace —el commit
# "el cerebro con tool calling nativo" lo demuestra— pero lo demuestra en el
# arnes propio, que inyecta muchas menos definiciones por vuelta.
#
# Se pregunta al ENDPOINT directamente, sin Hermes de por medio, a proposito.
# Junta, estas dos preguntas se contestan mal:
#
# ¿sabe el modelo emitir una llamada? <- esto mide esta prueba
# ¿funciona Hermes? <- eso es la ESCALERA
#
# Si esto falla, no hay nada que depurar en Hermes. Si esto pasa y el TUI se
# atraganta, el fallo esta en el arnes y se busca alli.
#
# Tres casos, no uno: uno que PIDE herramienta, uno que NO la necesita, y uno de
# dos argumentos. Un modelo que llama siempre es tan inutil como uno que no
# llama nunca, y con una sola pregunta no se distingue.
cd "$(dirname "$0")" && . ./comun.sh
titulo "Llamadas a herramienta"
HERRAMIENTAS='[
{"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"]}}},
{"type":"function","function":{
"name":"escribe_fichero",
"description":"Escribe texto en un fichero del disco",
"parameters":{"type":"object","properties":{
"ruta":{"type":"string"},"contenido":{"type":"string"}},"required":["ruta","contenido"]}}}
]'
# pregunta <descripcion> <texto> <herramienta esperada, o "" si no debe llamar>
pregunta() {
local desc=$1 texto=$2 espera=$3 cuerpo r
cuerpo=$(python3 -c "
import json, sys
print(json.dumps({
'model': '$MODELO',
'messages': [{'role': 'user', 'content': sys.argv[1]}],
'tools': json.loads(sys.argv[2]),
}))" "$texto" "$HERRAMIENTAS")
r=$(curl -s --max-time 300 "$OLLAMA/v1/chat/completions" \
-H 'Content-Type: application/json' -d "$cuerpo" 2>/dev/null)
local salida
salida=$(printf '%s' "$r" | python3 -c "
import json, sys
try:
d = json.load(sys.stdin)
except Exception:
print('ILEGIBLE|'); raise SystemExit
if 'error' in d:
print('ERROR|' + str(d['error'])[:120]); raise SystemExit
m = d['choices'][0]['message']
tc = m.get('tool_calls') or []
if not tc:
print('PROSA|' + (m.get('content') or '')[:90].replace('\n', ' ')); raise SystemExit
f = tc[0]['function']
try:
args = json.loads(f['arguments'])
except Exception:
print('ROTO|' + str(f['arguments'])[:90]); raise SystemExit
print(f\"{f['name']}|\" + json.dumps(args, ensure_ascii=False)[:90])
")
local nombre=${salida%%|*} detalle=${salida#*|}
if [ -z "$espera" ]; then
# aqui lo correcto es NO llamar
if [ "$nombre" = PROSA ]; then
printf ' %s %s\n' "$(verde OK)" "$desc"
printf ' %s\n' "$(gris "contesto: $detalle")"
_ok=$((_ok + 1))
else
printf ' %s %s\n' "$(rojo FALLO)" "$desc"
printf ' llamo a %s cuando no hacia falta: %s\n' "$nombre" "$detalle"
_fallo=$((_fallo + 1))
fi
return
fi
if [ "$nombre" = "$espera" ]; then
printf ' %s %s\n' "$(verde OK)" "$desc"
printf ' %s\n' "$(gris "$nombre $detalle")"
_ok=$((_ok + 1))
else
printf ' %s %s\n' "$(rojo FALLO)" "$desc"
printf ' esperado: %s\n obtenido: %s %s\n' "$espera" "$nombre" "$detalle"
_fallo=$((_fallo + 1))
fi
}
pregunta "llama cuando hace falta" "¿Cuánto espacio libre queda en el disco?" "ejecuta_comando"
pregunta "acierta la herramienta y sus DOS argumentos" \
"Guarda la palabra hola en el fichero /tmp/prueba-verde.txt" "escribe_fichero"
pregunta "NO llama cuando no hace falta" "¿Cuánto son dos más dos? Contesta solo con el número." ""
resumen

92
verde/pruebas/05_cabe.sh Executable file
View file

@ -0,0 +1,92 @@
#!/usr/bin/env bash
# ¿Cabe la pregunta? El bloqueo que paro el primer arranque.
#
# No es que qwen3.5:4b rindiera mal: es que el prompt fijo de Hermes —26 KB de
# sistema + 56 KB de esquemas de 19 herramientas— son ~20.500 tokens contra los
# 4.096 de num_ctx del naranja. Cinco veces por encima. Con eso, la pregunta del
# usuario ni entra.
#
# DOS TRAMPAS que esta prueba evita, las dos costaron tiempo:
#
# 1. El directorio desde el que se lanza CAMBIA el prompt de sistema. Desde
# ~/.hermes/hermes-agent son 55.261 B en vez de 26.281: se cuela el AGENTS.md
# de 81 KB del propio repo de Hermes como fichero de contexto. Por eso aqui
# se hace cd a la raiz del proyecto, que es lo que hace verde-launcher.sh.
#
# 2. El contexto que se compara es el del MODELFILE, no el de /api/show. Ollama
# reporta ahi 262144, el maximo del modelo, no lo configurado.
cd "$(dirname "$0")" && . ./comun.sh
titulo "El prompt fijo, medido"
SALIDA=$TMP/prompt-size.txt
( cd "$RAIZ" && timeout 180 hermes prompt-size ) > "$SALIDA" 2>&1
if [ $? -ne 0 ] || ! grep -q 'System prompt total' "$SALIDA"; then
echo " $(rojo FALLO) 'hermes prompt-size' no dio una medida"
sed 's/^/ /' "$SALIDA" | head -5
exit 1
fi
# Se parsea con python y no con awk por el awk de Debian: mawk no admite el
# match() de tres argumentos de gawk y devolveria vacio en silencio, que en una
# resta da un numero plausible y equivocado.
#
# " System prompt total : 26,278 B (25.7 KB, 26,148 chars)"
# " Tool schemas : 55,978 B (54.7 KB, 19 tools)"
leer=$(python3 - "$SALIDA" <<'PY'
import re, sys
texto = open(sys.argv[1]).read()
def bytes_de(etiqueta):
m = re.search(re.escape(etiqueta) + r'\s*:\s*([\d,]+)\s*B', texto)
return int(m.group(1).replace(',', '')) if m else 0
herr = re.search(r'([\d,]+)\s+tools', texto)
print(bytes_de('System prompt total'), bytes_de('Tool schemas'),
herr.group(1).replace(',', '') if herr else '?')
PY
)
read -r SISTEMA ESQUEMAS HERRAM <<<"$leer"
if [ "${SISTEMA:-0}" -eq 0 ] || [ "${ESQUEMAS:-0}" -eq 0 ]; then
echo " $(rojo FALLO) no pude leer las cifras de 'hermes prompt-size'"
sed 's/^/ /' "$SALIDA" | head -8
exit 1
fi
FIJO=$((SISTEMA + ESQUEMAS))
# ~4 B por token en castellano y JSON. Es la misma regla con la que se dimensiono
# el modelo verde; sirve para decidir, no para presumir de precision.
TOKENS=$((FIJO / 4))
nota "sistema $SISTEMA B"
nota "esquemas $ESQUEMAS B ($HERRAM herramientas)"
nota "fijo $FIJO B ~ $TOKENS tokens"
titulo "Contra el contexto configurado"
CTX=$(parametro "$MODELO" num_ctx)
nota "num_ctx del Modelfile: $CTX"
menor_que "el prompt fijo cabe en el contexto" "$TOKENS" "$CTX"
# Que quepa no basta: si no sobra sitio, Hermes comprime el historial en cuanto
# empiece la conversacion y el agente se olvida de lo que acaba de hacer. El
# suelo son los 2000 que usa arrancar.sh para avisar.
HUECO=$((CTX - TOKENS))
nota "quedan ~$HUECO tokens para la conversacion"
mayor_que "queda sitio para conversar" "$HUECO" 2000
# El otro tope, el escondido: 300 tokens de num_predict cortan el JSON de una
# tool call por la mitad. El naranja lo hereda del modelo base y es correcto
# para el —respuestas habladas cortas—, pero aqui seria un fallo silencioso.
mayor_que "la respuesta no se corta a media llamada" \
"$(parametro "$MODELO" num_predict)" 1024
titulo "De donde viene el numero"
nota "medido con cd a $RAIZ"
nota "desde ~/.hermes/hermes-agent saldrian ~30 KB mas (su AGENTS.md de 81 KB)"
resumen

90
verde/pruebas/06_privacidad.sh Executable file
View file

@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Que siga siendo local. Es la premisa del carril, asi que se verifica igual que
# cualquier otra cosa en vez de darla por hecha.
#
# Misma idea que pruebas/06_privacidad.sh del naranja, con las preguntas que
# aplican aqui: alli se interroga a gsettings, aqui al .env, al YAML y a ss.
#
# Lo que SI puede salir, y no es fallo, se lista al final para que no sorprenda:
# el navegador si el agente decide navegar, y la comprobacion de versiones.
cd "$(dirname "$0")" && . ./comun.sh
titulo "Credenciales"
# Las 11 lineas con valor del .env no son claves: son TERMINAL_TIMEOUT,
# BROWSER_SESSION_TIMEOUT, banderas de depuracion y similares. Se busca por el
# NOMBRE de la variable, que es lo que distingue una clave de un ajuste.
CLAVES=$(grep -vE '^\s*#|^\s*$' "$HERMES/.env" 2>/dev/null \
| grep -iE '^[A-Z_]*(API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[A-Z_]*=.+' \
| grep -vE '=\s*$' | wc -l)
comprueba "cero credenciales con valor en el .env" "$CLAVES" "0"
# Sin tokens de mensajeria el gateway no arranca, que es justo lo que se quiere:
# un canal de mensajeria es la unica pieza del carril que sacaria conversaciones
# de esta maquina.
MENSAJERIA=$(grep -vE '^\s*#|^\s*$' "$HERMES/.env" 2>/dev/null \
| grep -icE '^(TELEGRAM|DISCORD|SLACK|WHATSAPP|SIGNAL|TEAMS|QQ)[A-Z_]*=.+')
comprueba "cero tokens de mensajeria" "$MENSAJERIA" "0"
titulo "La red"
# Ollama es el cerebro: tiene que escuchar, pero solo aqui.
FUERA=$(ss -ltn 2>/dev/null | awk 'NR>1 {print $4}' \
| grep -E '^(0\.0\.0\.0|\*|\[::\]):(11434|8000|8080|3000)$' | wc -l)
comprueba "ningun puerto nuestro escucha fuera de loopback" "$FUERA" "0"
OLLAMA_LOCAL=$(ss -ltn 2>/dev/null | awk 'NR>1 {print $4}' | grep -c '127.0.0.1:11434')
comprueba "ollama escucha SOLO en 127.0.0.1" "$OLLAMA_LOCAL" "1"
# Conexiones salientes vivas de hermes en este momento. Cero es lo normal: la
# 04 y la 05 hablan con 127.0.0.1.
SALIENTES=$(ss -tnp 2>/dev/null | grep -c 'hermes' || true)
nota "conexiones de hermes ahora mismo: ${SALIENTES:-0}"
titulo "El cerebro no sale de aqui"
BASE=$(config_model base_url)
case "$BASE" in
http://127.0.0.1:*|http://localhost:*)
echo " $(verde OK) inferencia en esta maquina ($BASE)"; _ok=$((_ok + 1)) ;;
*)
echo " $(rojo FALLO) la inferencia sale a $BASE"; _fallo=$((_fallo + 1)) ;;
esac
# telemetry es de Hermes y viene en el config; si se enciende, sale. No es una
# bandera suelta sino un arbol —hoy telemetry.shared_metrics.enabled— asi que se
# recorre entero: leer solo la clave de arriba devolvia el diccionario y la
# prueba fallaba con la telemetria APAGADA, que es la peor forma de fallar.
ENCENDIDAS=$(python3 - "$CONFIG" <<'PY'
import sys, yaml
encendidas = []
def anda(nodo, ruta="telemetry"):
if isinstance(nodo, dict):
for k, v in nodo.items():
if k == "enabled" and v is True:
encendidas.append(ruta)
else:
anda(v, f"{ruta}.{k}")
elif nodo is True and ruta.endswith("enabled"):
encendidas.append(ruta)
anda(yaml.safe_load(open(sys.argv[1])).get('telemetry', {}) or {})
print(",".join(encendidas))
PY
)
if [ -z "$ENCENDIDAS" ]; then
echo " $(verde OK) telemetria apagada en todas sus ramas"; _ok=$((_ok + 1))
else
echo " $(rojo FALLO) telemetria encendida en: $ENCENDIDAS"; _fallo=$((_fallo + 1))
fi
titulo "Salidas conocidas, que no son fallo"
nota "el navegador (Playwright/Chromium) SOLO si el agente decide navegar"
nota "hermes update comprueba versiones contra su repo cuando se le pide"
nota "el driver de Computer Use esta INSTALADO pero nada lo invoca hoy"
resumen

88
verde/pruebas/07_voz.sh Executable file
View file

@ -0,0 +1,88 @@
#!/usr/bin/env bash
# Que el verde hable, y que hable IGUAL que el naranja.
#
# No es un detalle estetico. Si el verde sonara con una voz que Piper se
# descarga por su cuenta —la GPL de OHF-Voice que trae Hermes de serie—,
# comparar los dos carriles no diria nada: la mitad de la impresion de un
# asistente de voz es el timbre.
#
# Por eso el TTS de Hermes va por comando (type: command, sin documentar en su
# web pero si en tools/tts_tool.py) contra voz-verde.sh, que es un adaptador de
# 15 lineas al mismo config/jarvis-piper.sh del naranja: mismo binario Piper
# 1.2.0 MIT, mismo .onnx, misma cadena de audio.
#
# La prueba llama al adaptador EXACTAMENTE como lo llama Hermes: dos argumentos,
# el WAV de salida y un FICHERO con el texto. Es donde estaba la trampa —
# jarvis-piper.sh quiere el texto como argumento— y una interfaz mal encajada no
# se ve hasta que suena mal.
#
# No suena nada: se genera el fichero y se mide.
cd "$(dirname "$0")" && . ./comun.sh
titulo "El adaptador"
afirma "existe voz-verde.sh" test -x "$VERDE/voz-verde.sh"
afirma "existe jarvis-piper.sh del naranja" test -x "$RAIZ/config/jarvis-piper.sh"
titulo "Generar"
TEXTO=$TMP/texto.txt
WAV=$TMP/voz.wav
rm -f "$WAV"
printf 'A sus órdenes, señor. Todos los sistemas responden con normalidad.' > "$TEXTO"
INICIO=$(date +%s.%N)
"$VERDE/voz-verde.sh" "$WAV" "$TEXTO" >"$TMP/voz.log" 2>&1
SALIO=$?
TARDO=$(awk "BEGIN{printf \"%.2f\", $(date +%s.%N) - $INICIO}")
if [ $SALIO -ne 0 ] || [ ! -s "$WAV" ]; then
echo " $(rojo FALLO) voz-verde.sh no genero el WAV (codigo $SALIO)"
sed 's/^/ /' "$TMP/voz.log" | head -8
exit 1
fi
echo " $(verde OK) WAV generado en ${TARDO}s"; _ok=$((_ok + 1))
titulo "El fichero"
# soxi/ffprobe no estan garantizados: se lee la cabecera RIFF a mano, que son
# cuatro campos en posiciones fijas y no depende de nada instalado.
leer=$(python3 - "$WAV" <<'PY'
import struct, sys, wave
with wave.open(sys.argv[1]) as w:
print(w.getframerate(), w.getnchannels(), w.getsampwidth() * 8,
round(w.getnframes() / w.getframerate(), 2))
PY
)
read -r HZ CANALES BITS SEGUNDOS <<<"$leer"
comprueba "22050 Hz, como el naranja" "$HZ" "22050"
comprueba "mono" "$CANALES" "1"
comprueba "16 bits" "$BITS" "16"
# Frase de ~4 s. Si sale mucho mas corta, Piper se comio el texto; si sale mucho
# mas larga, el tono esta mal y sonaria a camara lenta.
mayor_que "dura lo que una frase (s)" "$SEGUNDOS" 2
menor_que "y no se alarga de mas (s)" "$SEGUNDOS" 12
# Tiempo real: 0,34 s para 3,2 s de audio, medido el 15 ago. Es CPU, no toca la
# grafica, asi que no compite con el modelo.
RATIO=$(awk "BEGIN{printf \"%.2f\", $TARDO / $SEGUNDOS}")
nota "sintesis $TARDO s para $SEGUNDOS s de audio (x$RATIO del tiempo real)"
menor_que "sintetiza mas rapido de lo que dura" "$RATIO" 1
titulo "La misma voz que el naranja"
# voz-verde.sh copia al NUCLEO (nucleo/boca/voz.py: POR_DEFECTO = "davefx"), no
# al README, que describe sharvard y es del JARVIS sobre Newelle. Se compara
# contra el nucleo porque es contra quien se va a comparar el carril.
VOZ_VERDE=$(grep -oP 'JARVIS_PIPER_VOZ:-\K[^}]+' "$VERDE/voz-verde.sh")
VOZ_NUCLEO=$(grep -oP 'POR_DEFECTO\s*=\s*"\K[^"]+' "$RAIZ/nucleo/boca/voz.py" 2>/dev/null)
if [ -z "$VOZ_NUCLEO" ]; then
nota "no pude leer la voz del nucleo; comparacion omitida"
else
comprueba "misma voz que el nucleo" "$VOZ_VERDE" "es_ES-${VOZ_NUCLEO}-medium"
fi
resumen

73
verde/pruebas/08_escucha.sh Executable file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Que el verde ESCUCHE, y en local. El complemento del 07 (que hable).
#
# El TUI de Hermes no tiene microfono, y su STT nativo es para notas de voz de
# mensajeria —que saldrian de la maquina—. El puente escucha-verde.sh cierra el
# hueco con faster-whisper local (el mismo 'small' en espanol que ya declara la
# seccion stt: del config.yaml), en CPU para no pelear por la VRAM con el 4B.
#
# La prueba no necesita microfono: hace un BUCLE. Genera voz con el mismo TTS del
# verde (voz-verde.sh, 07), la transcribe con escucha_transcribe.py, y comprueba
# que el texto vuelve entero. Si el oido funciona, lo que entro por el altavoz
# sale por el transcriptor.
cd "$(dirname "$0")" && . ./comun.sh
PY=$RAIZ/nucleo/venv/bin/python
[ -x "$PY" ] || PY=$(command -v python3)
TRANS=$VERDE/escucha_transcribe.py
titulo "Las piezas"
afirma "existe escucha_transcribe.py" test -f "$TRANS"
afirma "existe el puente escucha-verde.sh" test -x "$VERDE/escucha-verde.sh"
afirma "faster-whisper importa" "$PY" -c "import faster_whisper"
afirma "el TTS del verde existe (para el bucle)" test -x "$VERDE/voz-verde.sh"
# quita acentos y baja a minusculas, para comparar sin depender de la tilde
_norm() { "$PY" -c "import sys,unicodedata as u; t=u.normalize('NFD',sys.stdin.read().lower()); print(''.join(c for c in t if u.category(c)!='Mn'))"; }
titulo "El bucle voz -> texto"
# (frase dicha, palabras clave que TIENEN que volver). Se evita 'jarvis' a
# proposito: es un nombre raro y whisper lo oye 'harvis'; eso no mide el oido.
probar_frase() {
local frase=$1 claves=$2
local wav=$TMP/oir.wav txt=$TMP/oir.txt
printf '%s' "$frase" > "$txt"
rm -f "$wav"
"$VERDE/voz-verde.sh" "$wav" "$txt" >/dev/null 2>&1
if [ ! -s "$wav" ]; then
echo " $(rojo FALLO) no se genero el WAV para: $frase"; _fallo=$((_fallo+1)); return
fi
local ini out norm falta=""
ini=$(date +%s.%N)
out=$(timeout 120 "$PY" "$TRANS" "$wav" 2>/dev/null)
local tardo; tardo=$(awk "BEGIN{printf \"%.1f\", $(date +%s.%N) - $ini}")
norm=$(printf '%s' "$out" | _norm)
local c
for c in $claves; do
printf '%s' "$norm" | grep -q "$c" || falta="$falta $c"
done
if [ -z "$falta" ]; then
printf ' %s escucho: "%s" (%ss)\n' "$(verde OK)" "$out" "$tardo"; _ok=$((_ok+1))
else
printf ' %s faltan [%s] en: "%s"\n' "$(rojo FALLO)" "$falta" "$out"; _fallo=$((_fallo+1))
fi
}
probar_frase "cuanto espacio libre queda en el disco" "espacio disco"
probar_frase "abre el navegador y busca el tiempo en madrid" "navegador tiempo madrid"
probar_frase "apaga las luces del salon a las diez" "luces salon"
titulo "Local de verdad"
# El transcriptor no debe abrir ningun socket a la nube: se comprueba que el
# codigo no importa clientes de APIs de STT de pago.
if grep -qiE "openai|groq|mistral|requests\.post|urllib.*http" "$TRANS"; then
echo " $(rojo FALLO) el transcriptor referencia una API remota"; _fallo=$((_fallo+1))
else
echo " $(verde OK) el transcriptor no llama a ninguna API remota"; _ok=$((_ok+1))
fi
resumen

111
verde/pruebas/comun.sh Executable file
View file

@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Utilidades compartidas por las pruebas del verde. No se ejecuta suelto.
#
# Misma forma que pruebas/comun.sh del naranja —comprueba, afirma, resumen— para
# que las dos suites se lean igual. Lo que cambia es a QUE se le pregunta: alli
# es gsettings dentro de un flatpak, aqui es un YAML, ollama y un binario.
#
# Ninguna prueba de esta carpeta abre el TUI ni habla por el altavoz.
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/../.." && pwd)
VERDE=$RAIZ/verde
CONFIG=$HOME/.hermes/config.yaml
HERMES=$HOME/.hermes
OLLAMA=${OLLAMA_URL:-http://127.0.0.1:11434}
MODELO=qwen3.5:4b-verde
NARANJA=qwen3.5:4b-jarvis
TMP=${TMPDIR:-/tmp}/jarvis-verde-pruebas
mkdir -p "$TMP"
_ok=0
_fallo=0
verde() { printf '\033[32m%s\033[0m' "$1"; }
rojo() { printf '\033[31m%s\033[0m' "$1"; }
gris() { printf '\033[90m%s\033[0m' "$1"; }
# comprueba <descripcion> <valor obtenido> <valor esperado>
comprueba() {
local desc=$1 obtenido=$2 esperado=$3
if [ "$obtenido" = "$esperado" ]; then
printf ' %s %s\n' "$(verde OK)" "$desc"
_ok=$((_ok + 1))
else
printf ' %s %s\n' "$(rojo FALLO)" "$desc"
printf ' esperado: %s\n obtenido: %s\n' "$esperado" "$obtenido"
_fallo=$((_fallo + 1))
fi
}
# afirma <descripcion> <comando...> — pasa si el comando devuelve 0
afirma() {
local desc=$1; shift
if "$@" >/dev/null 2>&1; then
printf ' %s %s\n' "$(verde OK)" "$desc"
_ok=$((_ok + 1))
else
printf ' %s %s\n' "$(rojo FALLO)" "$desc"
printf ' fallo: %s\n' "$*"
_fallo=$((_fallo + 1))
fi
}
# menor_que <descripcion> <valor> <tope>
menor_que() {
local desc=$1 valor=$2 tope=$3
if awk "BEGIN{exit !($valor < $tope)}"; then
printf ' %s %s %s\n' "$(verde OK)" "$desc" "$(gris "($valor < $tope)")"
_ok=$((_ok + 1))
else
printf ' %s %s %s\n' "$(rojo FALLO)" "$desc" "$(gris "($valor >= $tope)")"
_fallo=$((_fallo + 1))
fi
}
# mayor_que <descripcion> <valor> <suelo>
mayor_que() {
local desc=$1 valor=$2 suelo=$3
if awk "BEGIN{exit !($valor > $suelo)}"; then
printf ' %s %s %s\n' "$(verde OK)" "$desc" "$(gris "($valor > $suelo)")"
_ok=$((_ok + 1))
else
printf ' %s %s %s\n' "$(rojo FALLO)" "$desc" "$(gris "($valor <= $suelo)")"
_fallo=$((_fallo + 1))
fi
}
titulo() { printf '\n\033[1m%s\033[0m\n' "$1"; }
nota() { printf ' %s %s\n' "$(gris '·')" "$1"; }
resumen() {
printf '\n %s pasan · %s fallan\n' "$(verde $_ok)" \
"$([ $_fallo -eq 0 ] && gris 0 || rojo $_fallo)"
[ $_fallo -eq 0 ]
}
# Lo que ocupa la tarjeta ahora mismo, en MiB.
vram() { nvidia-smi --query-gpu=memory.used --format=csv,noheader 2>/dev/null | tr -d ' MiB'; }
# Un parametro del Modelfile tal y como lo tiene ollama guardado. Se pregunta a
# ollama y no al fichero del repo: el Modelfile del repo es la intencion, esto
# es lo que de verdad se va a cargar. arrancar.sh recrea el modelo al medir el
# techo de contexto, asi que los dos pueden diferir legitimamente.
parametro() { # $1 = modelo $2 = parametro
ollama show --modelfile "$1" 2>/dev/null \
| awk -v p="$2" '$1=="PARAMETER" && $2==p {print $3; exit}'
}
# El bloque model: del config.yaml, PARSEADO. Nunca leido con grep: el fichero
# declara provider y base_url dos veces dentro de model: y en YAML gana la
# ultima. Leyendolo parecia local y el trafico salia por OpenRouter.
config_model() { # $1 = clave
python3 -c "
import yaml, sys
try:
print(yaml.safe_load(open('$CONFIG'))['model'].get('$1', ''))
except Exception as e:
print('ERROR:', e)
" 2>/dev/null | tr -d '\n'
}

39
verde/pruebas/ejecutar.sh Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Pasa las pruebas del verde y resume.
#
# ./ejecutar.sh todas
# ./ejecutar.sh 04 05 solo esas
#
# NINGUNA abre el TUI ni suena por el altavoz. Lo que no se puede automatizar
# —si contesta bien, si encadena herramientas, si la voz convence— esta en
# ESCALERA.md, que se recorre a mano.
#
# Van en este orden porque cada una explica los fallos de la siguiente: sin
# tarjeta libre (01) no carga el modelo (02), sin modelo no hay tool calling
# (04), y sin que quepa el prompt (05) el TUI dara respuestas truncadas que
# parecen otra cosa.
cd "$(dirname "$0")"
if [ $# -gt 0 ]; then
PRUEBAS=()
for n in "$@"; do PRUEBAS+=("$(ls "${n}"_*.sh 2>/dev/null | head -1)"); done
else
PRUEBAS=($(ls [0-9][0-9]_*.sh))
fi
FALLADAS=()
for p in "${PRUEBAS[@]}"; do
[ -n "$p" ] || continue
[ -x "$p" ] || chmod +x "$p" 2>/dev/null
"./$p" || FALLADAS+=("$p")
done
printf '\n\033[1m════════════════════════════════════\033[0m\n'
if [ ${#FALLADAS[@]} -eq 0 ]; then
printf '\033[32m todo en orden\033[0m\n'
printf '\033[90m siguiente: ../verde-launcher.sh y seguir ../ESCALERA.md\033[0m\n'
exit 0
fi
printf '\033[31m fallan: %s\033[0m\n' "${FALLADAS[*]}"
exit 1

View file

@ -0,0 +1,60 @@
# Variante de qwen3.5:4b para el carril VERDE: contexto grande para el arnes.
#
# ollama create qwen3.5:4b-verde -f verde/qwen3.5-4b-verde.Modelfile
#
# ── Por que hace falta una variante propia ─────────────────────────────────
#
# El naranja usa qwen3.5:4b-jarvis, afinado para lo contrario que esto:
# respuestas habladas cortas y VRAM libre para whisper. Medido con
# `hermes prompt-size` desde $HOME/COFRE/CODERS/JARVIS:
#
# prompt de sistema de Hermes 26.281 B
# esquemas de las 19 herramientas 55.978 B
# ─────────────────────────────────────────────
# fijo, antes de decir nada 82.259 B ≈ 20.500 tokens
#
# Contra los num_ctx 4096 del naranja. Cinco veces por encima: no es que rinda
# mal, es que no entra la pregunta.
#
# ── Y el numero NO se elige por lo que cabe, sino por lo que Hermes exige ──
#
# CORREGIDO EL 16 DE AGOSTO. Aqui ponia num_ctx 32768 con las 32 capas en GPU,
# elegido como "lo mas que cabe en la GPU". Dos cosas mal:
#
# 1. 32768 con 32 capas da cudaMalloc failed. Lo que cabia asi era 24576.
# 2. Da igual: Hermes RECHAZA cualquier modelo por debajo de 64.000 tokens
# (MINIMUM_CONTEXT_LENGTH, agent/model_metadata.py). Es un tope duro y no
# se ve hasta que abres el TUI.
#
# O sea que el contexto no se negocia: 65536. Lo que se mide entonces es
# cuantas capas caben con ese contexto, y son 16 de 32 (medido el 16 ago:
# 32 y 24 dan OOM; con 16 son 2795 MiB en tarjeta y ~3 GB en RAM).
#
# El precio son las 16 capas que van por CPU, y se paga en el prefill de los
# ~20.500 tokens del prompt fijo. Ver la tabla de tiempos en NOTAS.md.
#
# ── num_predict: el tope que no se ve venir ────────────────────────────────
#
# El naranja hereda num_predict 300 del modelo BASE (no lo pone su Modelfile).
# 300 tokens cortan el JSON de una llamada a herramienta por la mitad, y el
# sintoma seria "el modelo no sabe llamar herramientas", que es falso. Hay que
# sobrescribirlo aqui a mano.
#
# ── temperature ───────────────────────────────────────────────────────────
#
# El base trae 0.4, bien para conversar. Emitir JSON valido no es conversar:
# se baja a 0.2. Menos invencion en los nombres de parametros.
# OJO: ollama NO admite comentarios en la misma linea que un PARAMETER.
# "PARAMETER num_gpu 32 # comentario" falla con:
# Error: invalid int value [32 # comentario]
# Van encima, cada uno.
FROM qwen3.5:4b
# 16 de 32: con mas, num_ctx 65536 no carga (cudaMalloc failed)
PARAMETER num_gpu 16
# no es lo que cabe, es el suelo que exige Hermes (64.000)
PARAMETER num_ctx 65536
# 300 (el que hereda del base) cortaria una tool call por la mitad
PARAMETER num_predict 4096
# llamar herramientas no es escribir prosa
PARAMETER temperature 0.2

View file

@ -0,0 +1,38 @@
---
name: buscar-en-apuntes
description: Use this skill whenever the user asks about hacking/pentesting tools and methodology, Linux commands, self-hosted software, or "what do my notes say about X". It searches the user's own indexed notes (the JARVIS RAG) and grounds the answer in what is actually written there, instead of guessing. Prefer it over answering from memory for any tooling/how-to question.
metadata:
origin: JARVIS-verde
---
# Buscar en los apuntes (RAG del naranja)
El usuario tiene miles de apuntes indexados —metodología de pentesting, páginas
de manual de Linux, cheatsheets, catálogo de herramientas y software—. Es el mismo
índice que usa el núcleo naranja. Para no inventar, **búscalo antes de responder**.
## Cuándo activarla
- Herramientas de hacking/pentesting o su uso (nmap, ffuf, impacket, kerberoasting,
shells reversas, escalada de privilegios, Active Directory…).
- "¿Qué comando hace X en Linux?"
- "¿Qué software autoalojado hay para Y?" / "alternativa libre a Z".
- Cualquier "¿qué dicen mis apuntes sobre…?".
## Cómo usarla
Ejecuta la búsqueda con la herramienta de terminal (local, no sale nada):
```
python3 "$JARVIS/nucleo/saber/busca_cli.py" "CONSULTA EN CASTELLANO"
```
donde `$JARVIS` es la raíz del repo. Reformula la pregunta como una consulta corta
y natural; si el primer intento no trae lo que buscas, prueba otras palabras.
`--n 8` para más resultados, `--json` para parsearlo.
## Cómo responder
Basa la respuesta en lo que devuelve la búsqueda y **cita la fuente**. Si no
devuelve nada útil, dilo; no te inventes comandos. Resume y da el comando o el
paso concreto, no vuelques el resultado en crudo.

150
verde/verde-launcher.sh Executable file
View file

@ -0,0 +1,150 @@
#!/usr/bin/env bash
# JARVIS verde — el banco de pruebas con Hermes Agent.
#
# verde-launcher.sh abre el TUI de Hermes en verde fosforo
# verde-launcher.sh --status que corre y cuanta VRAM, sin arrancar nada
# verde-launcher.sh --stop cerrar y soltar la VRAM
# verde-launcher.sh --sin-color igual pero sin tocar la paleta
#
# ── El verde no es decoracion ──────────────────────────────────────────────
#
# Hay tres JARVIS en esta maquina y se distinguen por color, como ya hacia el
# README: cian el de Newelle (congelado), naranja el nucleo (en desarrollo),
# verde este. Cuando tienes dos terminales abiertos a las tres de la manana,
# el color es lo unico que te dice cual estas tocando.
#
# Hermes NO tiene tema para su TUI — mirado en hermes_cli/config_defaults.py,
# la unica clave "theme" que existe es la del panel web. Asi que el color lo
# pone la terminal, con secuencias OSC:
#
# OSC 10 primer plano OSC 11 fondo OSC 12 cursor
#
# Se hace asi y no editando tu perfil de terminal a proposito: esto pinta SOLO
# esta ventana y solo mientras dura la sesion. Tu perfil por defecto y el del
# naranja se quedan como estan. Al salir se restaura con OSC 110/111/112.
set -uo pipefail
AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
RAIZ=$(cd -P "$AQUI/.." && pwd)
# Fosforo verde. Los mismos tonos que jarvis-verde.svg, para que el icono y la
# pantalla sean el mismo color.
VERDE=${JARVIS_VERDE_TINTA:-#00ff41}
FONDO=${JARVIS_VERDE_FONDO:-#04120a}
HERMES=${HERMES_BIN:-$HOME/.local/bin/hermes}
OLLAMA_URL=${OLLAMA_URL:-http://127.0.0.1:11434}
# ── la paleta ──────────────────────────────────────────────────────────────
pinta_verde() {
[ -t 1 ] || return 0 # sin terminal, nada que pintar
printf '\033]10;%s\007' "$VERDE"
printf '\033]11;%s\007' "$FONDO"
printf '\033]12;%s\007' "$VERDE"
}
restaura() {
[ -t 1 ] || return 0
printf '\033]110\007\033]111\007\033]112\007'
}
# ── comprobaciones que no arrancan nada ────────────────────────────────────
estado() {
printf 'JARVIS verde\n\n'
printf ' hermes '
if [ -x "$HERMES" ]; then printf 'instalado en %s\n' "$HERMES"
else printf 'NO instalado\n'; fi
printf ' ollama '
if curl -fsS --max-time 2 "$OLLAMA_URL/api/tags" >/dev/null 2>&1; then
printf 'en pie (%s)\n' "$OLLAMA_URL"
else
printf 'NO responde en %s\n' "$OLLAMA_URL"
fi
printf ' modelo '
# El modelo se LEE de la configuracion, no se escribe aqui. El lanzador del
# naranja llevaba el nombre a mano y se quedo desfasado: precargaba
# qwen3.5:4b mientras la app usaba qwen3.5:4b-jarvis, o sea 2941 MiB del que
# no era, ollama tenia que descargar y recargar, y de paso dejaba la tarjeta
# sin sitio para whisper. Aqui pasaria igual: este fichero decia
# qwen3.5:4b-jarvis y el carril va con qwen3.5:4b-verde.
MODELO=$(python3 -c "
import yaml
print(yaml.safe_load(open('$HOME/.hermes/config.yaml'))['model'].get('default',''))
" 2>/dev/null)
if [ -z "$MODELO" ]; then
printf 'no pude leer model.default de ~/.hermes/config.yaml\n'
elif curl -fsS --max-time 2 "$OLLAMA_URL/api/tags" 2>/dev/null \
| grep -q "$MODELO"; then
printf '%s disponible\n' "$MODELO"
else
printf '%s NO esta (lo crea verde/arrancar.sh)\n' "$MODELO"
fi
printf ' voz '
if [ -x "$AQUI/voz-verde.sh" ] && [ -x "$RAIZ/config/jarvis-piper.sh" ]; then
printf 'jarvis-piper.sh, la misma que el naranja\n'
else
printf 'falta el adaptador o jarvis-piper.sh\n'
fi
printf ' VRAM '
if command -v nvidia-smi >/dev/null 2>&1; then
nvidia-smi --query-gpu=memory.used,memory.total --format=csv,noheader
else
printf '(sin nvidia-smi)\n'
fi
}
parar() {
# El gateway solo existe si algun dia se configura un canal de mensajeria.
if [ -x "$HERMES" ]; then "$HERMES" gateway stop 2>/dev/null || true; fi
pkill -f 'hermes' 2>/dev/null || true
printf 'parado. La VRAM la suelta ollama solo cuando el modelo caduca.\n'
}
# ── main ───────────────────────────────────────────────────────────────────
COLOR=si
case "${1:-}" in
--status|--estado) estado; exit 0 ;;
--stop|--parar) parar; exit 0 ;;
--sin-color) COLOR=no; shift ;;
esac
if [ ! -x "$HERMES" ]; then
printf 'No encuentro hermes en %s\n' "$HERMES" >&2
printf 'Instalalo con: %s/instalar.sh\n' "$AQUI" >&2
exit 1
fi
# El cerebro es local: si ollama no esta en pie, Hermes no tiene con que
# pensar. Se avisa antes en vez de dejar que falle en la primera frase.
if ! curl -fsS --max-time 2 "$OLLAMA_URL/api/tags" >/dev/null 2>&1; then
printf 'Aviso: ollama no responde en %s.\n' "$OLLAMA_URL" >&2
printf 'El cerebro del verde es local; arrancalo con: systemctl start ollama\n' >&2
fi
if [ "$COLOR" = si ]; then
pinta_verde
trap restaura EXIT INT TERM
fi
# ── El cd NO basta: hay que decirselo a Hermes ─────────────────────────────
#
# Este `cd` estaba solo, y no servia de nada. Hermes RESTAURA el directorio de
# trabajo que tenga apuntado en la sesion y se salta aquel desde el que lo
# lanzas. El sintoma no parece un fallo de rutas: se le pide "lee
# verde/README.md y resumelo" y contesta un resumen impecable de OTRO fichero
# —$HOME/README.md, que resulta ser el de Gitleaks— sin un solo error por
# ningun lado. Parece el modelo alucinando y es el directorio.
#
# --in DIR entra en el directorio Y se salta la restauracion; es la bandera que
# existe justo para esto. El cd se queda porque las comprobaciones de arriba y
# los ficheros de contexto (AGENTS.md, cwd files) se leen antes.
cd "$RAIZ"
exec "$HERMES" --in "$RAIZ" "$@"

51
verde/voz-verde.sh Executable file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env bash
# Adaptador entre el TTS por comando de Hermes y la voz que ya usa el naranja.
#
# voz-verde.sh <salida.wav> <fichero-con-el-texto>
#
# ── Por que existe este fichero ─────────────────────────────────────────────
#
# Hermes tiene un proveedor de TTS por comando (`type: command`) que NO esta
# documentado en su web pero si en el codigo: tools/tts_tool.py, el bloque
# "Custom command providers". Eso es lo que permite que el verde hable con la
# MISMA voz que el naranja en vez de con la que Piper se descargue por su
# cuenta — y sin esa igualdad, comparar los dos carriles no diria nada.
#
# Las interfaces no encajan directamente y por eso hay adaptador:
#
# jarvis-piper.sh espera <salida.wav> <texto como argumento>
# Hermes entrega {output_path} y {input_path}, un FICHERO con el texto
#
# Hermes ejecuta con shell=True, asi que un "$(cat ...)" en la plantilla habria
# funcionado. No se hace por dos motivos: _run_command_tts llama a
# hermes_subprocess_env(inherit_credentials=False), que LIMPIA el entorno, de
# modo que JARVIS_PIPER_VOZ y JARVIS_PIPER_TONO no llegarian; y una plantilla
# con comillas anidadas dentro de un YAML es justo el sitio donde un fallo no
# se ve hasta que suena mal.
#
# ── Que voz, y por que esta ────────────────────────────────────────────────
#
# davefx a tono 1.0, que es lo que tiene puesto el naranja HOY en
# nucleo/boca/voz.py (POR_DEFECTO = "davefx", JARVIS_PIPER_TONO = "1.0").
#
# OJO: el README describe sharvard + 0.86 = 106 Hz como "la puesta". Eso era el
# JARVIS sobre Newelle. El nucleo cambio a davefx "porque se eligio a oido". El
# verde copia al NUCLEO, que es contra quien se va a comparar, no al README.
set -uo pipefail
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)
SALIDA=${1:-}
ENTRADA=${2:-}
[ -n "$SALIDA" ] || { echo "falta el fichero de salida" >&2; exit 1; }
[ -f "$ENTRADA" ] || { echo "no esta el fichero de texto: $ENTRADA" >&2; exit 1; }
TEXTO=$(cat "$ENTRADA")
[ -n "$TEXTO" ] || { echo "el fichero de texto esta vacio" >&2; exit 1; }
# Las variables se ponen aqui porque Hermes limpia el entorno antes de llamar.
export JARVIS_PIPER_VOZ=${JARVIS_PIPER_VOZ:-es_ES-davefx-medium}
export JARVIS_PIPER_TONO=${JARVIS_PIPER_TONO:-1.0}
exec "$RAIZ/config/jarvis-piper.sh" "$SALIDA" "$TEXTO"