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).
This commit is contained in:
sito 2026-08-18 11:26:42 +02:00
parent b1f8118272
commit 1e3e5ca20d
32 changed files with 3697 additions and 161 deletions

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