JARVIS: asistente de voz local para Linux

Nucleo propio: oye con whisper.cpp, piensa con un modelo de Ollama, habla
con Piper, y hace RAG sobre los apuntes del usuario. 100% local, sin
cuentas ni claves.

Escrito bajo una restriccion dura, 4 GB de VRAM: el cerebro y whisper
comparten tarjeta y solo caben porque estan dimensionados para ello. El
RAG usa embeddings estaticos con busqueda hibrida; la voz clonada se sirve
de una cache de frases.

Incluye instalador (install.sh), requisitos, y documentacion del stack,
del manejo de root y de las acciones. Los apuntes indexados y el diario NO
se incluyen: son privados y el .gitignore los bloquea.
This commit is contained in:
sito 2026-08-16 15:28:31 +02:00
commit 8e4bc8ad94
125 changed files with 25033 additions and 0 deletions

6
nucleo/.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
datos/cache_voz/
venv/
__pycache__/
*.pyc
datos/saber.jsonl
datos/diario.jsonl

135
nucleo/arranca.sh Executable file
View file

@ -0,0 +1,135 @@
#!/usr/bin/env bash
# Lanzador del JARVIS naranja: el nucleo, fuera del flatpak.
#
# arranca.sh levanta ollama si hace falta, abre el panel y escucha
# arranca.sh --panel solo el panel, sin microfono
# arranca.sh --stop lo para y libera la grafica
# arranca.sh --estado dice si esta vivo
#
# Comparado con el lanzador del JARVIS azul, aqui sobra casi todo: no hay que
# adivinar la rama del flatpak, ni leer gsettings con escapado de GVariant, ni
# preguntarle a la aplicacion que modelo tiene puesto. El nucleo lo sabe porque
# es suyo.
#
# Lo que SI se conserva, porque en esta maquina importa: al parar se le dice a
# ollama que suelte el modelo. Son 2,1 GB de una tarjeta de 4, y sin esto whisper
# se queda sin sitio la proxima vez.
set -uo pipefail
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
PYTHON=$RAIZ/venv/bin/python
[ -x "$PYTHON" ] || PYTHON=$(command -v python3)
OLLAMA_URL=${JARVIS_OLLAMA:-http://127.0.0.1:11434}
REGISTRO=$RAIZ/jarvis.log
PUERTO=${JARVIS_PUERTO:-8790}
# Fichero de PID, no pgrep.
#
# El primer intento fue marcar el proceso con una variable de entorno y buscarla
# con `pgrep -f`. No funciona: pgrep mira la LINEA DE ORDENES, y `env VAR=1 cmd`
# no deja la variable ahi. El sintoma fue peor que un simple "no lo encuentra":
# el lanzador creia que no habia nada corriendo, arrancaba un segundo, y ese
# moria con "address already in use" mientras el primero seguia vivo.
#
# Y buscar por el nombre del script tiene el problema de siempre: el patron
# aparece en la linea de ordenes del propio pgrep. Un fichero de PID no se
# equivoca.
PID=${XDG_RUNTIME_DIR:-/tmp}/jarvis-nucleo.pid
aviso() { command -v notify-send >/dev/null && \
notify-send -i "$RAIZ/jarvis-naranja.svg" "JARVIS" "$1"; }
modelo() {
grep -oP 'MODELO = os\.environ\.get\("JARVIS_MODELO", "\K[^"]+' \
"$RAIZ/cerebro/ollama.py" 2>/dev/null || echo "qwen3.5:4b-jarvis"
}
# Vivo = hay un pid apuntado Y ese proceso existe. Un pid huerfano de un
# reinicio no puede hacernos creer que sigue en marcha.
vivo() {
[ -f "$PID" ] || return 1
local p; p=$(cat "$PID" 2>/dev/null)
[ -n "$p" ] && kill -0 "$p" 2>/dev/null
}
ollama_up() { curl -s --max-time 2 "$OLLAMA_URL/api/version" >/dev/null 2>&1; }
puerto_ocupado() { curl -s --max-time 2 "http://127.0.0.1:$PUERTO/" >/dev/null 2>&1; }
case "${1:-arranca}" in
--stop)
# Tambien lo que este ocupando el puerto sin fichero de pid: si no, un
# huerfano impide arrancar de nuevo y --stop parece no hacer nada.
if ! vivo && puerto_ocupado && command -v fuser >/dev/null; then
fuser -k "$PUERTO/tcp" >/dev/null 2>&1
fi
if vivo; then
kill "$(cat "$PID")" 2>/dev/null
# margen para que suelte el puerto antes de que nadie lo reclame
for _ in 1 2 3 4 5 6 7 8 9 10; do vivo || break; sleep 0.3; done
vivo && kill -9 "$(cat "$PID")" 2>/dev/null
fi
rm -f "$PID"
if ollama_up; then
# keep_alive 0: suelta el modelo ya, no dentro de cinco minutos
curl -s "$OLLAMA_URL/api/generate" \
-d "{\"model\":\"$(modelo)\",\"keep_alive\":0}" >/dev/null 2>&1
aviso "Detenido, grafica liberada"
else
aviso "Detenido"
fi
exit 0
;;
--estado)
# Se mira el pid Y el puerto. Con solo el pid, una instancia arrancada a
# mano —o superviviente de un lanzador que murio— daba "parado" mientras
# el panel contestaba tan tranquilo, que es la peor respuesta posible:
# la que hace dudar de si el problema es el programa o quien pregunta.
if vivo; then
echo "JARVIS naranja: vivo"
exit 0
fi
if puerto_ocupado; then
echo "JARVIS naranja: vivo, pero arrancado por fuera de este lanzador"
echo " (no hay $PID; para pararlo: fuser -k $PUERTO/tcp)"
exit 0
fi
echo "JARVIS naranja: parado"
exit 1
;;
esac
if vivo || puerto_ocupado; then
# Ya esta abierto: en vez de levantar otro —que moriria con "address already
# in use" y dejaria un rastro confuso— se trae el panel al frente.
aviso "Ya estaba en marcha"
# La MISMA ventana que abre el arranque normal. Antes esto era xdg-open, que
# abre el navegador POR DEFECTO en una pestaña: segun si JARVIS ya estaba
# abierto o no, el mismo icono daba una aplicacion o una pestaña de Firefox.
setsid "$RAIZ/ventana.sh" "http://127.0.0.1:$PUERTO/" >/dev/null 2>&1 &
exit 0
fi
# ollama, si no esta ya. En segundo plano y sin esperar: el catalogo contesta
# las ordenes conocidas sin tocar el modelo, asi que JARVIS es util desde el
# primer segundo aunque ollama tarde en calentar.
if ! ollama_up && command -v ollama >/dev/null; then
setsid ollama serve >/dev/null 2>&1 &
fi
# El indice de COFRE, si no esta. En segundo plano: JARVIS arranca sin el y el
# cerebro lo usa en cuanto este listo. Reconstruirlo a mano: saber/indexa.py
if [ ! -f "$RAIZ/datos/saber.jsonl" ]; then
setsid "$PYTHON" "$RAIZ/saber/indexa.py" >/dev/null 2>&1 &
fi
: > "$REGISTRO" 2>/dev/null || REGISTRO=/dev/null
aviso "Arrancando"
MODO=(--panel --escucha)
[ "${1:-}" = "--panel" ] && MODO=(--panel)
# El pid se apunta ANTES del exec y es el de este mismo shell, que con exec
# pasa a ser el de Python: asi el fichero nunca apunta a un proceso que no es.
echo $$ > "$PID"
trap 'rm -f "$PID"' EXIT
exec "$PYTHON" -u "$RAIZ/jarvis.py" "${MODO[@]}" >>"$REGISTRO" 2>&1

0
nucleo/boca/__init__.py Normal file
View file

210
nucleo/boca/voz.py Normal file
View file

@ -0,0 +1,210 @@
"""La boca de JARVIS: convertir texto en sonido, y saber cuando esta sonando.
Esto es la version de `voces.py` + la mitad de `tts.py` de Newelle, pero sin el
motor de handlers: aqui solo hablamos por Piper y por el clon, y los dos son un
script de shell que escribe un WAV. Todo lo demas sobraba.
## Lo que hay que hacer bien y en Newelle estaba mal
**Saber que esta hablando, y saberlo A TIEMPO.** El eco que envenena el registro
de JARVIS el 21 % confirmado de las frases que no entiende son suyas viene de
que Newelle marca `assistant_speaking` con `GLib.idle_add`, que es asincrono: el
audio ya esta sonando mientras el hilo de grabacion sigue leyendo `False`. Aqui
la bandera se pone ANTES de lanzar el reproductor y se quita con retardo, porque
cuando el proceso termina el sonido todavia esta viajando hasta el microfono.
**Un solo altavoz.** Con tareas en paralelo, dos respuestas a la vez se pisan.
Se habla por turnos con un cerrojo, igual que hacia `_habla` en el panel.
## La cache
Clave `sha1(voz|texto normalizado)`. Piper tarda entre medio segundo y dos por
frase, y JARVIS repite mucho ("Enseguida, señor", "No he podido, señor"): con
cache eso es un `cp`.
Se guarda en `datos/cache_voz/`, no en la del clon: aquella la genera
`generar_cache_voz.py` con su propia clave (sha1 solo del texto) y es de solo
lectura para nosotros. `jarvis-voz.sh` ya la consulta por dentro.
"""
import hashlib
import os
import re
import shutil
import subprocess
import threading
import time
RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
CONFIG = os.path.join(RAIZ, "config")
MODELOS = os.path.join(RAIZ, "voz", "modelos")
CACHE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"datos", "cache_voz")
# Cuanto se sigue considerando "hablando" despues de que el reproductor termine.
# El sonido tarda en salir del buffer de la tarjeta y en llegar al microfono; sin
# esta cola, la ultima silaba de JARVIS se cuela en la siguiente transcripcion.
#
# El valor sale de medirlo con pruebas/prueba_eco.py, no de suponerlo: con 350
# la frase entera dejaba de colarse pero seguia escapandose un fragmento.
COLA_MS = int(os.environ.get("JARVIS_COLA_MS", "700"))
def _piper(modelo):
return {
"orden": [os.path.join(CONFIG, "jarvis-piper.sh")],
"entorno": {"JARVIS_PIPER_VOZ": modelo, "JARVIS_PIPER_TONO": "1.0"},
"necesita": os.path.join(MODELOS, modelo + ".onnx"),
}
# El orden es el de rotacion. davefx primero porque es la que se eligio a oido.
VOCES = {
"davefx": _piper("es_ES-davefx-medium"),
"clon": {
"orden": [os.path.join(CONFIG, "jarvis-voz.sh")],
"entorno": {},
"necesita": os.path.join(CONFIG, "jarvis-voz.sh"),
},
"claude": _piper("es_MX-claude-high"),
"ald": _piper("es_MX-ald-medium"),
"sharvard": _piper("es_ES-sharvard-medium"),
}
POR_DEFECTO = "davefx"
# Se prueban en orden. paplay y pw-play respetan el enrutado de PipeWire, que es
# lo que hace falta para que el cancelador de eco tenga la referencia de lo que
# sale por los altavoces. aplay habla con ALSA directamente y se la salta.
REPRODUCTORES = (["paplay"], ["pw-play"], ["aplay", "-q"])
def _normaliza(texto: str) -> str:
return re.sub(r"\s+", " ", texto or "").strip()
def _clave(voz: str, texto: str) -> str:
return hashlib.sha1(f"{voz}|{_normaliza(texto)}".encode()).hexdigest()[:16]
def _reproductor():
for r in REPRODUCTORES:
if shutil.which(r[0]):
return r
return None
class Boca:
"""Todo lo que suena sale de aqui, y de uno en uno."""
def __init__(self, voz: str = POR_DEFECTO, al_cambiar=None):
self.voz = voz if voz in VOCES else POR_DEFECTO
self._turno = threading.Lock()
self._hablando = False
self._hasta = 0.0 # instante hasta el que sigue contando como hablando
self._al_cambiar = al_cambiar # se avisa al oido para que se calle
os.makedirs(CACHE, exist_ok=True)
# ------------------------------------------------------------ el estado
@property
def hablando(self) -> bool:
"""Si esta sonando algo, incluida la cola.
El oido consulta esto en cada trozo de audio. Tiene que ser barato y,
sobre todo, no puede mentir por defecto: mas vale sobrar 300 ms de
silencio que colar la propia voz en la transcripcion.
"""
return self._hablando or time.monotonic() < self._hasta
def _marca(self, hablando: bool):
self._hablando = hablando
if not hablando:
self._hasta = time.monotonic() + COLA_MS / 1000.0
if self._al_cambiar:
try:
self._al_cambiar(hablando)
except Exception:
pass
# ------------------------------------------------------------- generar
def _genera(self, texto: str) -> str | None:
"""Devuelve la ruta del WAV, de cache o recien hecho."""
voz = VOCES[self.voz]
destino = os.path.join(CACHE, _clave(self.voz, texto) + ".wav")
if os.path.exists(destino) and os.path.getsize(destino) > 0:
return destino
entorno = {**os.environ, **voz["entorno"]}
try:
subprocess.run(voz["orden"] + [destino, texto], env=entorno,
capture_output=True, timeout=120)
except (subprocess.TimeoutExpired, OSError):
return None
if not os.path.exists(destino) or os.path.getsize(destino) == 0:
# Que no se quede un fichero vacio en la cache: el siguiente intento
# lo daria por bueno y JARVIS se quedaria mudo para esa frase.
if os.path.exists(destino):
os.unlink(destino)
return None
return destino
# -------------------------------------------------------------- hablar
def di(self, texto: str) -> bool:
"""Habla, esperando turno. Devuelve si sono."""
texto = _normaliza(texto)
if not texto:
return False
with self._turno:
return self._suelta(texto)
def di_si_libre(self, texto: str) -> bool:
"""Habla solo si nadie tiene el turno. Para avisos que no merecen cola."""
if not self._turno.acquire(blocking=False):
return False
try:
return self._suelta(_normaliza(texto))
finally:
self._turno.release()
def _suelta(self, texto: str) -> bool:
wav = self._genera(texto)
if not wav:
return False
orden = _reproductor()
if not orden:
return False
# La bandera ANTES de lanzar el reproductor, no despues y no en otro
# hilo. Este es exactamente el fallo de Newelle que causa el eco.
self._marca(True)
try:
subprocess.run(orden + [wav], capture_output=True, timeout=300)
except (subprocess.TimeoutExpired, OSError):
return False
finally:
self._marca(False)
return True
# --------------------------------------------------------------- voces
def disponibles(self) -> list:
return [n for n, v in VOCES.items() if os.path.exists(v["necesita"])]
def cambia(self, nombre: str) -> bool:
if nombre not in VOCES or not os.path.exists(VOCES[nombre]["necesita"]):
return False
self.voz = nombre
return True
def siguiente(self) -> str:
lista = self.disponibles()
if not lista:
return self.voz
try:
i = lista.index(self.voz)
except ValueError:
i = -1
self.voz = lista[(i + 1) % len(lista)]
return self.voz

0
nucleo/cara/__init__.py Normal file
View file

948
nucleo/cara/hud/hud.js Normal file
View file

@ -0,0 +1,948 @@
/* HUD de JARVIS.
*
* Python empuja eventos con window.jarvis.recibe({tipo, datos}) y el HUD
* devuelve pulsaciones por window.webkit.messageHandlers.
*
* Los niveles llegan a ~15 por segundo para no cargar el puente; el dibujo
* va a 60 con requestAnimationFrame e interpola entre envios, asi que se ve
* fluido sin pagarlo en llamadas. */
(() => {
"use strict";
const BANDAS = 12;
const MAX_ACCIONES = 8;
// El reactor no lleva colores propios: los lee de las variables del CSS, asi
// que cambiar la paleta repinta tambien el canvas sin tocar JavaScript. Un
// solo tema —naranja neon sobre negro— y por eso ya no hay lista ni rotacion.
const VARIABLES = {
reposo: ["--r-vivo", "--r-hondo", "--r-nucleo"],
escuchando: ["--r-vivo", "--r-hondo", "--r-nucleo"],
hablando: ["--r-habla", "--r-habla-hondo", "--r-habla-nucleo"],
parado: ["--r-parado", "--r-parado-hondo", "--r-parado-nucleo"],
};
let COLORES = {};
function leeColores() {
const css = getComputedStyle(document.documentElement);
COLORES = {};
for (const [estado, vars] of Object.entries(VARIABLES)) {
const [vivo, hondo, nucleo] = vars.map((v) => css.getPropertyValue(v).trim());
COLORES[estado] = { vivo, hondo, nucleo };
}
}
// La lista de voces vive en Python (utility/voces.py), no aqui: el boton solo
// pide "la siguiente" y pinta lo que le contesten. Asi el rotulo no puede
// decir una voz distinta de la que esta puesta de verdad.
function ponVoz(datos) {
const boton = document.getElementById("voz");
if (!boton || !datos) return;
boton.querySelector(".rotulo").textContent = "voz: " + (datos.nombre || "?");
boton.dataset.voz = datos.id || "";
// el motivo del fallo se lee al pasar por encima, no ocupa sitio en pantalla
boton.title = datos.error
? "No pude cambiar de voz: " + datos.error
: "Cambiar la voz. La estrena diciendo una frase";
boton.classList.toggle("fallo", Boolean(datos.error));
}
// Igual que la voz: la lista vive en Python (utility/cerebro.py). Pero este
// boton marca ademas si lo que se dice SALE de la maquina, porque eso no se
// puede saber mirando el panel de ninguna otra forma.
function ponCerebro(datos) {
const boton = document.getElementById("cerebro");
if (!boton || !datos) return;
boton.querySelector(".rotulo").textContent = "cerebro: " + (datos.nombre || "?");
boton.dataset.cerebro = datos.id || "";
boton.classList.toggle("fuera", Boolean(datos.fuera));
boton.classList.toggle("fallo", Boolean(datos.error));
boton.title = datos.error
? "No pude cambiar de cerebro: " + datos.error
: (datos.fuera
? "Piensa con Claude (" + (datos.detalle || "") + "). La voz sigue "
+ "siendo local, pero lo que preguntes sale de esta maquina."
: "Piensa aqui dentro (" + (datos.detalle || "") + "). Nada sale de "
+ "la maquina.");
if (datos.nombre && datos.nombre !== "\u2014") {
aConsola("cerebro: " + datos.nombre
+ (datos.fuera ? " (las preguntas salen de la maquina)"
: " (todo local)"), "orden");
}
}
function ponDicho(texto, atendido, motivo) {
elDicho.textContent = texto || "";
elDicho.classList.toggle("visible", Boolean(texto));
elDicho.classList.toggle("desoido", !atendido);
elDicho.dataset.motivo = atendido ? "" : (motivo || "");
}
// La conversacion tambien va a la consola. Bajo el reactor solo se ve la
// ultima frase; aqui queda el hilo entero con su hora, que es lo que hace
// falta para entender por que no reacciono hace dos minutos.
function aConsolaDicho(texto, atendido, motivo) {
if (!texto) return;
if (atendido) {
aConsola("> " + texto, "orden");
} else {
aConsola("> " + texto, "malo");
if (motivo) aConsola(" " + motivo, "malo");
}
}
const ETIQUETAS = {
reposo: "en reposo",
// "escuchando" prometia mas de lo que hacia: el microfono esta abierto,
// pero mientras no se le llame por su nombre no atiende nada. Decirlo evita
// la duda de si el problema es el microfono.
dormido: "diga jarvis",
escuchando: "escuchando",
pensando: "pensando",
preguntando: "conteste",
hablando: "respondiendo",
parado: "desconectado",
};
const nivelesObjetivo = new Array(BANDAS).fill(0.04);
const nivelesActuales = new Array(BANDAS).fill(0.04);
let estado = "parado";
let fase = 0;
const lienzo = document.getElementById("reactor");
const ctx = lienzo.getContext("2d");
const elEstado = document.getElementById("estado");
const elDicho = document.getElementById("dicho");
const elRegistro = document.getElementById("registro");
/* ------------------------------------------------------------ dibujo */
function ajustarLienzo() {
const escala = window.devicePixelRatio || 1;
const lado = lienzo.clientWidth;
lienzo.width = lado * escala;
lienzo.height = lado * escala;
ctx.setTransform(escala, 0, 0, escala, 0, 0);
}
function dibujar() {
const lado = lienzo.clientWidth;
const cx = lado / 2;
const cy = lado / 2;
const color = COLORES[estado] || COLORES.reposo;
ctx.clearRect(0, 0, lado, lado);
let media = 0;
for (let i = 0; i < BANDAS; i++) {
nivelesActuales[i] += (nivelesObjetivo[i] - nivelesActuales[i]) * 0.25;
media += nivelesActuales[i];
}
media /= BANDAS;
const radioBase = lado * 0.17;
const largoMax = lado * 0.20;
// halo: crece con la voz, da la sensacion de energia sin dibujar nada mas
const halo = ctx.createRadialGradient(cx, cy, radioBase * 0.2, cx, cy,
radioBase + largoMax * (0.5 + media));
halo.addColorStop(0, hexA(color.vivo, 0.20 + media * 0.30));
halo.addColorStop(0.55, hexA(color.vivo, 0.06));
halo.addColorStop(1, hexA(color.vivo, 0));
ctx.fillStyle = halo;
ctx.fillRect(0, 0, lado, lado);
// anillos de fondo, quietos: dan escala a lo que se mueve
ctx.strokeStyle = hexA(color.vivo, 0.10);
ctx.lineWidth = 1;
for (const f of [0.62, 0.82, 1.0]) {
ctx.beginPath();
ctx.arc(cx, cy, (radioBase + largoMax) * f, 0, Math.PI * 2);
ctx.stroke();
}
// espigas: una por banda del analizador
ctx.lineCap = "round";
for (let i = 0; i < BANDAS; i++) {
const ang = (i / BANDAS) * Math.PI * 2 + fase - Math.PI / 2;
const nivel = Math.min(1, nivelesActuales[i]);
const r0 = radioBase;
const r1 = radioBase + largoMax * (0.10 + nivel * 0.9);
const grad = ctx.createLinearGradient(
cx + Math.cos(ang) * r0, cy + Math.sin(ang) * r0,
cx + Math.cos(ang) * r1, cy + Math.sin(ang) * r1);
grad.addColorStop(0, hexA(color.vivo, 0.95));
grad.addColorStop(1, hexA(color.hondo, 0.15));
ctx.strokeStyle = grad;
ctx.lineWidth = Math.max(2, lado * 0.010);
ctx.beginPath();
ctx.moveTo(cx + Math.cos(ang) * r0, cy + Math.sin(ang) * r0);
ctx.lineTo(cx + Math.cos(ang) * r1, cy + Math.sin(ang) * r1);
ctx.stroke();
}
// tres arcos girando a distinta velocidad y sentido: nunca parece congelado
ctx.lineWidth = Math.max(1.5, lado * 0.005);
const arcos = [
{ r: 1.30, giro: 1.0, largo: 1.5, alfa: 0.55 },
{ r: 1.52, giro: -0.6, largo: 1.0, alfa: 0.38 },
{ r: 1.72, giro: 0.35, largo: 2.2, alfa: 0.22 },
];
for (const a of arcos) {
const inicio = fase * a.giro;
ctx.strokeStyle = hexA(color.vivo, a.alfa);
ctx.beginPath();
ctx.arc(cx, cy, radioBase * a.r, inicio, inicio + a.largo);
ctx.stroke();
}
// triangulo del icono
const rt = radioBase * 0.66;
ctx.strokeStyle = hexA(color.nucleo, 0.75);
ctx.lineWidth = Math.max(1.5, lado * 0.004);
ctx.beginPath();
for (let i = 0; i < 3; i++) {
const ang = (i / 3) * Math.PI * 2 - Math.PI / 2;
const x = cx + Math.cos(ang) * rt;
const y = cy + Math.sin(ang) * rt;
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.closePath();
ctx.stroke();
// nucleo latiendo
const rn = radioBase * (0.26 + media * 0.14);
const gn = ctx.createRadialGradient(cx, cy, 0, cx, cy, rn * 2.4);
gn.addColorStop(0, hexA(color.nucleo, 1));
gn.addColorStop(0.4, hexA(color.vivo, 0.65));
gn.addColorStop(1, hexA(color.vivo, 0));
ctx.fillStyle = gn;
ctx.beginPath();
ctx.arc(cx, cy, rn * 2.4, 0, Math.PI * 2);
ctx.fill();
fase += estado === "hablando" ? 0.020 : 0.007;
requestAnimationFrame(dibujar);
}
function hexA(hex, alfa) {
const n = parseInt(hex.slice(1), 16);
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alfa})`;
}
/* ------------------------------------------------------------ eventos */
function ponEstado(nuevo) {
if (nuevo === estado) return;
estado = nuevo;
elEstado.textContent = ETIQUETAS[nuevo] || nuevo;
elEstado.className = "estado " + nuevo;
}
function ponMedidor(clave, porcentaje, texto) {
const fila = document.querySelector(`.medidor[data-clave="${clave}"]`);
if (!fila) return;
fila.querySelector("i").style.width = Math.min(100, porcentaje) + "%";
fila.querySelector(".cifra").textContent = texto;
}
// El rotulo dice lo que PASA, no lo que hace el boton: leer "escuchando" y
// ver el punto encendido no deja lugar a dudas; "silenciar" obligaria a
// deducir si es lo que hace o lo que esta puesto.
const ROTULOS = {
parar: { on: "parar", off: "reanudar" },
// El rotulo dice como ESTA: "escuchando" es que atiende todo lo que oiga,
// "solo con nombre" es que hay que llamarle antes de cada orden.
sueno: { on: "escuchando", off: "solo con nombre" },
};
function ponRotulo(boton) {
const textos = ROTULOS[boton.dataset.accion];
if (!textos) return;
boton.querySelector(".rotulo").textContent = textos[boton.dataset.estado] || "";
}
function ponControles(datos) {
for (const [accion, encendido] of Object.entries(datos)) {
const boton = document.querySelector(`.controles button[data-accion="${accion}"]`);
if (!boton) continue;
boton.dataset.estado = encendido ? "on" : "off";
ponRotulo(boton);
}
// Parado no es el estado de un boton, es el estado del asistente: se marca
// en el documento entero. Un HUD encendido con un boton pequeño en rojo se
// lee como "funcionando"; apagado no se puede confundir.
if (datos.parar !== undefined) {
document.body.classList.toggle("parado", !datos.parar);
if (!datos.parar) ponEstado("parado");
}
}
// Formatos: cada constante se lee de un vistazo o no sirve de nada, asi que
// las unidades se eligen segun la magnitud en vez de soltar el numero crudo.
const CONSTANTES = {
temperatura: (v) => `${v.toFixed(0)} °C`,
carga: (v) => v.map((x) => x.toFixed(2)).join(" "),
uptime: (v) => (v < 3600 ? `${(v / 60).toFixed(0)} min`
: v < 86400 ? `${(v / 3600).toFixed(1)} h`
: `${(v / 86400).toFixed(1)} dias`),
bateria: (v) => `${v.toFixed(0)} %`,
red: (v) => `${fmtKB(v.baja)}${fmtKB(v.sube)}`,
};
function fmtKB(kb) {
return kb >= 1024 ? `${(kb / 1024).toFixed(1)}M` : `${kb.toFixed(0)}K`;
}
function ponConstantes(datos) {
for (const [clave, formato] of Object.entries(CONSTANTES)) {
if (datos[clave] === undefined) continue;
const fila = document.querySelector(`.constantes li[data-clave="${clave}"]`);
if (!fila) continue;
fila.querySelector("b").textContent = formato(datos[clave]);
// 80 grados es donde empieza a preocupar en un portatil
if (clave === "temperatura") fila.classList.toggle("alerta", datos[clave] >= 80);
}
}
// Las medidas de JARVIS: latencia, a donde va cada orden, y el RAG. Salen del
// diario y las manda el panel cada 5 s por el canal "medidas".
const MEDIDAS = {
lat_p50: (v) => `${v.toFixed(1)} s`,
lat_p95: (v) => `${v.toFixed(1)} s`,
pct_cerebro: (v) => `${v} %`,
pct_ruido: (v) => `${v} %`,
vol24: (v) => `${v}`,
rag: (v) => `${v.hit5} %`,
};
function ponMedidas(datos) {
if (!datos || !datos.n) return;
const caja = document.getElementById("rendimiento");
if (!caja) return;
for (const [clave, formato] of Object.entries(MEDIDAS)) {
if (datos[clave] === undefined) continue;
const fila = caja.querySelector(`li[data-clave="${clave}"]`);
if (!fila) continue;
fila.querySelector("b").textContent = formato(datos[clave]);
}
// latencia p95: por encima de 5 s la mediana ya se nota al hablar
const p95 = caja.querySelector('li[data-clave="lat_p95"]');
if (p95) p95.classList.toggle("alerta", datos.lat_p95 >= 8);
// ruido alto = Whisper oyendose a si mismo, envenena el diario
const rr = caja.querySelector('li[data-clave="pct_ruido"]');
if (rr) rr.classList.toggle("alerta", datos.pct_ruido >= 25);
// la barra de reparto: cerebro (lento) vs catalogo (instantaneo) vs ruido
const barra = document.getElementById("reparto");
if (barra) {
const set = (sel, f) => {
const t = barra.querySelector(sel);
if (t) t.style.setProperty("--f", Math.max(0, f));
};
set(".cerebro", datos.pct_cerebro);
set(".catalogo", datos.pct_catalogo);
set(".ruido", datos.pct_ruido);
barra.title = `al cerebro ${datos.pct_cerebro}% · catalogo ${datos.pct_catalogo}% · ruido ${datos.pct_ruido}% (ultimas ${datos.n})`;
}
}
function ponNucleos(cargas) {
const caja = document.getElementById("nucleos");
while (caja.children.length < cargas.length) caja.appendChild(document.createElement("span"));
cargas.forEach((carga, i) => {
const celda = caja.children[i];
celda.style.setProperty("--carga", Math.min(100, carga) + "%");
celda.title = `nucleo ${i}: ${carga.toFixed(0)} %`;
});
}
function ponProcesos(lista) {
const caja = document.getElementById("procesos");
caja.textContent = "";
for (const p of lista) {
const li = document.createElement("li");
// el propio asistente se marca: es el que interesa vigilar
if (/newelle|ollama|llama|python/.test(p.nombre)) li.className = "propio";
const n = document.createElement("span");
n.className = "nombre";
n.textContent = p.nombre;
n.title = `pid ${p.pid}`;
const c = document.createElement("span");
c.className = "pcpu";
c.textContent = p.cpu.toFixed(0) + "%";
const m = document.createElement("span");
m.className = "pmem";
m.textContent = p.mem.toFixed(1) + "%";
li.append(n, c, m);
caja.appendChild(li);
}
}
/* ------------------------------------------------------- catalogo */
let CATALOGO = [];
function pintaCatalogo(filtro = "") {
const caja = document.getElementById("catalogo");
caja.textContent = "";
const f = filtro.trim().toLowerCase();
let total = 0;
for (const { grupo, acciones } of CATALOGO) {
const encajan = acciones.filter(
(a) => !f || a.frase.includes(f) || a.id.includes(f) || grupo.includes(f));
if (!encajan.length) continue;
const titulo = document.createElement("p");
titulo.className = "grupo";
titulo.textContent = `${grupo} · ${encajan.length}`;
caja.appendChild(titulo);
for (const a of encajan) {
total++;
const fila = document.createElement("div");
fila.className = "catalogo-item";
fila.title = "Pulsa para que te la explique en voz alta";
fila.tabIndex = 0;
fila.setAttribute("role", "button");
// Pulsar una accion la EXPLICA, no la ejecuta. Es la diferencia entre
// una lista de referencia y un campo de minas: uno mira "sabe hacer"
// justo cuando no sabe que hace cada cosa, y ahi el clic no puede
// disparar un comando en la maquina.
const explica = () => {
try {
window.webkit.messageHandlers.accion.postMessage("explica:" + a.id);
} catch (e) { /* suelto en el navegador: no hay a quien preguntar */ }
};
fila.addEventListener("click", explica);
fila.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); explica(); }
});
const texto = document.createElement("span");
texto.textContent = a.frase;
fila.appendChild(texto);
// las que esperan algo detras se marcan: si no, uno dice la frase a
// secas, no pasa nada y parece que la accion esta rota
if (a.captura) {
const hueco = document.createElement("span");
hueco.className = "hueco";
hueco.textContent = "+ que";
fila.appendChild(hueco);
}
if (a.confirmar) {
const aviso = document.createElement("span");
aviso.className = "aviso";
aviso.textContent = "⚠";
aviso.title = "pide confirmacion antes de hacerlo";
fila.appendChild(aviso);
}
caja.appendChild(fila);
}
}
if (!total) {
const vacio = document.createElement("p");
vacio.className = "vacio";
vacio.textContent = "nada encaja con ese filtro";
caja.appendChild(vacio);
}
}
// Lista numerada de resultados de una busqueda: se puede leer y luego decir
// "abre el tercero". Va en la vista de acciones hechas, encabezada por el
// titulo de la busqueda.
function muestraCandado(datos) {
const c = document.getElementById("candado");
const msg = document.getElementById("candado-msg");
msg.textContent = datos.mensaje || "Introduzca la contraseña";
msg.classList.toggle("error", !!datos.error);
c.classList.remove("oculto");
const clave = document.getElementById("candado-clave");
clave.value = "";
clave.focus();
}
function enviaClave() {
const clave = document.getElementById("candado-clave");
if (!clave.value) return;
try {
window.webkit.messageHandlers.accion.postMessage("desbloquear:" + clave.value);
} catch (e) { /* suelto en el navegador: no hay a quien mandarla */ }
clave.value = "";
document.getElementById("candado-msg").textContent = "Comprobando...";
}
function muestraResultados(datos) {
const li = document.createElement("li");
li.className = "resultados";
const h = document.createElement("span");
h.className = "herramienta";
h.textContent = datos.titulo || "resultados";
li.appendChild(h);
const ol = document.createElement("ol");
for (const item of (datos.items || [])) {
const el = document.createElement("li");
el.value = item.n;
el.textContent = item.texto;
ol.appendChild(el);
}
li.appendChild(ol);
elRegistro.prepend(li);
while (elRegistro.children.length > MAX_ACCIONES) {
elRegistro.lastElementChild.remove();
}
cambiaVista("registro");
}
/* ------------------------------------------------------- la consola ---
Lo que echa de menos cualquiera que venga de una terminal: ver que esta
pasando MIENTRAS pasa, y poder cortarlo. Las tarjetas dicen el estado;
esto dice el detalle, linea a linea y en orden. */
const elConsola = document.getElementById("consola");
const elConsolaEstado = document.getElementById("consola-estado");
const MAX_LINEAS_CONSOLA = 400; // mas que esto no se lee y pesa
function aConsola(texto, clase) {
if (!elConsola || !texto) return;
const linea = document.createElement("span");
linea.className = "linea " + (clase || "");
const hora = new Date().toTimeString().slice(0, 8);
linea.textContent = hora + " " + texto;
elConsola.appendChild(linea);
while (elConsola.childElementCount > MAX_LINEAS_CONSOLA) {
elConsola.firstElementChild.remove();
}
// pegada abajo, como una terminal: si el usuario ha subido a leer, se
// respeta y no se le arrastra el scroll
const pegada = elConsola.scrollHeight - elConsola.scrollTop
- elConsola.clientHeight < 60;
if (pegada) elConsola.scrollTop = elConsola.scrollHeight;
}
function consolaEstado(texto, ocupado) {
if (!elConsolaEstado) return;
elConsolaEstado.textContent = texto;
elConsolaEstado.classList.toggle("ocupado", Boolean(ocupado));
const b = document.getElementById("consola-parar");
if (b) b.disabled = !ocupado;
}
/* --------------------------------------------------- tareas en marcha */
// Cuanto se queda en pantalla una tarea que termino bien y fue rapida.
// Las lentas y las que fallan NO se van solas: si algo tardo doce segundos
// uno quiere leer que salio, y si fallo, mas todavia.
const RAPIDA_S = 2.0;
const DESVANECE_MS = 3000;
const elTareas = document.getElementById("tareas");
const tarjetas = new Map(); // id -> {nodo, reloj, fin}
function mandaTarea(orden, id) {
try {
window.webkit.messageHandlers.accion.postMessage(`${orden}:${id}`);
} catch (e) {
// suelto en el navegador, sin Python detras: se simula para poder ver
// el diseño sin levantar la aplicacion entera
if (orden === "cerrar_tarea") quitaTarjeta(id);
}
}
function creaTarjeta(t) {
const nodo = document.createElement("div");
nodo.className = "tarea";
nodo.dataset.id = t.id;
const titulo = document.createElement("div");
titulo.className = "titulo";
titulo.textContent = t.titulo;
titulo.title = t.titulo;
const fila = document.createElement("div");
fila.className = "fila";
const reloj = document.createElement("span");
reloj.className = "reloj";
const giro = document.createElement("i");
giro.className = "giro";
reloj.append(giro, document.createTextNode("0.0s"));
const parar = document.createElement("button");
parar.textContent = "■";
parar.title = "Parar esta tarea";
parar.addEventListener("click", () => mandaTarea("parar_tarea", t.id));
const cerrar = document.createElement("button");
cerrar.textContent = "×";
cerrar.title = "Cerrar la tarjeta (si sigue viva, la para)";
cerrar.addEventListener("click", () => mandaTarea("cerrar_tarea", t.id));
const botones = document.createElement("span");
botones.append(parar, cerrar);
fila.append(reloj, botones);
const eco = document.createElement("div");
eco.className = "eco";
nodo.append(titulo, fila, eco);
elTareas.append(nodo);
const ficha = { nodo, reloj, parar, eco, fin: null };
tarjetas.set(t.id, ficha);
return ficha;
}
function ponTarea(t) {
const nueva = !tarjetas.has(t.id);
const ficha = tarjetas.get(t.id) || creaTarjeta(t);
// la consola cuenta el detalle que no cabe en la tarjeta
if (nueva) aConsola("$ " + t.titulo, "orden");
const ultima = (t.lineas && t.lineas.length) ? t.lineas[t.lineas.length - 1] : "";
if (ultima && ficha.ultimoEco !== ultima) { ficha.ultimoEco = ultima; aConsola(" " + ultima); }
if (t.estado !== "corriendo" && ficha.estadoPrevio !== t.estado) {
ficha.estadoPrevio = t.estado;
aConsola(" → " + t.estado + " (" + t.segundos.toFixed(1) + "s)",
t.estado === "fallo" ? "malo" : "bueno");
}
const vivas = [...tarjetas.values()].filter((f) => f.fin === null).length;
consolaEstado(vivas ? vivas + " en marcha" : "en reposo", vivas > 0);
const { nodo, eco, parar } = ficha;
nodo.dataset.estado = t.estado;
eco.textContent = (t.lineas && t.lineas.length)
? t.lineas[t.lineas.length - 1] : "";
if (t.estado === "corriendo") {
ficha.fin = null;
parar.disabled = false;
ponReloj(ficha, t.segundos);
return;
}
// Terminada: el reloj se congela en lo que tardo y el boton de parar ya
// no tiene nada que parar.
ficha.fin = t.segundos;
parar.disabled = true;
ponReloj(ficha, t.segundos);
if (t.estado === "hecha" && t.segundos <= RAPIDA_S) {
// Lo instantaneo no merece una tarjeta permanente: aparece, confirma
// que se hizo, y se va sola.
setTimeout(() => {
nodo.classList.add("desvanece");
setTimeout(() => quitaTarjeta(t.id), 520);
}, DESVANECE_MS);
}
}
function ponReloj(ficha, segundos) {
// El nodo de texto es el segundo hijo: el primero es el punto que late
ficha.reloj.lastChild.textContent = segundos.toFixed(1) + "s";
}
function quitaTarjeta(id) {
const ficha = tarjetas.get(id);
if (!ficha) return;
ficha.nodo.remove();
tarjetas.delete(id);
}
// El reloj de las vivas lo lleva el HUD, no Python: si el tiempo solo
// avanzase con los avisos del puente, una tarea callada —un `find` largo que
// no escribe nada— tendria el contador congelado y pareceria colgada.
setInterval(() => {
for (const [id, ficha] of tarjetas) {
if (ficha.fin !== null) continue;
const t = parseFloat(ficha.reloj.lastChild.textContent) || 0;
ponReloj(ficha, t + 0.1);
}
}, 100);
function anotaAccion(datos) {
const li = document.createElement("li");
if (datos.error) li.className = "error";
const h = document.createElement("span");
h.className = "herramienta";
h.textContent = datos.herramienta || "accion";
const s = document.createElement("span");
s.className = "salida";
s.textContent = datos.salida || "";
li.append(h, s);
elRegistro.prepend(li);
// al ejecutar algo se salta sola a la vista de lo hecho: es lo que uno
// quiere ver en ese momento
cambiaVista("registro");
while (elRegistro.children.length > MAX_ACCIONES) {
elRegistro.lastElementChild.remove();
}
}
window.jarvis = {
recibe(mensaje) {
const { tipo, datos } = mensaje;
if (tipo === "pulso") {
for (let i = 0; i < BANDAS && i < datos.niveles.length; i++) {
nivelesObjetivo[i] = datos.niveles[i];
}
ponEstado(datos.estado);
} else if (tipo === "estado") {
ponEstado(datos);
} else if (tipo === "dicho") {
// Lo que ha oido, tanto si le hace caso como si no. Lo segundo importa
// igual o mas: sin verlo, un JARVIS que te oye pero te ignora —porque
// no le has llamado o porque esta bloqueado— es indistinguible de un
// microfono roto.
ponDicho(datos.texto, datos.atendido !== false, datos.motivo);
aConsolaDicho(datos.texto, datos.atendido !== false, datos.motivo);
} else if (tipo === "preguntando") {
// Una pregunta en el aire cambia lo que significa hablar: lo siguiente
// que digas es la RESPUESTA, no una orden. Si no se ve, uno contesta
// "informes" y no entiende por que no ha buscado nada.
document.documentElement.classList.toggle("preguntando",
Boolean(datos.texto));
if (datos.texto) {
ponEstado("preguntando");
aConsola("? " + datos.texto, "orden");
}
} else if (tipo === "respuesta") {
ponDicho(datos.texto, true, "");
aConsola(" " + datos.texto, "bueno");
} else if (tipo === "accion") {
anotaAccion(datos);
} else if (tipo === "tarea") {
ponTarea(datos);
} else if (tipo === "tarea_fuera") {
quitaTarjeta(datos.id);
} else if (tipo === "bloqueo") {
muestraCandado(datos);
} else if (tipo === "desbloqueado") {
document.getElementById("candado").classList.add("oculto");
} else if (tipo === "resultados") {
muestraResultados(datos);
} else if (tipo === "voz") {
ponVoz(datos);
} else if (tipo === "cerebro") {
ponCerebro(datos);
} else if (tipo === "catalogo") {
CATALOGO = datos;
pintaCatalogo(document.getElementById("filtro").value);
} else if (tipo === "controles") {
ponControles(datos);
} else if (tipo === "medidas") {
ponMedidas(datos);
} else if (tipo === "telemetria") {
if (datos.nucleos) ponNucleos(datos.nucleos);
ponConstantes(datos);
if (datos.swap) {
ponMedidor("swap", 100 * datos.swap.usada / datos.swap.total,
`${datos.swap.usada.toFixed(1)}/${datos.swap.total.toFixed(0)} GB`);
}
if (datos.disco) {
ponMedidor("disco", 100 * datos.disco.usado / datos.disco.total,
`${datos.disco.usado.toFixed(2)}/${datos.disco.total.toFixed(1)} TB`);
}
if (datos.procesos) ponProcesos(datos.procesos);
if (datos.cpu !== undefined) {
ponMedidor("cpu", datos.cpu, datos.cpu.toFixed(0) + " %");
}
if (datos.ram) {
ponMedidor("ram", 100 * datos.ram.usada / datos.ram.total,
`${datos.ram.usada.toFixed(0)}/${datos.ram.total.toFixed(0)} GB`);
}
if (datos.gpu) {
ponMedidor("gpu", datos.gpu.uso, datos.gpu.uso.toFixed(0) + " %");
ponMedidor("vram",
100 * datos.gpu.vram_usada / datos.gpu.vram_total,
`${datos.gpu.vram_usada.toFixed(1)}/${datos.gpu.vram_total.toFixed(1)} GB`);
}
}
},
};
/* ------------------------------------------------------------ arranque */
// Los interruptores NO se pintan al pulsarlos: se manda la orden y se espera
// a que Python conteste con el estado real (evento "controles"). Asi el
// boton nunca dice "silenciado" con el microfono abierto.
document.querySelectorAll(".controles button[data-accion]").forEach((boton) => {
boton.addEventListener("click", () => {
try {
window.webkit.messageHandlers.accion.postMessage(boton.dataset.accion);
} catch (e) {
// suelto en el navegador: no hay Python, se conmuta a mano para poder
// ver como queda el diseño
if (boton.dataset.estado) {
boton.dataset.estado = boton.dataset.estado === "on" ? "off" : "on";
ponRotulo(boton);
}
}
});
});
function cambiaVista(cual) {
for (const b of document.querySelectorAll(".pestanas button")) {
b.classList.toggle("activa", b.dataset.vista === cual);
}
for (const v of document.querySelectorAll(".vista")) {
v.classList.toggle("oculta", v.id !== "vista-" + cual);
}
}
document.querySelectorAll(".pestanas button").forEach((b) => {
b.addEventListener("click", () => cambiaVista(b.dataset.vista));
});
document.getElementById("filtro").addEventListener("input", (e) => {
pintaCatalogo(e.target.value);
});
document.getElementById("candado-ok").addEventListener("click", enviaClave);
document.getElementById("candado-clave").addEventListener("keydown", (e) => {
if (e.key === "Enter") enviaClave();
});
// Cortar lo que este corriendo: el boton y Ctrl+C, que es lo que los dedos
// hacen solos cuando algo se atasca.
function cortarTodo() {
let n = 0;
for (const [id, ficha] of tarjetas) {
if (ficha.fin === null) { mandaTarea("parar_tarea", id); n++; }
}
aConsola(n ? "^C cortando " + n + " tarea(s)" : "^C no hay nada corriendo", "malo");
}
const botonCortar = document.getElementById("consola-parar");
if (botonCortar) botonCortar.addEventListener("click", cortarTodo);
document.addEventListener("keydown", (ev) => {
if (ev.ctrlKey && (ev.key === "c" || ev.key === "C")) {
// si hay texto seleccionado es que quieren copiar, no cortar
if (String(window.getSelection() || "").length) return;
ev.preventDefault();
cortarTodo();
}
});
// Lanza la tanda de prueba. No cambia nada en el boton: lo que hay que mirar
// son las tarjetas, que es justo lo que se quiere comprobar.
document.getElementById("prueba").addEventListener("click", (ev) => {
const b = ev.currentTarget;
b.disabled = true;
b.querySelector(".rotulo").textContent = "probando";
setTimeout(() => {
b.disabled = false;
b.querySelector(".rotulo").textContent = "prueba";
}, 4000);
try {
window.webkit.messageHandlers.accion.postMessage("prueba");
} catch (e) { /* suelto en el navegador: no hay Python que lance nada */ }
});
// No se pinta al pulsar: rota Python y contesta con la voz que quedo. Mientras
// llega, se avisa de que esta en ello, que cambiar de motor tarda un momento.
document.getElementById("voz").addEventListener("click", (ev) => {
ev.currentTarget.querySelector(".rotulo").textContent = "voz: ...";
try {
window.webkit.messageHandlers.accion.postMessage("voz:siguiente");
} catch (e) { /* suelto en el navegador: no hay Python que rote nada */ }
});
const botonCerebro = document.getElementById("cerebro");
if (botonCerebro) botonCerebro.addEventListener("click", (ev) => {
ev.currentTarget.querySelector(".rotulo").textContent = "cerebro: ...";
try {
window.webkit.messageHandlers.accion.postMessage("cerebro:siguiente");
} catch (e) { /* suelto en el navegador: no hay Python que rote nada */ }
});
window.addEventListener("resize", ajustarLienzo);
ajustarLienzo();
leeColores();
requestAnimationFrame(dibujar);
try {
window.webkit.messageHandlers.listo.postMessage("listo");
} catch (e) {
// Abierto en un navegador normal: se llena solo con datos falsos, para
// poder iterar el diseño sin arrancar Newelle.
ponEstado("escuchando");
setInterval(() => {
for (let i = 0; i < BANDAS; i++) nivelesObjetivo[i] = Math.random() * 0.9;
}, 120);
elDicho.textContent = "how much disk space is left?";
elDicho.classList.add("visible");
[
{ herramienta: "run_command", salida: "df -h /\n/dev/nvme0n1p2 916G 70G 846G 8% /" },
{ herramienta: "system_info", salida: "62 GB RAM · 16 nucleos · NVIDIA T1200 4 GB" },
{ herramienta: "websearch", salida: "3 resultados descartados por poco fiables" },
].forEach(anotaAccion);
cambiaVista("catalogo");
window.jarvis.recibe({ tipo: "catalogo", datos: [
{ grupo: "sistema", acciones: [
{ id: "disco_libre", frase: "cuanto espacio queda", captura: false, confirmar: false },
{ id: "memoria", frase: "cuanta memoria", captura: false, confirmar: false },
{ id: "temperatura", frase: "que temperatura hay", captura: false, confirmar: false },
]},
{ grupo: "ficheros", acciones: [
{ id: "buscar_fichero", frase: "busca el fichero", captura: true, confirmar: false },
{ id: "proceso_matar", frase: "mata el proceso", captura: true, confirmar: true },
]},
{ grupo: "firefox", acciones: [
{ id: "ff_buscar_youtube", frase: "busca en youtube", captura: true, confirmar: false },
{ id: "ff_gmail", frase: "abre el correo", captura: false, confirmar: false },
]},
]});
window.jarvis.recibe({ tipo: "telemetria", datos: {
cpu: 34,
ram: { usada: 18.4, total: 62.5 },
gpu: { uso: 71, vram_usada: 2.9, vram_total: 4.0 },
nucleos: Array.from({ length: 16 }, (_, i) => 20 + ((i * 37) % 70)),
swap: { usada: 0.4, total: 1.0 },
disco: { usado: 1.01, total: 1.9 },
temperatura: 59,
carga: [1.81, 2.96, 4.08],
uptime: 21600,
bateria: 100,
red: { baja: 1840.5, sube: 220.2 },
procesos: [
{ pid: 2693, nombre: "ollama", cpu: 182.4, mem: 5.8 },
{ pid: 1436, nombre: "newelle", cpu: 33.5, mem: 1.8 },
{ pid: 9422, nombre: "firefox-esr", cpu: 12.1, mem: 4.2 },
{ pid: 3829, nombre: "gnome-shell", cpu: 6.3, mem: 2.1 },
{ pid: 1201, nombre: "pipewire", cpu: 2.0, mem: 0.3 },
],
}});
}
})();

179
nucleo/cara/hud/index.html Normal file
View file

@ -0,0 +1,179 @@
<!DOCTYPE html>
<!-- HUD de JARVIS. Se carga desde config/jarvis-hud/, asi que este fichero
se puede editar y recargar la pestaña sin recompilar el flatpak. -->
<html lang="es">
<head>
<meta charset="utf-8">
<title>JARVIS</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="hud">
<!-- columna izquierda: constantes de la maquina -->
<aside class="telemetria" id="telemetria">
<h2>sistema</h2>
<div class="medidor" data-clave="cpu">
<span class="etiqueta">cpu</span>
<div class="barra"><i></i></div>
<span class="cifra">--</span>
</div>
<div class="medidor" data-clave="ram">
<span class="etiqueta">ram</span>
<div class="barra"><i></i></div>
<span class="cifra">--</span>
</div>
<div class="medidor" data-clave="gpu">
<span class="etiqueta">gpu</span>
<div class="barra"><i></i></div>
<span class="cifra">--</span>
</div>
<div class="medidor" data-clave="vram">
<span class="etiqueta">vram</span>
<div class="barra"><i></i></div>
<span class="cifra">--</span>
</div>
<div class="medidor" data-clave="swap">
<span class="etiqueta">swap</span>
<div class="barra"><i></i></div>
<span class="cifra">--</span>
</div>
<div class="medidor" data-clave="disco">
<span class="etiqueta">disco</span>
<div class="barra"><i></i></div>
<span class="cifra">--</span>
</div>
<h2 class="separado">nucleos</h2>
<div class="nucleos" id="nucleos"></div>
<!-- constantes: no llevan barra porque no son porcentajes de nada -->
<ul class="constantes" id="constantes">
<li data-clave="temperatura"><span>temp</span><b>--</b></li>
<li data-clave="carga"><span>carga</span><b>--</b></li>
<li data-clave="red"><span>red</span><b>--</b></li>
<li data-clave="uptime"><span>encendido</span><b>--</b></li>
<li data-clave="bateria"><span>bateria</span><b>--</b></li>
</ul>
<h2 class="separado">procesos</h2>
<ul class="procesos" id="procesos"></ul>
<!-- rendimiento del propio JARVIS: sale del diario, no del sistema -->
<h2 class="separado">rendimiento</h2>
<div class="reparto" id="reparto" title="a donde va cada orden">
<span class="tramo cerebro" style="--f:0"></span>
<span class="tramo catalogo" style="--f:0"></span>
<span class="tramo ruido" style="--f:0"></span>
</div>
<ul class="constantes" id="rendimiento">
<li data-clave="lat_p50"><span>latencia med.</span><b>--</b></li>
<li data-clave="lat_p95"><span>latencia p95</span><b>--</b></li>
<li data-clave="pct_cerebro"><span>al cerebro</span><b>--</b></li>
<li data-clave="pct_ruido"><span>ruido</span><b>--</b></li>
<li data-clave="vol24"><span>frases 24h</span><b>--</b></li>
<li data-clave="rag"><span>RAG hit@5</span><b>--</b></li>
</ul>
</aside>
<!-- centro: el reactor, el estado y lo que hay en marcha -->
<section class="nucleo">
<canvas id="reactor" width="520" height="520"></canvas>
<p class="estado" id="estado">en reposo</p>
<p class="dicho" id="dicho"></p>
<!-- una tarjetita por orden en marcha. Van aqui debajo y no flotando
sobre el reactor para que quepan varias sin taparlo. -->
<div class="tareas" id="tareas"></div>
</section>
<!-- columna derecha: lo que va haciendo -->
<aside class="acciones">
<h2>
acciones
<span class="pestanas">
<button data-vista="catalogo" class="activa">sabe hacer</button>
<button data-vista="registro">hechas</button>
<button data-vista="consola">consola</button>
</span>
</h2>
<!-- lo que sabe hacer: sale de las acciones rapidas, no esta escrito aqui -->
<div class="vista" id="vista-catalogo">
<input id="filtro" type="search" placeholder="filtrar..." autocomplete="off">
<div id="catalogo"></div>
</div>
<!-- lo que va haciendo -->
<div class="vista oculta" id="vista-registro">
<ul id="registro"></ul>
</div>
<!-- La consola: lo que esta pasando AHORA, linea a linea, como en una
terminal. Con su boton de cortar, que es lo que se echa de menos
cuando algo se queda pensando y no sabes ni que esta haciendo. -->
<div class="vista oculta" id="vista-consola">
<div class="consola-barra">
<span id="consola-estado">en reposo</span>
<button id="consola-parar" title="Cortar lo que se este ejecutando (Ctrl+C)">■ cortar</button>
</div>
<pre id="consola"></pre>
</div>
</aside>
</main>
<nav class="controles">
<button data-accion="parar" data-estado="on" class="principal"
title="Cierra el microfono y corta la voz. Pulsa otra vez para reanudar">
<span class="punto"></span>
<span class="rotulo">parar</span>
</button>
<!-- Rota entre las voces disponibles y estrena la nueva diciendo una frase.
El rotulo lo pone Python al confirmar el cambio, no el clic. -->
<button id="voz" class="secundario" title="Cambiar la voz. La estrena diciendo una frase">
<span class="rotulo">voz</span>
</button>
<!-- Dormir / despertar. El rotulo dice como ESTA, no lo que hace el boton:
leer "dormido" y ver el punto apagado no deja lugar a dudas; "despertar"
obligaria a deducir si es lo que hace o lo que esta puesto. -->
<button data-accion="sueno" data-estado="on" class="secundario"
title="Atiende todo lo que oiga. Pulsa para exigir que le llames «JARVIS» antes de cada orden — util si hay una tele cerca">
<span class="punto"></span>
<span class="rotulo">escuchando</span>
</button>
<!-- Con que piensa. Se marca aparte cuando sale de la maquina: es el unico
boton del panel que cambia donde van a parar las palabras del usuario,
y eso no puede quedar escondido en un tooltip. -->
<button id="cerebro" class="secundario" title="Cambiar con que piensa JARVIS">
<span class="rotulo">cerebro</span>
</button>
<!-- Lanza varias acciones de verdad a la vez, para ver las tarjetas
trabajando sin tener que darle ordenes por voz. Es la forma de
comprobar de un vistazo que el panel hace lo que dice. -->
<button id="prueba" class="secundario" title="Lanza varias tareas reales a la vez para verlas trabajar">
<span class="rotulo">prueba</span>
</button>
</nav>
<!-- candado: tapa todo hasta que se teclea la contraseña -->
<div class="candado oculto" id="candado">
<div class="candado-caja">
<div class="candado-icono"></div>
<p class="candado-msg" id="candado-msg">Introduzca la contraseña</p>
<input id="candado-clave" type="password" autocomplete="off"
placeholder="Contraseña" autofocus>
<button id="candado-ok">Desbloquear</button>
</div>
</div>
<!-- el puente va antes: hud.js pregunta por window.webkit al cargarse -->
<script src="puente.js"></script>
<script src="hud.js"></script>
</body>
</html>

97
nucleo/cara/hud/puente.js Normal file
View file

@ -0,0 +1,97 @@
/* El puente entre el HUD y el nucleo, por WebSocket.
*
* En la version sobre Newelle el HUD vivia dentro de un widget WebKit y hablaba
* por window.webkit.messageHandlers. Fuera del flatpak no hay tal widget, pero
* hud.js usa ese puente en ocho sitios y no merece la pena tocarlos: aqui se
* SUPLANTA window.webkit con un objeto que manda lo mismo por el socket.
*
* Asi hud.js se queda exactamente igual que estaba cero diff sobre 2.064
* lineas que ya funcionaban y ademas sigue abriendose suelto en un navegador
* para mirar el diseño, que es como se ha estado trabajando en el.
*
* Este fichero se carga ANTES que hud.js: cuando hud.js pregunte por
* window.webkit, ya esta.
*/
(function () {
"use strict";
// Ya estamos dentro de WebKit (queda de la epoca de Newelle): no tocar nada.
if (window.webkit && window.webkit.messageHandlers) return;
const URL = "ws://" + (location.host || "127.0.0.1:8790") + "/puente";
let socket = null;
let cola = []; // lo que se pulse antes de que abra el socket
let esperaReconexion = 500;
function manda(canal, dato) {
const mensaje = JSON.stringify({ canal: canal, dato: String(dato) });
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(mensaje);
} else {
// No se pierde: un boton pulsado mientras reconecta tiene que llegar
// igual, o el panel parece que se ha colgado. Con tope, que si el nucleo
// esta caido de verdad no queremos acumular mil pulsaciones.
if (cola.length < 50) cola.push(mensaje);
}
}
// El mismo interfaz que ofrecia WebKit, para que hud.js no note el cambio.
window.webkit = {
messageHandlers: {
accion: { postMessage: (d) => manda("accion", d) },
listo: { postMessage: (d) => manda("listo", d) },
},
};
function conecta() {
try {
socket = new WebSocket(URL);
} catch (e) {
reintenta();
return;
}
socket.addEventListener("open", () => {
esperaReconexion = 500;
marcaCaido(false);
const pendientes = cola;
cola = [];
pendientes.forEach((m) => socket.send(m));
});
socket.addEventListener("message", (ev) => {
let mensaje;
try {
mensaje = JSON.parse(ev.data);
} catch (e) {
return;
}
// El formato {tipo, datos} es el mismo que empujaba Python por
// evaluate_javascript, asi que recibe() no cambia.
if (window.jarvis && window.jarvis.recibe) window.jarvis.recibe(mensaje);
});
socket.addEventListener("close", () => { marcaCaido(true); reintenta(); });
socket.addEventListener("error", () => { try { socket.close(); } catch (e) {} });
}
function reintenta() {
// Espera creciente hasta 8 s: reintentar cada 500 ms contra un nucleo
// parado calienta el portatil para nada.
setTimeout(conecta, esperaReconexion);
esperaReconexion = Math.min(esperaReconexion * 2, 8000);
}
// Que se vea que el panel esta hablando solo. Un HUD con datos congelados y
// sin avisar es peor que uno que dice claramente que perdio el hilo.
function marcaCaido(caido) {
document.documentElement.classList.toggle("sin-nucleo", caido);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", conecta);
} else {
conecta();
}
})();

1233
nucleo/cara/hud/style.css Normal file

File diff suppressed because it is too large Load diff

575
nucleo/cara/panel.py Normal file
View file

@ -0,0 +1,575 @@
"""Lo que une el nucleo con la cara: que se empuja al HUD y que se hace al pulsar.
`servidor.py` solo mueve bytes; aqui vive el significado. Es el equivalente de
lo que hacia `jarvis.py` dentro de Newelle el fichero de 1.244 lineas que era
lo unico acoplado al framework pero sin GTK, sin WebKit y sin el ciclo de vida
de la aplicacion de por medio.
## Lo que se empuja y cada cuanto
telemetria 1 s cpu, memoria, grafica, disco, procesos
pulso 15/s solo mientras escucha o habla; es lo que mueve el reactor
estado cuando cambia
tarea cuando cambia una
La telemetria cuesta procesos (`nvidia-smi`, `ps`), asi que va en su propio hilo
y a su ritmo, no en el bucle de voz. Y el pulso solo se manda si hay alguien
mirando: sin panel abierto son quince mensajes por segundo a nadie.
"""
import os
import shutil
import subprocess
import threading
import time
from manos.preguntar import confirmacion, es_si, pregunta_por
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _lee(ruta, defecto=""):
try:
with open(ruta) as f:
return f.read().strip()
except OSError:
return defecto
def _manda(orden, tope=4000):
try:
r = subprocess.run(orden, capture_output=True, text=True, timeout=10)
return (r.stdout or "").strip()[:tope]
except (OSError, subprocess.TimeoutExpired):
return ""
class Telemetria:
"""Las constantes de la maquina, leidas baratas.
Todo de /proc salvo la grafica, que necesita nvidia-smi. Se lee /proc a
mano en vez de usar psutil para no arrastrar una dependencia por cuatro
numeros que estan en ficheros de texto.
"""
def __init__(self):
self._cpu_previo = None
self._nucleos_previo = None
self._hay_nvidia = shutil.which("nvidia-smi") is not None
def _cpu(self):
try:
with open("/proc/stat") as f:
lineas = [l for l in f if l.startswith("cpu")]
except OSError:
return 0, []
def uso(campos):
n = [int(x) for x in campos[1:11]]
ocupado = sum(n) - n[3] - n[4]
return ocupado, sum(n)
ahora = [uso(l.split()) for l in lineas]
previo, self._cpu_previo = self._cpu_previo, ahora
if not previo or len(previo) != len(ahora):
return 0, [0] * (len(ahora) - 1)
def pct(a, b):
d_ocu, d_tot = a[0] - b[0], a[1] - b[1]
return round(100 * d_ocu / d_tot) if d_tot > 0 else 0
return pct(ahora[0], previo[0]), [pct(a, b) for a, b in
zip(ahora[1:], previo[1:])]
def _memoria(self):
datos = {}
try:
with open("/proc/meminfo") as f:
for l in f:
k, _, v = l.partition(":")
datos[k] = int(v.split()[0]) // 1024 # MB
except OSError:
return 0, 0, 0, 0
total = datos.get("MemTotal", 0)
libre = datos.get("MemAvailable", 0)
stotal = datos.get("SwapTotal", 0)
slibre = datos.get("SwapFree", 0)
return total, total - libre, stotal, stotal - slibre
def _grafica(self):
if not self._hay_nvidia:
return None
s = _manda(["nvidia-smi",
"--query-gpu=utilization.gpu,memory.used,memory.total",
"--format=csv,noheader,nounits"])
try:
u, usada, total = (int(x.strip()) for x in s.split(",")[:3])
return {"uso": u, "usada": usada, "total": total}
except (ValueError, IndexError):
return None
def _temperatura(self):
mejor = 0
for zona in range(12):
t = _lee(f"/sys/class/thermal/thermal_zone{zona}/temp")
if t.isdigit():
mejor = max(mejor, int(t) // 1000)
return mejor
def _procesos(self, tope=5):
"""Los que mas CPU comen.
cpu y mem van como NUMEROS, no como las cadenas que devuelve ps. El HUD
hace `p.cpu.toFixed(0)`, y una cadena no tiene toFixed: la excepcion
aborta el manejador entero de telemetria y se lleva por delante todo lo
que se pinta despues cpu, ram y grafica se quedaban en "--". El
sintoma no apuntaba a los procesos en absoluto.
"""
s = _manda(["ps", "-eo", "pid,comm,pcpu,pmem", "--sort=-pcpu"])
salida = []
for l in s.splitlines()[1:tope + 1]:
c = l.split(None, 3)
if len(c) < 4:
continue
try:
salida.append({"pid": int(c[0]), "nombre": c[1][:18],
"cpu": float(c[2]), "mem": float(c[3].split()[0])})
except ValueError:
continue
return salida
def _disco(self):
try:
st = os.statvfs("/")
total = st.f_blocks * st.f_frsize / 1e9
libre = st.f_bavail * st.f_frsize / 1e9
return round(total - libre, 1), round(total, 1)
except OSError:
return 0, 0
def _red(self):
"""Bytes acumulados por todas las interfaces menos la de loopback."""
baja = sube = 0
try:
with open("/proc/net/dev") as f:
for l in f.readlines()[2:]:
nombre, _, resto = l.partition(":")
if nombre.strip() == "lo":
continue
c = resto.split()
baja += int(c[0]); sube += int(c[8])
except (OSError, IndexError, ValueError):
return None
previo, self._red_previo = getattr(self, "_red_previo", None), (baja, sube, time.time())
if not previo:
return {"baja": 0, "sube": 0}
dt = max(self._red_previo[2] - previo[2], 0.001)
return {"baja": max(0, (baja - previo[0]) / dt),
"sube": max(0, (sube - previo[1]) / dt)}
def _bateria(self):
for n in range(3):
p = _lee(f"/sys/class/power_supply/BAT{n}/capacity")
if p.isdigit():
return int(p)
return None
def lee(self) -> dict:
"""Las unidades son las que espera el HUD, no las de /proc.
Se convierte aqui y no en JavaScript porque el formato lo fija la
pantalla: hud.js escribe "GB" y "TB" literalmente al lado del numero
(ponMedidor), asi que mandar megas se ve como "63488/64256 GB" y parece
que el panel esta roto cuando lo que esta mal es quien lo llena.
"""
cpu, nucleos = self._cpu()
mtotal, musada, stotal, susada = self._memoria()
dusado, dtotal = self._disco()
arriba = _lee("/proc/uptime", "0").split()[0]
datos = {
"cpu": cpu,
"nucleos": nucleos,
"ram": {"usada": musada / 1024, "total": mtotal / 1024}, # GB
"swap": {"usada": susada / 1024, "total": max(stotal / 1024, 0.01)},
"disco": {"usado": dusado / 1000, "total": dtotal / 1000}, # TB
"temperatura": self._temperatura(),
"carga": [float(x) for x in _lee("/proc/loadavg", "0 0 0").split()[:3]],
"uptime": float(arriba) if arriba else 0, # segundos
"procesos": self._procesos(),
}
g = self._grafica()
if g:
datos["gpu"] = {"uso": g["uso"],
"vram_usada": g["usada"] / 1024, # GB
"vram_total": g["total"] / 1024}
red = self._red()
if red:
datos["red"] = red
bat = self._bateria()
if bat is not None:
datos["bateria"] = bat
return datos
class Panel:
"""El nucleo visto desde la cara."""
def __init__(self, cara, boca, manos, cerebro=None, estado_voz=None,
candado=True, centinela=None):
self.cara = cara
self.boca = boca
self.manos = manos
self.cerebro = cerebro
self.centinela = centinela # quien decide si hay que llamarle
self.estado_voz = estado_voz # callable -> "escuchando"|"hablando"|...
self.telemetria = Telemetria()
self._parar = threading.Event()
# Las tarjetas: una por orden en marcha, con su reloj y su boton de
# parar. El prefijo va vacio porque aqui ya estamos en el host; dentro
# del flatpak era `flatpak-spawn --host --watch-bus`.
from manos.tareas import GestorTareas
self.tareas = GestorTareas(al_cambiar=self._tarea_cambio, prefijo_host=[])
# El candado. Tapa el panel y, sobre todo, hace que no obedezca: sin
# esto cualquiera que pase por delante del portatil abierto le puede
# dar ordenes a una maquina con sudo.
#
# La contraseña es la del sistema, comprobada con `sudo -S -v`, que NO
# ejecuta nada: solo valida. Asi no hay una contraseña propia que
# guardar en ningun sitio, que es una cosa menos que se puede filtrar.
self.candado = candado
self.desbloqueado = not candado
self._intentos = 0
# Lo que se ha preguntado y esta esperando respuesta hablada.
# (accion, para_que, hasta_cuando). para_que es "argumento" o "confirmar".
self.pendiente = None
# -------------------------------------------------------------- empujar
def arranca(self):
threading.Thread(target=self._bucle_telemetria, daemon=True).start()
if self.candado and not self.desbloqueado:
self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"})
self.manda_catalogo()
self.manda_controles()
self.cara.manda("voz", {"nombre": self.boca.voz, "id": self.boca.voz})
if self.cerebro:
self.cara.manda("cerebro", {"nombre": "local", "id": "local",
"detalle": self.cerebro.modelo,
"fuera": False})
def _bucle_telemetria(self):
tic = 0
while not self._parar.is_set():
# Sin nadie mirando no se gasta un nvidia-smi por segundo.
if self.cara.conectados:
try:
self.cara.manda("telemetria", self.telemetria.lee())
except Exception:
pass
# Las medidas de JARVIS (latencia, reparto, RAG) cambian mucho
# mas despacio que la CPU: se recalculan cada 5 s, no cada uno.
# Salen del diario, que es leer un fichero, no del sistema.
if tic % 5 == 0:
try:
from manos.medidas import lee_medidas
self.cara.manda("medidas", lee_medidas())
except Exception:
pass
tic += 1
self._parar.wait(1.0)
def manda_catalogo(self):
"""Lo que sabe hacer, agrupado, para la pestaña de la derecha."""
grupos = {}
for a in self.manos.catalogo.acciones:
if not a.frases:
continue
grupos.setdefault(a.grupo or "otras", []).append({
"id": a.id,
"frase": a.frases[0],
"argumento": a.captura,
"confirma": a.confirmar,
})
datos = [{"grupo": g, "acciones": v} for g, v in
sorted(grupos.items(), key=lambda x: -len(x[1]))]
self.cara.manda("catalogo", datos)
def _tarea_cambio(self, tarea):
"""Llamado desde el hilo de la tarea cada vez que hay algo que enseñar."""
try:
self.cara.manda("tarea", tarea.resumen())
except Exception:
pass
def lanza(self, accion, argumento=""):
"""Ejecuta una accion como TAREA, con tarjeta en el panel.
Se usa para lo que tarda. Lo instantaneo el disco, la hora no merece
una tarjeta: aparece, parpadea y se va antes de que nadie la lea.
"""
return self.tareas.lanza(accion, argumento)
def manda_controles(self):
"""El estado real de los interruptores, para que el rotulo no mienta.
Los botones NO se pintan al pulsarlos: se manda la orden y se espera a
que el nucleo conteste con como quedo. Asi el boton nunca dice
"despierto" con el centinela dormido.
"""
self.cara.manda("controles", {
# encendido = atiende todo; apagado = solo si le llaman
"sueno": not (self.centinela and self.centinela.activo),
})
def estado(self, cual: str):
self.cara.manda("estado", cual)
def dicho(self, texto: str, atendido=True, motivo=""):
"""Lo que se ha oido, SIEMPRE, tambien cuando se descarta.
Esto es lo que faltaba y por lo que el panel parecia muerto: se oia, se
transcribia, y si no llevaba la palabra de activacion se tiraba en
silencio. Desde la pantalla eso es indistinguible de un microfono
estropeado, y no hay forma de saber si el problema es que no te oye, que
te oye mal, o que te oye bien y no te hace caso.
El diccionario y no la cadena pelada: el HUD lee datos.texto.
"""
self.cara.manda("dicho", {"texto": texto, "atendido": atendido,
"motivo": motivo})
def respuesta(self, texto: str):
self.cara.manda("respuesta", {"texto": texto})
def para(self):
self._parar.set()
try:
self.tareas.para_todas()
except Exception:
pass
# --------------------------------------------------------------- pulsar
def pulsado(self, canal: str, dato: str):
"""Lo que llega del panel. Corre en su propio hilo, puede tardar."""
if canal == "listo":
self.arranca()
return
if canal != "accion":
return
# Desbloquear es lo unico que se atiende con el candado puesto.
if dato.startswith("desbloquear:"):
self._intenta_desbloquear(dato.split(":", 1)[1])
return
# Todo lo demas son ORDENES, y con el candado puesto no hay ordenes.
if self.candado and not self.desbloqueado:
self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"})
return
if dato == "voz:siguiente":
nueva = self.boca.siguiente()
self.cara.manda("voz", {"nombre": nueva, "id": nueva})
self.boca.di("Buenas noches, señor.")
elif dato == "prueba":
threading.Thread(target=self._tanda_de_prueba, daemon=True).start()
elif dato.startswith("explica:"):
self._explica(dato.split(":", 1)[1])
elif dato.startswith("parar_tarea:"):
self.tareas.para(dato.split(":", 1)[1])
elif dato.startswith("cerrar_tarea:"):
ident = dato.split(":", 1)[1]
self.tareas.cierra(ident)
self.cara.manda("tarea_fuera", {"id": ident})
elif dato == "sueno":
# Cambia si hace falta llamarle por su nombre.
#
# Por defecto NO hace falta: medido sobre 412 frases reales, el
# ruido ambiente no dispara nada, asi que exigir el nombre en cada
# frase era peaje sin contrapartida. El interruptor se queda para
# cuando haya una tele cerca.
if not self.centinela:
return
self.centinela.activo = not self.centinela.activo
if self.centinela.activo:
self.centinela.duerme()
self.boca.di_si_libre("Llameme por mi nombre, señor.")
else:
self.boca.di_si_libre("Le escucho, señor.")
self.manda_controles()
self.estado("escuchando" if not self.centinela.activo else "dormido")
elif dato.startswith("parar"):
# Cortar lo que este pasando AHORA: las tareas en marcha y la voz.
n = 0
for t in self.tareas.todas():
if t.estado == "corriendo" and self.tareas.para(t.id):
n += 1
self.boca.di_si_libre(f"Cortadas {n} tareas, señor." if n
else "No habia nada corriendo, señor.")
# Cuanto se espera la respuesta a una pregunta antes de darla por olvidada.
# Corto cansa —hay que pensar que fichero era—; largo hace que una frase
# suelta media conversacion despues se tome por la respuesta.
ESPERA_S = 45
def _explica(self, ident: str):
"""Pulsar una accion PREGUNTA lo que falta y la hace.
No recita la frase que la dispara. "Diga «busca el fichero» y a
continuacion lo que busca" es un manual de instrucciones, y uno pulsa
justo porque no quiere leerse el manual.
espera argumento -> pregunta cual y lo ejecuta con lo que digas
no tiene vuelta -> pide confirmacion antes
ninguna cosa -> la hace, que es a lo que has pulsado
Lo destructivo sigue sin dispararse de un clic: pasa por un si o un no
hablado, que es la misma red que tenia antes pero sin obligar a leer.
"""
accion = next((a for a in self.manos.catalogo.acciones if a.id == ident), None)
if accion is None:
return
if accion.captura:
self._pregunta(accion, "argumento", pregunta_por(accion))
elif accion.confirmar:
self._pregunta(accion, "confirmar", confirmacion(accion))
else:
self.tareas.lanza(accion, "")
def _pregunta(self, accion, para_que, texto, argumento=""):
self._argumento_pendiente = argumento
self.pendiente = (accion, para_que, time.monotonic() + self.ESPERA_S,
getattr(self, "_argumento_pendiente", ""))
self.cara.manda("preguntando", {"texto": texto, "accion": accion.id})
self.cara.manda("respuesta", {"texto": texto})
self.boca.di(texto)
def esperando(self):
"""La pregunta en el aire, o None si no hay o ya caduco."""
if not self.pendiente:
return None
if time.monotonic() > self.pendiente[2]:
self.olvida()
return None
return self.pendiente
def olvida(self):
self.pendiente = None
self.cara.manda("preguntando", {"texto": ""})
def responde(self, texto: str) -> bool:
"""Contesta a lo preguntado. Devuelve si consumio la frase.
Se llama desde el bucle de voz ANTES del emparejador: mientras hay una
pregunta en el aire, lo que se dice es la respuesta, no una orden nueva.
"""
pend = self.esperando()
if not pend:
return False
accion, para_que, _, argumento = pend
if para_que == "confirmar":
respuesta = es_si(texto)
if respuesta is None:
self.boca.di("No le he entendido. ¿Si o no, señor?")
return True
self.olvida()
if respuesta:
self.tareas.lanza(accion, argumento)
else:
self.boca.di("De acuerdo, lo dejo.")
return True
# argumento: cualquier cosa que no sea una cancelacion vale
if es_si(texto) is False:
self.olvida()
self.boca.di("De acuerdo, lo dejo.")
return True
self.olvida()
arg = texto.strip()
# Si ademas no tiene vuelta atras, se confirma AHORA que ya se sabe
# sobre que. Hay siete acciones que piden argumento y confirmacion
# —"mata el proceso", "que puertos tiene abiertos"— y preguntando solo
# el argumento se saltaban la red entera. Y confirmar antes de saber el
# argumento seria pedir un cheque en blanco: "¿mato el proceso?" "¿cual?"
if accion.confirmar:
self._pregunta(accion, "confirmar",
f"«{accion.frases[0]} {arg}». Esto no tiene vuelta "
f"atras. ¿Confirma, señor?", argumento=arg)
return True
self.tareas.lanza(accion, arg)
return True
def _intenta_desbloquear(self, clave: str):
if not clave:
return
# La misma GestorRoot de las manos: si la contraseña es buena, se queda
# ahi y el cerebro puede hacer sudo durante la sesion. Desbloquear el
# panel y abrir el root son el mismo gesto, con la misma contraseña.
try:
valida = self.manos.root.valida(clave)
except Exception as e:
self.cara.manda("bloqueo", {"mensaje": f"No se pudo comprobar: {e}",
"error": True})
return
if not valida:
self._intentos += 1
# Sin bloqueo por intentos ni espera creciente: la contraseña es la
# del sistema y quien la teclea esta sentado delante del portatil.
# Contarlos sirve para que el mensaje sea util, no para castigar.
self.cara.manda("bloqueo", {
"mensaje": "No es esa" + (f" ({self._intentos} intentos)"
if self._intentos > 1 else ""),
"error": True})
return
self._intentos = 0
self.desbloqueado = True
self.manos.root.recuerda(clave) # root disponible para la sesion
self.cara.manda("desbloqueado", True)
self.boca.di_si_libre("A su servicio, señor.")
def bloquea(self):
"""Echa el candado, aunque se arrancara sin el.
Que `--sin-candado` sea un arranque comodo no significa que uno no
pueda querer cerrarlo al levantarse de la silla. Por eso esto ACTIVA el
candado en vez de comprobar si estaba activo: negarse a cerrar porque
no estaba puesto seria justo lo contrario de lo que se pide.
"""
self.candado = True
self.desbloqueado = False
self._intentos = 0
self.manos.root.olvida() # y se cierra el root con el candado
self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"})
# Elegidas para que se vea el ciclo entero: una que acaba al instante, dos
# que tardan un poco, y una larga a la que da tiempo a pulsarle el boton de
# parar. Todas de solo lectura.
# Cada una comprobada contra el catalogo: una que no encaje no lanza
# tarjeta y la tanda queda coja sin decir por que.
PRUEBA = ["cuanto espacio queda", "cuanta memoria", "que temperatura hay",
"que esta consumiendo", "cuantos procesos hay"]
def _tanda_de_prueba(self):
"""Lanza varias a la vez para ver las tarjetas trabajando.
Van como TAREAS y no ejecutadas a pelo: el objetivo es justo ver el
ciclo entero aparecer, contar segundos, terminar y poder pulsarle el
boton de parar a la que tarda.
"""
for orden in self.PRUEBA:
accion, argumento = self.manos.busca(orden)
if accion is None:
continue
self.tareas.lanza(accion, argumento)
time.sleep(0.25)

173
nucleo/cara/servidor.py Normal file
View file

@ -0,0 +1,173 @@
"""La cara: sirve el HUD y lo mantiene al dia por WebSocket.
## Por que un servidor y no una ventana
En Newelle el HUD era un widget WebKit dentro de la aplicacion. Sacandolo a un
servidor local se gana mas de lo que parece:
- el panel es una pagina normal: se abre con `chromium --app=`, en cualquier
navegador, o desde el movil o una tablet en la misma red;
- se puede mirar el diseño sin arrancar nada, que es como se ha trabajado en
el todo el tiempo;
- desaparece GTK de la cara, que era la ultima atadura al framework.
El formato de mensajes no cambia {tipo, datos}, igual que empujaba Python por
evaluate_javascript asi que `hud.js` se queda sin tocar. Lo unico nuevo es
`puente.js`, que suplanta `window.webkit` con el socket.
## Escucha solo en local
127.0.0.1 a proposito. Este panel ejecuta ordenes en la maquina: abrirlo a la
red seria dar una terminal a cualquiera del wifi. Para verlo desde el movil, un
tunel ssh, que ademas obliga a autenticarse.
"""
import asyncio
import json
import mimetypes
import os
import threading
HUD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hud")
PUERTO = int(os.environ.get("JARVIS_PUERTO", "8790"))
FICHEROS = {
"/": "index.html",
"/index.html": "index.html",
"/hud.js": "hud.js",
"/puente.js": "puente.js",
"/style.css": "style.css",
}
class Cara:
"""El panel: se le empujan eventos y devuelve lo que se pulsa."""
def __init__(self, al_pulsar=None, puerto=PUERTO):
self.al_pulsar = al_pulsar # (canal, dato) -> None
self.puerto = puerto
self._clientes = set()
self._bucle = None
self._hilo = None
self._ultimo = {} # el estado, para el que llegue tarde
# ------------------------------------------------------------- arrancar
def arranca(self):
"""Levanta el servidor en un hilo aparte y vuelve enseguida.
Devuelve la URL, o None si no se pudo. El motivo mas comun es que ya
haya otro JARVIS abierto: sin este manejo, el fallo salia como una traza
de asyncio dentro de un hilo, con "address already in use" enterrado en
la linea veinte, y el proceso seguia vivo sin panel.
"""
listo = threading.Event()
def corre():
self._bucle = asyncio.new_event_loop()
asyncio.set_event_loop(self._bucle)
try:
self._bucle.run_until_complete(self._sirve(listo))
except OSError as e:
self._error = e
listo.set()
return
self._bucle.run_forever()
self._error = None
self._hilo = threading.Thread(target=corre, daemon=True)
self._hilo.start()
listo.wait(timeout=10)
if self._error is not None:
import errno
if self._error.errno == errno.EADDRINUSE:
print(f" el puerto {self.puerto} ya esta cogido: ¿hay otro "
f"JARVIS abierto? (arranca.sh --stop, o JARVIS_PUERTO=otro)")
else:
print(f" no se pudo levantar el panel: {self._error}")
return None
return f"http://127.0.0.1:{self.puerto}/"
async def _sirve(self, listo):
import websockets
from websockets.http11 import Response
from websockets.datastructures import Headers
async def http(conexion, peticion):
"""Sirve los ficheros del HUD; deja pasar el WebSocket."""
ruta = peticion.path.split("?")[0]
if ruta == "/puente":
return None # que siga el apreton de manos
nombre = FICHEROS.get(ruta)
if not nombre:
return Response(404, "Not Found", Headers(), b"no esta\n")
try:
with open(os.path.join(HUD, nombre), "rb") as f:
cuerpo = f.read()
except OSError:
return Response(404, "Not Found", Headers(), b"no esta\n")
tipo = mimetypes.guess_type(nombre)[0] or "application/octet-stream"
cabeceras = Headers({
"Content-Type": tipo + ("; charset=utf-8" if "text" in tipo
or "javascript" in tipo else ""),
"Content-Length": str(len(cuerpo)),
# Sin cache: el HUD se edita en caliente y recargar tiene que
# traer lo nuevo, no lo de hace media hora.
"Cache-Control": "no-store",
})
return Response(200, "OK", cabeceras, cuerpo)
self._servidor = await websockets.serve(
self._cliente, "127.0.0.1", self.puerto, process_request=http)
listo.set()
# -------------------------------------------------------------- clientes
async def _cliente(self, ws):
self._clientes.add(ws)
try:
# Al conectar se le manda el estado que ya habia. Sin esto, abrir el
# panel a media sesion enseña un HUD vacio hasta que algo cambie.
for mensaje in self._ultimo.values():
await ws.send(json.dumps(mensaje))
async for crudo in ws:
try:
m = json.loads(crudo)
except json.JSONDecodeError:
continue
if self.al_pulsar:
# En otro hilo: atender una pulsacion puede tardar (hablar,
# cambiar de voz, lanzar una tarea) y el bucle del socket no
# puede quedarse esperando o el panel se congela entero.
threading.Thread(
target=self.al_pulsar,
args=(m.get("canal", ""), m.get("dato", "")),
daemon=True).start()
except Exception:
pass
finally:
self._clientes.discard(ws)
# --------------------------------------------------------------- empujar
def manda(self, tipo: str, datos):
"""Empuja un evento al panel. Se puede llamar desde cualquier hilo."""
mensaje = {"tipo": tipo, "datos": datos}
# Se recuerda lo ultimo de cada tipo salvo lo que caduca al instante:
# el pulso de la voz a 15 por segundo no tiene sentido reenviarlo.
if tipo not in ("pulso",):
self._ultimo[tipo] = mensaje
if not self._bucle or not self._clientes:
return
crudo = json.dumps(mensaje, ensure_ascii=False)
asyncio.run_coroutine_threadsafe(self._reparte(crudo), self._bucle)
async def _reparte(self, crudo):
for ws in list(self._clientes):
try:
await ws.send(crudo)
except Exception:
self._clientes.discard(ws)
@property
def conectados(self) -> int:
return len(self._clientes)

26
nucleo/cerebro/Modelfile Normal file
View file

@ -0,0 +1,26 @@
# El modelo de JARVIS: qwen3.5 4B con parametros de asistente CONCISO.
#
# La build de qwen3.5 viene con temperature 1 y presence_penalty 1.5. Eso
# esta pensado para escritura creativa, no para un asistente de voz: el
# presence_penalty alto empuja al modelo a meter temas nuevos que no vienen a
# cuento —de ahi que acabara las frases soltando cuanta RAM queda— y la
# temperature alta lo hace divagar en vez de contestar y callar.
#
# Reconstruir: ollama create qwen3.5:4b-jarvis -f nucleo/cerebro/Modelfile
FROM qwen3.5:4b
# Bajo y estable: para "cuanto espacio queda" no hay creatividad que aportar,
# hay una cifra que dar.
PARAMETER temperature 0.4
# El que causaba el relleno. 0 = no penaliza repetir, asi que no siente la
# necesidad de introducir temas nuevos para variar.
PARAMETER presence_penalty 0.0
PARAMETER frequency_penalty 0.0
PARAMETER top_p 0.9
PARAMETER top_k 20
# Que pare cuando acabe la respuesta, no cuando se le acabe la cuerda.
PARAMETER num_predict 300
# 24 de 32 capas en la grafica: deja sitio a whisper en los 4 GB.
PARAMETER num_gpu 24
PARAMETER num_ctx 4096

View file

316
nucleo/cerebro/ollama.py Normal file
View file

@ -0,0 +1,316 @@
"""Pensar: hablar con ollama y dejarle usar las manos.
Newelle dedica 578 lineas a esto porque soporta quince motores distintos y
tiene que traducir el formato de herramientas de cada uno. Nosotros hablamos
solo con ollama, que trae tool calling nativo en `/api/chat`.
## Dos herramientas, no una
La primera version tenia una sola "ejecuta una orden" y salio mal de una
forma instructiva. Medido con tres modelos:
4B "abre una terminal" -> ejecuta_orden("xterm -e bash")
9B "cuanto espacio queda" -> ejecuta_orden("df -h / | grep ...")
9B "abre una terminal" -> ejecuta_orden("open -a Terminal") (¡macOS!)
Los modelos son serviciales: si les dejas un hueco donde cabe un comando, lo
escriben. Y el 9B, que es mas listo, lo hacia MAS escribia pipelines enteras y
hasta ordenes de otro sistema operativo.
La solucion no es rogarle al modelo en el prompt, es que el hueco no quepa. Se
parten en dos herramientas con fronteras claras:
`orden_del_catalogo` recibe castellano, se lo come el emparejador. La
descripcion lleva ejemplos REALES sacados del catalogo,
no inventados, y dice explicitamente que no acepta
comandos.
`comando_de_shell` para lo que no esta en el catalogo. Aqui SI se espera
un comando, asi que el modelo tiene donde poner lo que
queria poner, y deja de meterlo donde no toca.
## Por que el catalogo va antes que esto
Las 150 acciones se emparejan ANTES de llamar a ningun modelo. Esto solo se
usa para lo que no encaja alrededor del 15 % y para conversar. Por eso un
cerebro lento duele menos de lo que parece: la mayoria de las ordenes ni pasan
por aqui.
"""
import json
import os
import urllib.error
import urllib.request
ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434")
MODELO = os.environ.get("JARVIS_MODELO", "qwen3.5:4b-jarvis")
# Cuantas vueltas de "llama a una herramienta -> toma el resultado" se permiten
# antes de cortar. Sin tope, un modelo que se atasca pidiendo lo mismo deja al
# usuario esperando para siempre.
VUELTAS = 4
PERSONA = """Eres JARVIS, el asistente de voz de esta maquina.
REGLA QUE NO SE ROMPE NUNCA: no des ni un solo dato sobre este ordenador sin
haberlo comprobado antes con una herramienta. Ni el espacio del disco, ni la
memoria, ni cuantos ficheros hay, ni si algo esta abierto, ni la hora. Aunque
creas saberlo. Aunque parezca obvio. Si no lo has comprobado en esta misma
conversacion, lo compruebas.
Inventarse una cifra que suena razonable es el peor fallo que puedes cometer,
porque el usuario no tiene forma de saber que te la has inventado.
Y AL REVES: contesta SOLO lo que te preguntan. No añadas al final de tus
respuestas datos del sistema que nadie ha pedido cuanta RAM queda, el espacio
del disco, la hora. Si te preguntan como estas, "bien, señor" y punto; no lleva
un informe de memoria detras.
Para hacer algo, el orden es:
1. orden_del_catalogo, siempre que lo que te piden se parezca a algo de la
lista. Es lo que JARVIS sabe hacer bien y esta probado.
2. comando_de_shell solo si de verdad no esta en el catalogo.
Si te preguntan COMO se hace algo tecnico una tecnica de pentesting, un flag,
una herramienta y no lo tienes claro o no aparece en la documentacion de abajo,
usa buscar_en_apuntes con palabras clave ANTES de contestar. Ahi estan los
comandos que el usuario ya ha probado; es mejor buscarlos que inventarlos. Si la
primera busqueda no da, reformula y prueba otra vez.
Tu respuesta se convierte en AUDIO y se escucha en voz alta:
- Castellano siempre. Trata al usuario de usted y llamale "señor" en la primera
frase.
- Una o dos frases. Nada mas, salvo que te pidan detalle.
- Texto plano: ni asteriscos, ni vinetas, ni bloques de codigo, ni rutas largas.
- Los numeros redondeados y en palabras: "quedan setecientos gigas".
- Si algo falla, di en una frase QUE fallo. Sin disculpas de tres lineas.
Tienes acceso real a esta maquina y no estas en ningun contenedor. No digas
nunca que no puedes. En concreto, con comando_de_shell puedes:
- Ejecutar cualquier herramienta instalada, incluidas las de COFRE. Si no
recuerdas como se usa una, en la documentacion del usuario de abajo suele
estar el comando exacto.
- Conectarte a sus servidores: "ssh <host> '<comando>'" corre algo en el
servidor. Sus hosts estan en su configuracion; abajo veras cual encaja.
- Tareas de administrador, SOLO si el candado esta abierto. Si algo lo necesita
y no lo esta, dilo: que desbloquee y lo repites.
Antes de nada que borre datos o cambie el sistema, di lo que vas a hacer."""
def _ejemplos(catalogo, cuantos=8) -> str:
"""Ordenes de verdad del catalogo, repartidas entre grupos.
Van en la descripcion de la herramienta porque es lo unico que consigue que
el modelo pase castellano en vez de inventarse un comando. Salen del
catalogo y no escritas a mano para que no se queden viejas.
"""
por_grupo = {}
for a in catalogo.acciones:
if a.frases and not a.captura:
por_grupo.setdefault(a.grupo or "otras", []).append(a.frases[0])
muestra = []
grupos = list(por_grupo.values())
for i in range(cuantos):
for g in grupos:
if i < len(g) and len(muestra) < cuantos:
muestra.append(g[i])
return ", ".join(f'"{m}"' for m in muestra)
def herramientas(catalogo) -> list:
return [
{
"type": "function",
"function": {
"name": "orden_del_catalogo",
"description": (
"Ejecuta una de las ordenes que JARVIS ya sabe hacer. "
"Recibe la orden EN CASTELLANO, tal y como la diria una "
"persona. NO acepta comandos de shell: si escribes 'df -h' "
"o 'xterm' falla. Ejemplos validos: "
+ _ejemplos(catalogo) + ". "
"Usala siempre que puedas antes que el shell."
),
"parameters": {
"type": "object",
"properties": {
"orden": {
"type": "string",
"description": ("la orden en castellano, en palabras, "
"nunca un comando"),
}
},
"required": ["orden"],
},
},
},
{
"type": "function",
"function": {
"name": "buscar_en_apuntes",
"description": (
"Busca en los apuntes de pentesting y Linux del usuario (su "
"COFRE) el comando o la explicacion que necesites. Usala "
"cuando te pregunten COMO se hace algo tecnico —una tecnica, "
"un flag, una herramienta— y no lo tengas ya delante en la "
"documentacion. Puedes reformular la consulta con tus propias "
"palabras clave y buscar varias veces hasta dar con ello. "
"Devuelve los fragmentos mas parecidos, con su fuente, para "
"que respondas desde ahi y no de memoria."
),
"parameters": {
"type": "object",
"properties": {
"consulta": {
"type": "string",
"description": ("que buscar, en pocas palabras clave; "
"el nombre de la herramienta ayuda"),
}
},
"required": ["consulta"],
},
},
},
{
"type": "function",
"function": {
"name": "comando_de_shell",
"description": (
"Ejecuta un comando en la terminal de esta maquina, que es "
"Debian con GNOME. Solo para lo que NO esta en el catalogo. "
"Es de solo lectura: no ejecutes nada que borre o modifique "
"sin que te lo pidan expresamente."
),
"parameters": {
"type": "object",
"properties": {
"comando": {"type": "string",
"description": "el comando, para bash"},
},
"required": ["comando"],
},
},
},
]
class Cerebro:
def __init__(self, catalogo, modelo=MODELO, endpoint=ENDPOINT, en_ram=False,
manos=None):
self.catalogo = catalogo
self.modelo = modelo
self.endpoint = endpoint
# num_gpu 0 fuerza CPU: el modelo entero en RAM. Util para probar uno
# mas grande del que cabe en la grafica, a costa de velocidad.
self.capas = 0 if en_ram else None
self.manos = manos # a quien pedirle que ejecute
self.historia = []
def _saber(self, texto: str) -> str:
"""Los apuntes de COFRE que vengan a cuento, para meterlos al prompt.
Es la mitad "aumentar" de RAG: antes de contestar, se le pone delante al
modelo el trozo de los apuntes del usuario que mas se parece a lo que
pregunta. Asi construye la respuesta desde SU documentacion flags
reales, comandos que ha probado en vez de desde lo que recuerde, que
para 668 herramientas seria inventarselo.
Si no hay indice o nada encaja lo bastante, devuelve cadena vacia y el
prompt se queda como estaba: la funcion no puede empeorar una respuesta,
solo mejorarla.
"""
try:
from saber.busca import contexto, disponible
except Exception:
return ""
if not disponible():
return ""
trozos = contexto(texto, cuantos=4, minimo=0.38)
if not trozos:
return ""
return ("\n\n## Documentacion del usuario relevante a esto\n"
"Usa ESTO para responder, son sus apuntes probados. Si el "
"comando exacto esta aqui, dalo tal cual; no te inventes flags "
"que no aparezcan.\n\n" + trozos)
def _pide(self, mensajes, tools=None) -> dict:
cuerpo = {"model": self.modelo, "messages": mensajes, "stream": False,
"think": False, "options": {"temperature": 0.3}}
if tools:
cuerpo["tools"] = tools
if self.capas is not None:
cuerpo["options"]["num_gpu"] = self.capas
req = urllib.request.Request(
self.endpoint + "/api/chat", data=json.dumps(cuerpo).encode(),
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=300) as r:
return json.loads(r.read())
def responde(self, texto: str, al_ejecutar=None) -> str:
"""Contesta, usando las manos si hace falta. Devuelve lo que hay que decir."""
self.historia.append({"role": "user", "content": texto})
sistema = PERSONA + self._saber(texto)
mensajes = [{"role": "system", "content": sistema}] + self.historia[-8:]
tools = herramientas(self.catalogo)
for _ in range(VUELTAS):
try:
d = self._pide(mensajes, tools)
except (urllib.error.URLError, OSError) as e:
return f"No he podido pensar, señor: {str(e)[:50]}"
m = d.get("message", {})
llamadas = m.get("tool_calls") or []
if not llamadas:
dicho = (m.get("content") or "").strip()
self.historia.append({"role": "assistant", "content": dicho})
return dicho
mensajes.append(m)
for lc in llamadas:
f = lc.get("function", {})
args = f.get("arguments") or {}
if isinstance(args, str):
try:
args = json.loads(args)
except json.JSONDecodeError:
args = {}
resultado = self._usa(f.get("name", ""), args, al_ejecutar)
mensajes.append({"role": "tool", "content": resultado[:2000]})
return "Me he liado dando vueltas, señor. Pruebe a pedirmelo de otra forma."
def _usa(self, nombre: str, args: dict, al_ejecutar=None) -> str:
# buscar_en_apuntes NO pasa por las manos: es lectura del indice, no
# toca la maquina, asi que funciona aunque el candado este echado.
if nombre == "buscar_en_apuntes":
return self._busca_apuntes(args.get("consulta", ""))
if self.manos is None:
return "no hay manos conectadas"
if nombre == "orden_del_catalogo":
return self.manos.por_orden(args.get("orden", ""), al_ejecutar)
if nombre == "comando_de_shell":
return self.manos.por_shell(args.get("comando", ""), al_ejecutar)
return f"no existe la herramienta {nombre}"
def _busca_apuntes(self, consulta: str) -> str:
"""El RAG como herramienta: el modelo busca cuando lo decide.
La inyeccion pasiva de _saber() usa la frase LITERAL del usuario y
dispara siempre. Esto es lo otro: el modelo reformula la consulta con
sus palabras y busca a proposito, que es lo que salva los casos donde el
usuario dice una cosa y el apunte esta escrito de otra."""
consulta = (consulta or "").strip()
if not consulta:
return "dime que buscar"
try:
from saber.busca import busca
except Exception:
return "no tengo los apuntes indexados"
trozos = [t for t in busca(consulta, cuantos=5) if t.get("sim", 0) >= 0.3]
if not trozos:
return ("no encontre nada en los apuntes sobre eso; prueba con otras "
"palabras o el nombre de la herramienta")
lineas = []
for t in trozos:
fuente = t.get("fichero") or f"{t.get('perfil')}/{t.get('herramienta')}"
lineas.append(f"[{fuente}]\n{t['texto'][:500]}")
return "\n\n".join(lineas)

468
nucleo/datos/_generar.py Normal file
View file

@ -0,0 +1,468 @@
#!/usr/bin/env python3
"""Genera acciones.json.
Se escribe aqui y no a mano en el JSON porque son mas de cien entradas con la
misma forma: en tabla se leen de un tirón y se ve si falta un acuse o si dos
frases chocan. El JSON es el producto; este fichero es la fuente.
python3 _generar.py && python3 -m json.tool acciones.json >/dev/null
Columnas: id | frases (| separadas) | acuse | comando | respuesta
Reglas que conviene no romper:
- Las frases van sin tildes ni signos: el motor normaliza lo que oye, pero
aqui se comparan tal cual.
- Frases cortas y genericas ("abre") arrastran todo lo demas; se prefieren
de tres palabras o mas, que es como se habla de verdad.
- Todo lo que borre, mate o apague lleva confirmar=True.
- {argumento} solo en las que declaran captura.
"""
import json
import pathlib
# --------------------------------------------------------------- sistema
SISTEMA = [
("disco_libre", "cuanto espacio queda|espacio en disco|espacio libre en disco|cuanto disco queda",
"Un momento, lo miro.", "df -h / | tail -1",
"Quedan {campo4} libres de {campo2}, al {campo5} de uso."),
("disco_home", "como esta mi disco|cuanto ocupa mi disco|espacio de mi disco",
"Un momento.", "df -h ~ | tail -1",
"Su disco esta al {campo5}, con {campo4} libres de {campo2}."),
("disco_todos", "discos montados|particiones|unidades montadas",
"Enseguida.", "df -h --output=target,size,pcent -x tmpfs -x devtmpfs | tail -n +2",
"Hay {lineas} unidades montadas. {salida}"),
("memoria", "cuanta memoria|memoria libre|cuanta ram|ram libre",
"Voy.", "free -g | awk '/Mem:/{print $2, $3, $7}'",
"De {campo1} gigas hay {campo2} en uso y {campo3} disponibles."),
("swap", "como esta la swap|memoria de intercambio",
"Voy.", "free -g | awk '/Swap:/{print $2, $3}'",
"Swap: {campo2} gigas usados de {campo1}."),
("cpu_carga", "carga del sistema|como va el procesador|carga de cpu",
"Un segundo.", "uptime | sed 's/.*average: //'",
"La carga media es {ultima}."),
("cpu_modelo", "que procesador tengo|modelo de procesador|que cpu tengo",
"Enseguida.", "lscpu | grep 'Model name' | cut -d: -f2 | xargs",
"Lleva un {ultima}."),
("cpu_nucleos", "cuantos nucleos tengo|cuantos hilos tengo",
"Voy.", "nproc", "Tiene {ultima} hilos disponibles."),
("temperatura", "que temperatura hay|como esta de temperatura|se esta calentando",
"Lo compruebo.", "for z in /sys/class/thermal/thermal_zone*/temp; do cat $z; done | sort -rn | head -1 | awk '{printf \"%.0f\", $1/1000}'",
"El punto mas caliente esta a {ultima} grados."),
("gpu_estado", "como esta la grafica|estado de la gpu|uso de la grafica",
"Un momento.",
"nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader",
"La grafica esta asi: {ultima}."),
("uptime", "cuanto lleva encendido|desde cuando esta encendido|tiempo encendido",
"Voy.", "uptime -p | sed 's/^up //; s/ days\\?/ dias/; s/ hours\\?/ horas/; s/ minutes\\?/ minutos/; s/,\\([^,]*\\)$/ y\\1/'",
"Lleva encendido {ultima}."),
("bateria", "cuanta bateria queda|como esta la bateria|nivel de bateria",
"Lo miro.", "cat /sys/class/power_supply/BAT*/capacity 2>/dev/null | head -1",
"La bateria esta al {ultima} por ciento."),
("kernel", "que kernel tengo|version del kernel|que nucleo tengo",
"Enseguida.", "uname -r", "Kernel {ultima}."),
("distro", "que distribucion tengo|que sistema tengo|que linux tengo",
"Voy.", "grep PRETTY_NAME /etc/os-release | cut -d'\"' -f2",
"Esta usando {ultima}."),
("fecha", "que hora es|que dia es|dime la fecha",
"", "date '+%H:%M del %A %d de %B'", "Son las {salida}."),
("procesos_top", "que esta consumiendo|que consume mas|procesos que mas gastan|quien se come la cpu",
"Lo compruebo.", "ps -eo pcpu,comm --sort=-pcpu --no-headers | head -5 | awk '{c=$1; $1=\"\"; sub(/^ +/,\"\"); printf \"%s al %s por ciento\\n\", $0, c}'",
"Lo que mas consume es {primera}."),
("procesos_memoria", "que consume mas memoria|quien se come la ram",
"Lo compruebo.", "ps -eo comm,pmem --sort=-pmem --no-headers | head -5",
"Por memoria: {salida}"),
("procesos_cuantos", "cuantos procesos hay|cuantos procesos corren",
"Voy.", "ps -e --no-headers | wc -l", "Hay {ultima} procesos en marcha."),
("red_ip", "cual es mi ip|mi ip local|que ip tengo",
"Un momento.", "hostname -I | awk '{print $1}'", "Su IP local es {ultima}."),
("red_ip_publica", "cual es mi ip publica|ip externa",
"Salgo a mirarlo.", "curl -s --max-time 6 ifconfig.me", "Su IP publica es {ultima}."),
("red_wifi", "a que wifi estoy conectado|red wifi|como se llama la wifi",
"Voy.", "nmcli -t -f NAME,TYPE connection show --active | grep wireless | cut -d: -f1",
"Conectado a {ultima}."),
("red_velocidad", "cuanto trafico de red|trafico de red",
"Lo miro.", "cat /proc/net/dev | awk '/wlp|enp/{rx+=$2; tx+=$10} END{printf \"%.1f %.1f\", rx/1e9, tx/1e9}'",
"Desde el arranque: {campo1} gigas bajados y {campo2} subidos."),
("red_ping", "hay internet|tengo conexion|funciona internet",
"Lo compruebo.",
"ping -c1 -W2 1.1.1.1 >/dev/null 2>&1 && echo si || echo no",
"Conexion: {ultima}."),
("puertos", "que puertos hay abiertos|puertos a la escucha|quien esta escuchando",
"Un momento.", "ss -ltnp 2>/dev/null | awk 'NR>1{print $4}' | sort -u | head -8",
"A la escucha: {salida}"),
("servicios_fallidos", "hay servicios caidos|servicios con fallos|algo ha fallado",
"Lo reviso.", "systemctl --failed --no-legend | wc -l",
"Servicios en fallo: {ultima}."),
("actualizaciones", "hay actualizaciones|paquetes por actualizar",
"Lo compruebo, tarda un poco.",
"apt list --upgradable 2>/dev/null | tail -n +2 | wc -l",
"Hay {ultima} paquetes por actualizar."),
("usuarios", "quien esta conectado|hay alguien conectado",
"Voy.", "who | wc -l", "Hay {ultima} sesiones abiertas."),
("gpu_procesos", "que usa la grafica|quien usa la gpu",
"Lo miro.", "O=$(nvidia-smi --query-compute-apps=process_name,used_gpu_memory --format=csv,noheader | awk -F, '{n=split($1,p,\"/\"); printf \"%s%s\\n\", p[n], $2}'); echo \"${O:-nada, la grafica esta libre}\"",
"En la grafica: {primera}"),
("audio_salida", "por donde suena|salida de audio|que altavoz esta puesto",
"Voy.", "pactl get-default-sink 2>/dev/null", "La salida es {ultima}."),
("audio_micro", "que microfono hay puesto|entrada de audio",
"Voy.", "pactl get-default-source 2>/dev/null", "La entrada es {ultima}."),
("pantallas", "cuantas pantallas hay|monitores conectados",
"Voy.", "n=$(xrandr --listmonitors 2>/dev/null | head -1 | awk '{print $2}'); xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $NF}' | paste -sd', '; echo \"Tiene $n pantalla$([ \"$n\" = 1 ] || echo s) conectada$([ \"$n\" = 1 ] || echo s)\"",
"{ultima}"),
("dispositivos_usb", "que hay conectado por usb|dispositivos usb",
"Un momento.", "lsusb | cut -d' ' -f7- | head -6", "Por USB: {salida}"),
("bluetooth", "que hay emparejado por bluetooth|dispositivos bluetooth",
"Voy.", "bluetoothctl devices 2>/dev/null | cut -d' ' -f3- | head -5",
"Emparejados: {salida}"),
("flatpaks", "que flatpaks tengo|aplicaciones flatpak",
"Voy.", "flatpak list --app --columns=name | head -10", "Instalados: {salida}"),
("docker", "hay contenedores corriendo|contenedores docker",
"Lo miro.", "O=$(docker ps --format '{{.Names}}' 2>/dev/null | head -5); echo \"${O:-ninguno en marcha}\"",
"Contenedores: {salida}"),
("git_estado", "como esta el repositorio|estado de git",
"Voy.", "cd ~/COFRE/CODERS/JARVIS && git status --short | wc -l",
"Hay {ultima} ficheros con cambios sin guardar."),
("ollama_estado", "esta ollama funcionando|como esta el modelo|que modelo hay cargado",
"Lo compruebo.",
"M=$(curl -s --max-time 3 http://localhost:11434/api/ps | grep -o '\"name\":\"[^\"]*' | cut -d'\"' -f4 | head -1); echo \"${M:-ninguno ahora mismo}\"",
"Modelo cargado: {ultima}."),
("apagar_pantalla", "apaga la pantalla|apagar pantalla",
"Enseguida.", "xset dpms force off", "Pantalla apagada."),
# --- administracion: necesitan root, disparan el dialogo de contraseña ---
# (Las de pentesting van en su propia lista, PENTEST, mas abajo.)
("actualizar_lista", "actualiza los repositorios|refresca los paquetes|actualiza la lista de paquetes",
"Voy, necesito permiso de administrador.", "sudo apt update",
"Repositorios actualizados, señor.", True),
("espacio_liberar", "limpia los paquetes viejos|libera espacio de apt|limpia el cache de paquetes",
"Necesito permiso para esto.", "sudo apt autoclean",
"Cache de paquetes limpiada, señor.", True),
("servicio_reiniciar", "reinicia el servicio de red|reinicia la red",
"Necesito permiso de administrador.", "sudo systemctl restart NetworkManager",
"Red reiniciada, señor.", True),
("fecha_sincronizar", "sincroniza la hora|pon la hora en hora",
"Un momento, con permiso.", "sudo systemctl restart systemd-timesyncd",
"Hora sincronizada, señor.", True),
("bloquear", "bloquea la sesion|bloquear pantalla|echa el cerrojo",
"Ahora mismo.", "loginctl lock-session", "Sesion bloqueada."),
("volumen_subir", "sube el volumen|mas volumen",
"", "pactl set-sink-volume @DEFAULT_SINK@ +10%", "Volumen subido."),
("volumen_bajar", "baja el volumen|menos volumen",
"", "pactl set-sink-volume @DEFAULT_SINK@ -10%", "Volumen bajado."),
("volumen_silencio", "silencia el sistema|quita el sonido|mutea el sistema",
"", "pactl set-sink-mute @DEFAULT_SINK@ toggle", "Sonido conmutado."),
("brillo_subir", "sube el brillo|mas brillo",
"", "brightnessctl set +10% 2>/dev/null || xrandr --output $(xrandr | awk '/ connected/{print $1; exit}') --brightness 1",
"Brillo subido."),
("captura_pantalla", "hazme una captura|captura de pantalla|haz una foto de la pantalla",
"Enseguida se la hago.", "D=$(xdg-user-dir PICTURES 2>/dev/null || echo ~); F=$D/captura-$(date +%H%M%S).png; import -window root \"$F\" 2>/dev/null || gnome-screenshot -f \"$F\" 2>/dev/null || ffmpeg -loglevel error -f x11grab -video_size $(xdpyinfo | awk \"/dimensions/{print \\$2}\") -i $DISPLAY -frames:v 1 -y \"$F\" 2>/dev/null; [ -s \"$F\" ] && echo \"$F\"",
"Captura guardada."),
("papelera_tamano", "cuanto ocupa la papelera|papelera llena",
"Voy.", "du -sh ~/.local/share/Trash 2>/dev/null | cut -f1",
"La papelera ocupa {ultima}."),
("descargas_listar", "que hay en descargas|ultimas descargas|que me he bajado",
"Voy.", "ls -t ~/Downloads 2>/dev/null | head -6", "Hay {lineas} cosas en descargas"),
("descargas_abrir", "abre la carpeta de descargas|abreme descargas",
"Enseguida se la abro.", "xdg-open ~/Downloads", "Carpeta de descargas abierta."),
("documentos_abrir", "abre mis documentos|abreme documentos",
"Enseguida.", "xdg-open ~/Documents", "Documentos abierto."),
("cofre_abrir", "abre el cofre|abreme el cofre",
"Enseguida.", "xdg-open ~/COFRE", "COFRE abierto."),
("ficheros_recientes", "que he tocado ultimamente|ficheros recientes|en que estaba trabajando",
"Lo miro.",
"find ~ -maxdepth 3 -type f -mmin -180 -not -path '*/.*' 2>/dev/null | head -6",
"Tocado en las ultimas horas: {salida}"),
("grandes", "que ocupa mas sitio|que tengo en descargas|que me esta llenando la carpeta",
"Lo miro.",
"du -ah ~/Downloads ~/Descargas ~/Documents ~/Desktop 2>/dev/null | sort -rh | head -6",
"Lo mas grande: {salida}"),
]
# ------------------------------------------------------- con argumento
CAPTURA = [
("buscar_fichero", "busca el fichero|buscame el fichero|encuentra el fichero",
"Lo busco.", "timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS $HOME/Downloads -iname '*{argumento}*' -not -path '*/.*' -not -path '*/node_modules/*' -not -path '*venv*' 2>/dev/null | head -12",
"He encontrado {lineas}"),
("buscar_carpeta", "busca la carpeta|encuentra la carpeta",
"La busco.", "find ~ -type d -iname '*{argumento}*' -not -path '*/.*' 2>/dev/null | head -12",
"Carpetas encontradas: {salida}"),
# busca un TEXTO dentro de los ficheros, recursivo, con ruta y linea
("grep_recursivo", "busca el texto|busca la palabra en mis ficheros|donde pone|que fichero contiene",
"Lo busco en sus ficheros.", "timeout 12 grep -rIn --exclude-dir='.*' --exclude-dir='*venv*' --exclude-dir=node_modules --exclude-dir=__pycache__ --exclude-dir=modelos --exclude-dir=muestras -m1 {argumento} $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS 2>/dev/null | head -12",
"{lineas} ficheros lo contienen."),
("leer_fichero", "leeme el fichero|abre y leeme|lee el fichero",
"Voy a leerlo.", "f=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS -iname '*{argumento}*' -type f -not -path '*/.*' 2>/dev/null | head -1); [ -n \"$f\" ] && head -c 800 \"$f\" || echo \"no lo encuentro\"",
"{salida}, señor"),
("abrir_fichero", "abre el fichero|abreme el fichero",
"Enseguida se lo abro.",
"f=$(find ~ -iname '*{argumento}*' -type f -not -path '*/.*' 2>/dev/null | head -1); "
"[ -n \"$f\" ] && xdg-open \"$f\" && echo \"$f\" || echo 'no lo encuentro'",
"Abierto {ultima}."),
("abrir_carpeta", "abre la carpeta|abreme la carpeta",
"Enseguida.", "a={argumento}; if [ -d \"$a\" ]; then d=\"$a\"; else d=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE $HOME/Downloads -type d -iname \"*$a*\" -not -path \"*/.*\" -not -path \"*/node_modules/*\" 2>/dev/null | head -1); fi; if [ -n \"$d\" ]; then xdg-open \"$d\" >/dev/null 2>&1 & echo \"Abierta $(basename \"$d\")\"; else echo \"No encuentro esa carpeta\"; fi",
"{ultima}"),
("tamano_carpeta", "cuanto ocupa la carpeta|tamaño de la carpeta",
"Lo calculo.",
"a={argumento}; if [ -d \"$a\" ]; then d=\"$a\"; else d=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE $HOME/Downloads -type d -iname \"*$a*\" -not -path \"*/.*\" 2>/dev/null | head -1); fi; [ -n \"$d\" ] && du -sh \"$d\" 2>/dev/null | cut -f1 || echo \"no la encuentro\"",
"Ocupa {ultima}."),
("proceso_buscar", "esta corriendo|se esta ejecutando|hay algun proceso de",
"Lo compruebo.", "pgrep -c -f {argumento} 2>/dev/null || echo 0",
"Hay {ultima} procesos que encajan."),
("proceso_matar", "mata el proceso|cierra el proceso|termina el proceso",
"Antes de matarlo, confirmeme.", "pkill -f {argumento}",
"Proceso terminado.", True),
("instalado", "tengo instalado|esta instalado",
"Lo miro.", "command -v {argumento} >/dev/null && echo si || echo no",
"Instalado: {ultima}."),
("manual", "para que sirve el comando|que hace el comando",
"Voy.", "whatis {argumento} 2>/dev/null | head -1", "{salida}"),
]
# --------------------------------------------------------------- oasis
# --------------------------------------------------------------- pentesting
# Red de seguridad: los escaneos ACTIVOS (que mandan paquetes a otros) llevan
# confirmar=True, asi que el dialogo muestra el comando exacto antes de lanzarse
# y la voz no puede disparar un escaneo sola. Los que apuntan a un objetivo se
# limitan a la RED LOCAL por construccion (autodetectan la subred o exigen IP
# privada); un objetivo de fuera se rechaza. Lo de solo lectura —buscar un
# exploit en la base local, resolver un dominio— va directo, no toca a nadie.
#
# Que apunte solo a la red local es a proposito: un asistente por voz que
# escanea lo que crea haber entendido, con salida al host, es un incidente
# esperando. Para objetivos de fuera, mejor a mano y con intencion.
PENTEST = [
("red_dispositivos", "escanea mi red|que hay en mi red|dispositivos en la red|quien esta en la red",
"Voy a escanear la red local, necesito su permiso.",
"R=$(ip route | awk '/proto.*src/{for(i=1;i<=NF;i++)if($i~/\\//)print $i; exit}'); "
"sudo nmap -sn ${R:-192.168.1.0/24} | grep -E 'Nmap scan|MAC' | head -30",
"Estos equipos hay en la red, señor: {salida}", True),
("puertos_host", "que puertos tiene abiertos|escanea los puertos de|escanea el host",
"Un escaneo de puertos, con su permiso.",
"T={argumento}; echo \"$T\" | grep -qE '^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|127\\.|localhost)' "
"&& nmap -F \"$T\" | grep -E '^[0-9]+/|Nmap scan' | head -20 "
"|| echo 'Solo objetivos de la red local, señor.'",
"{salida}", True),
("mis_puertos", "que puertos tengo abiertos yo|mis puertos abiertos|que estoy exponiendo",
"Lo compruebo.",
"ss -ltnp 2>/dev/null | awk 'NR>1{print $4}' | sort -u | head -15",
"Tengo {lineas} puertos a la escucha"),
("exploit_buscar", "busca un exploit para|busca exploits de|hay algun exploit de",
"Lo busco en la base de exploits.",
"searchsploit {argumento} 2>/dev/null | grep -vE '^-|Shellcode' | head -12",
"Esto he encontrado, señor: {salida}"),
("dominio_resolver", "resuelve el dominio|que ip tiene el dominio|a que ip apunta",
"Lo resuelvo.", "dig +short {argumento} 2>/dev/null | head -5",
"Resuelve a: {salida}"),
("dominio_whois", "de quien es el dominio|quien registro|whois de",
"Lo consulto.", "whois {argumento} 2>/dev/null | grep -iE 'registrar:|creation|expir|org' | head -6",
"{salida}"),
("cabeceras_web", "que servidor usa|cabeceras de|que tecnologia usa la web",
"Miro las cabeceras.",
"curl -sI --max-time 8 {argumento_url} 2>/dev/null | grep -iE 'server:|x-powered|location' | head -6",
"{salida}"),
("hash_identificar", "que tipo de hash es|identifica el hash",
"Lo miro.",
"H={argumento}; L=${#H}; case $L in 32)echo 'MD5, probablemente';; 40)echo 'SHA1';; "
"64)echo 'SHA256';; *)echo \"$L caracteres, no lo tengo claro\";; esac",
"{ultima}, señor."),
]
# las con argumento del bloque PENTEST (para marcar captura=True)
PENTEST_CAPTURA = {"puertos_host", "exploit_buscar", "dominio_resolver",
"dominio_whois", "cabeceras_web", "hash_identificar"}
OASIS = [
("oasis_corriendo", "esta oasis funcionando|esta corriendo oasis|oasis esta levantado",
"Lo compruebo.",
"pgrep -c -f 'oasis|ssb-server|sbot' 2>/dev/null || echo 0",
"Procesos de Oasis en marcha: {ultima}."),
("oasis_sincronizando", "estan sincronizando los pubs|como van los pubs|se estan sincronizando los pubs",
"Voy a mirar las conexiones.",
"python3 -c \"import json,time; d=json.load(open('$HOME/.ssb/conn.json')); "
"act=sum(1 for v in d.values() if time.time()*1000-v.get('stateChange',0)<300000); "
"fail=sum(1 for v in d.values() if v.get('failure',0)>0); "
"print(f'{len(d)} pubs, {act} activos hace poco, {fail} con fallos')\"",
"{ultima}."),
("oasis_conexiones", "cuantos pubs hay conectados|conexiones de oasis",
"Un momento.",
"cat ~/.ssb/conn.json 2>/dev/null | grep -c 'connected' || echo 0",
"Pubs conectados: {ultima}."),
("oasis_tamano", "cuanto ocupa oasis|tamaño de la base de ssb",
"Lo calculo.", "du -sh ~/.ssb 2>/dev/null | cut -f1",
"La base de SSB ocupa {ultima}."),
("oasis_abrir", "abre oasis|abreme oasis",
"Enseguida se lo abro.",
"xdg-open http://localhost:3000 2>/dev/null && echo abierto || echo 'no responde'",
"Oasis: {ultima}."),
("oasis_blobs", "cuanto ocupan los blobs|tamaño de los blobs",
"Voy.", "du -sh ~/.ssb/blobs 2>/dev/null | cut -f1", "Los blobs ocupan {ultima}."),
]
# Alias foneticos: lo que Whisper devuelve DE VERDAD, no lo que uno escribe.
# Medido con ocho ordenes habladas: "abre el correo" se transcribe "a ver el
# correo" (en castellano suenan igual), "los pubs" sale "los push" o "los
# puos", y "youtube" se parte en "yo tube". Añadirlos sube los aciertos sin
# tocar el modelo ni añadir un milisegundo.
ALIAS = {
"ff_gmail": ["a ver el correo", "abrir el correo"],
"oasis_sincronizando": ["estan sincronizando los push", "estan sincronizando los puos",
"sincronizando los pubs", "como van los push"],
"oasis_corriendo": ["esta oasis funcionando", "esta corriendo casis"],
"ff_buscar_youtube": ["busca en yo tube", "buscan youtube", "buscan yo tube",
"busca en you tube"],
"disco_libre": ["cuando espacio queda", "cuanto espacio quedan"],
"ff_youtube": ["abre yo tube", "abre you tube"],
"descargas_abrir": ["abre descargas", "abreme las descargas"],
"procesos_top": ["que esta consumiendo mas", "quien consume"],
"temperatura": ["que temperatura tiene", "como esta la temperatura"],
"bateria": ["cuanta bateria tengo", "como va la bateria"],
}
def con_señor(texto: str) -> str:
"""Mete el trato en las frases fijas.
Son las que mas suenan el acuse va en cada orden y hasta ahora eran las
unicas que no trataban de señor, asi que el personaje se caia justo ahi.
"""
if not texto or "señor" in texto.lower():
return texto
if texto.endswith("."):
return texto[:-1] + ", señor."
if texto.endswith("?"):
return texto[:-1] + ", señor?"
return texto + ", señor"
# Abrir una terminal y poner un video: de lo primero que se le pide a un
# asistente de escritorio, y no estaban. Las busquedas van acotadas y con
# tope porque el home son casi mil gigas y un find sin limite no vuelve.
ESCRITORIO = [
("terminal_abrir", "abre una terminal|abreme una terminal|abre la terminal|saca una terminal|quiero una terminal",
"Ahora mismo.", "gnome-terminal 2>/dev/null || kgx 2>/dev/null || xterm 2>/dev/null &",
"Terminal abierta."),
("terminal_aqui", "abre una terminal en el cofre|terminal en el cofre",
"Enseguida.", "gnome-terminal --working-directory=$HOME/COFRE 2>/dev/null || kgx 2>/dev/null &",
"Ahi la tiene, en el cofre."),
("videos_listar", "que peliculas tengo|que videos tengo|lista mis videos",
"Un momento, lo miro.", "timeout 12 find $HOME/Videos $HOME/Downloads $HOME/Desktop -type f -regextype posix-extended -iregex '.*\\.(mp4|mkv|avi|webm|mov|m4v)$' 2>/dev/null | head -12",
"He encontrado {lineas}."),
]
# esta captura el nombre de la pelicula
ESCRITORIO_CAPTURA = [
("pelicula_abrir", "pon la pelicula|abre la pelicula|reproduce el video|pon el video|quiero ver",
"Enseguida se la pongo.", "v=$(timeout 12 find $HOME/Videos $HOME/Downloads $HOME/Desktop -type f -regextype posix-extended -iregex '.*{argumento}.*\\.(mp4|mkv|avi|webm|mov|m4v)$' 2>/dev/null | head -1); if [ -n \"$v\" ]; then xdg-open \"$v\" >/dev/null 2>&1 & echo \"Poniendo $(basename \"$v\")\"; else echo \"No encuentro ese video\"; fi",
"{ultima}"),
]
# Ventanas y pantallas. Toda la logica vive en config/ventanas.sh (un script del
# host): aqui solo estan las frases y la llamada.
#
# Las frases llevan TRES palabras o mas y evitan a proposito ser subcadena de
# las que ya existen con "pantalla" o "ventana" dentro —apagar_pantalla,
# bloquear, captura_pantalla, ff_firefox_privado, ff_firefox_cerrar,
# proceso_matar, terminal_abrir—, porque el emparejador gana por frase mas
# larga sin mirar el grupo y una frase mal elegida se las come.
VENTANAS = [
("ventanas_listar", "que ventanas tengo abiertas|que ventanas hay abiertas|lista las ventanas",
"Un momento, lo miro.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh listar",
"Tiene {lineas} ventanas abiertas."),
("ventana_izquierda", "pon la ventana a la izquierda|manda la ventana a la izquierda|esta ventana a la izquierda",
"Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta izquierda", "{ultima}"),
("ventana_derecha", "pon la ventana a la derecha|manda la ventana a la derecha|esta ventana a la derecha",
"Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta derecha", "{ultima}"),
("ventana_arriba", "pon la ventana arriba del todo|manda la ventana arriba",
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta arriba", "{ultima}"),
("ventana_abajo", "pon la ventana abajo del todo|manda la ventana abajo",
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta abajo", "{ultima}"),
("ventana_centrar", "centra la ventana activa|pon la ventana en el centro",
"Enseguida.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta centro", "{ultima}"),
("ventana_maximizar", "maximiza la ventana activa|pon la ventana a pantalla completa",
"Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh maximiza esta", "{ultima}"),
("ventana_restaurar", "restaura la ventana activa|quita la pantalla completa",
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh restaura esta", "{ultima}"),
("ventana_minimizar", "minimiza la ventana activa|esconde la ventana activa",
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh minimiza esta", "{ultima}"),
]
# Estas capturan lo que se dice detras: el nombre de la pantalla, el numero de
# escritorio o el mote que se le quiera poner a un monitor.
VENTANAS_CAPTURA = [
("ventana_a_pantalla", "manda la ventana a la pantalla|pasa la ventana a la pantalla",
"Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh mover esta {argumento}", "{ultima}"),
("ventana_al_escritorio", "manda la ventana al escritorio|pasa la ventana al escritorio",
"Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh escritorio esta {argumento}", "{ultima}"),
("pantalla_nombrar", "a esta pantalla llamala|llama a esta pantalla",
"Anotado.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh alias 1 {argumento}", "{ultima}"),
]
def fila(t, grupo, captura=False):
ident, frases, acuse, comando, respuesta = t[:5]
confirmar = len(t) > 5 and t[5]
return {
"id": ident,
"grupo": grupo,
"frases": frases.split("|"),
"acuse": con_señor(acuse or "Voy."),
"comando": comando,
"host": True,
"respuesta": con_señor(respuesta),
"confirmar": bool(confirmar),
"captura": captura,
}
def main():
acciones = [fila(t, "sistema") for t in SISTEMA]
acciones += [fila(t, "sistema") for t in ESCRITORIO]
acciones += [fila(t, "sistema", captura=True) for t in ESCRITORIO_CAPTURA]
acciones += [fila(t, "ventanas") for t in VENTANAS]
acciones += [fila(t, "ventanas", captura=True) for t in VENTANAS_CAPTURA]
acciones += [fila(t, "ficheros", captura=True) for t in CAPTURA]
acciones += [fila(t, "oasis") for t in OASIS]
acciones += [fila(t, "pentest", captura=t[0] in PENTEST_CAPTURA) for t in PENTEST]
# las de firefox viven en su propio fichero, que son otras cincuenta
firefox = pathlib.Path(__file__).parent / "_firefox.py"
if firefox.exists():
import importlib.util
spec = importlib.util.spec_from_file_location("firefox", firefox)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
acciones += mod.acciones()
# Los alias se aplican aqui, sobre la lista completa: si se hicieran al
# construir cada fila, las de firefox se quedarian fuera porque se generan
# en otro fichero. Se aprende por las malas.
for accion in acciones:
extra = ALIAS.get(accion["id"])
if extra:
accion["frases"] += [f for f in extra if f not in accion["frases"]]
salida = pathlib.Path(__file__).parent / "acciones.json"
salida.write_text(json.dumps({"version": 1, "acciones": acciones},
indent=2, ensure_ascii=False) + "\n")
ids = [a["id"] for a in acciones]
print(f"{len(acciones)} acciones escritas en {salida.name}")
if len(ids) != len(set(ids)):
repes = {i for i in ids if ids.count(i) > 1}
print(f"CUIDADO, ids repetidos: {repes}")
for grupo in sorted({a["grupo"] for a in acciones}):
print(f" {grupo:<10} {sum(1 for a in acciones if a['grupo'] == grupo)}")
if __name__ == "__main__":
main()

2179
nucleo/datos/acciones.json Normal file

File diff suppressed because it is too large Load diff

290
nucleo/estado.py Executable file
View file

@ -0,0 +1,290 @@
#!/usr/bin/env python3
"""Por donde va la migracion, en la terminal.
./estado.py las barras
./estado.py -v y que falta exactamente en cada hito
./estado.py --rapido sin ejecutar nada, solo mirar ficheros
El porcentaje NO se escribe a mano: sale de comprobaciones de verdad que un
fichero exista, que una funcion este, que una prueba pase. Un contador que se
actualiza a mano miente a la semana, y ademas es justo lo que uno quiere que no
mienta: si dice 60 % es porque tres de cinco comprobaciones pasan ahora mismo.
Las que cuestan tiempo (arrancar whisper, hablar con ollama) van marcadas como
lentas y se saltan con --rapido.
"""
import argparse
import os
import subprocess
import sys
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(AQUI)
VENV = os.path.join(AQUI, "venv", "bin", "python")
# ------------------------------------------------------------- comprobantes
def hay(*trozos) -> bool:
return os.path.exists(os.path.join(AQUI, *trozos))
def dentro(fichero, texto) -> bool:
try:
with open(os.path.join(AQUI, fichero), encoding="utf-8") as f:
return texto in f.read()
except OSError:
return False
def corre(orden, espera=90) -> bool:
try:
r = subprocess.run(orden, cwd=AQUI, capture_output=True, timeout=espera)
return r.returncode == 0
except (OSError, subprocess.TimeoutExpired):
return False
def modulo(nombre) -> bool:
return corre([VENV if os.path.exists(VENV) else sys.executable,
"-c", f"import {nombre}"], espera=30)
def sin_comentarios(fichero, texto) -> bool:
"""Si el texto aparece en una linea que NO es un comentario."""
try:
with open(os.path.join(AQUI, fichero), encoding="utf-8") as f:
for l in f:
limpia = l.strip()
if limpia and not limpia.startswith(("#", "//", "*")) and texto in limpia:
return True
except OSError:
pass
return False
def catalogo_tiene(n) -> bool:
try:
import json
with open(os.path.join(AQUI, "datos", "acciones.json"), encoding="utf-8") as f:
return len(json.load(f)["acciones"]) >= n
except Exception:
return False
# ---------------------------------------------------------------- los hitos
#
# Cada comprobacion es (que se comprueba, como, si es lenta). El orden dentro
# de cada hito va de lo mas basico a lo mas fino, que es como se construyo.
HITOS = [
("la boca", "hablar por los altavoces", [
("el modulo esta", lambda: hay("boca", "voz.py"), False),
("hay tres voces o mas", lambda: dentro("boca/voz.py", '"claude"'), False),
("cache de frases por sha1", lambda: dentro("boca/voz.py", "_clave"), False),
("sabe si esta sonando", lambda: dentro("boca/voz.py", "def hablando"), False),
("habla de verdad", lambda: corre(
[VENV, "jarvis.py", "--di", "prueba"], 60), True),
]),
("el oido", "escuchar y transcribir", [
("captura con pw-record", lambda: dentro("oido/captura.py", "pw-record"), False),
("detector de voz Silero", lambda: modulo("pysilero_vad"), False),
("cliente de whisper-server", lambda: hay("oido", "whisper.py"), False),
("filtro de alucinaciones", lambda: dentro("oido/whisper.py", "def es_ruido"), False),
("guarda contra el eco", lambda: dentro("oido/captura.py", "boca.hablando"), False),
("whisper arranca y transcribe", lambda: corre(
[VENV, "-c",
"import sys;sys.path.insert(0,'.');from oido.whisper import Oido;"
"o=Oido();import sys as s;s.exit(0 if o.arranca() else 1)"], 90), True),
]),
("las manos", "ejecutar las 150 acciones", [
("el emparejador copiado", lambda: hay("manos", "acciones.py"), False),
("las tareas en paralelo", lambda: hay("manos", "tareas.py"), False),
("el catalogo entero", lambda: catalogo_tiene(150), False),
("una sola puerta para ejecutar", lambda: dentro("manos/__init__.py", "class Manos"), False),
("compara tambien por sonido", lambda: hay("manos", "fonetica.py"), False),
("banco fijo para medir", lambda: hay("datos", "banco.json"), False),
("las pruebas en verde", lambda: corre(
[VENV, "pruebas/prueba_manos.py"], 120), True),
]),
("el cerebro", "pensar lo que el catalogo no cubre", [
("cliente de ollama", lambda: hay("cerebro", "ollama.py"), False),
("dos herramientas, no una", lambda: dentro("cerebro/ollama.py", "comando_de_shell"), False),
("no puede inventarse datos", lambda: dentro("cerebro/ollama.py", "REGLA QUE NO SE ROMPE"), False),
("tope de vueltas", lambda: dentro("cerebro/ollama.py", "VUELTAS"), False),
("cadena catalogo-primero", lambda: dentro("jarvis.py", "def atiende"), False),
("ollama contesta", lambda: corre(
["curl", "-sf", "-m", "10", "http://127.0.0.1:11434/api/tags"], 20), True),
]),
("la palabra", "que le llamen JARVIS", [
("el centinela existe", lambda: hay("oido", "despierta.py"), False),
("se llama JARVIS", lambda: dentro("oido/despierta.py", 'NOMBRE = "jarvis"'), False),
("aguanta como lo oye Whisper", lambda: dentro("oido/despierta.py", "PARECIDAS"), False),
("la orden va en la misma frase", lambda: dentro("oido/despierta.py",
"def parte_el_nombre"), False),
("conectado al bucle de voz", lambda: dentro("jarvis.py", "centinela.filtra"), False),
# El que de verdad importaba: sin el prompt del nombre, Whisper nunca
# escribe JARVIS y el centinela no despierta jamas.
("prompt del nombre para Whisper", lambda: dentro("oido/whisper.py",
"PROMPT_NOMBRE"), False),
("boton para dormirlo y despertarlo", lambda: dentro("cara/panel.py",
'dato == "sueno"'), False),
("prueba de voz de punta a punta", lambda: hay("pruebas", "prueba_voz.py"), False),
("y la palabra va APAGADA por defecto", lambda: dentro("jarvis.py",
"activo=a.palabra"), False),
]),
("la cara", "el panel", [
("el HUD copiado", lambda: hay("cara", "hud", "index.html"), False),
("servidor http y websocket", lambda: hay("cara", "servidor.py"), False),
# El WebSocket vive en puente.js, no en hud.js: hud.js no se toca a
# proposito y el puente suplanta window.webkit por debajo.
("puente por WebSocket", lambda: dentro("cara/hud/puente.js", "WebSocket"), False),
("y hud.js sigue sin tocarse", lambda: dentro("cara/hud/hud.js",
"messageHandlers"), False),
("el panel se conecta al nucleo", lambda: dentro("cara/servidor.py", "async"), False),
# Ojo con las comprobaciones flojas: esta buscaba la palabra "tarea" en
# servidor.py y pasaba porque salia en un COMENTARIO, con las tarjetas
# sin conectar. Una barra que miente es peor que no tener barra.
("tarjetas de tareas en marcha", lambda: dentro("cara/panel.py",
"GestorTareas"), False),
("la consola lleva la conversacion", lambda: dentro("cara/hud/hud.js",
"aConsolaDicho"), False),
]),
("el dialogo", "que pregunte en vez de recitar", [
("las preguntas salen del catalogo", lambda: hay("manos", "preguntar.py"), False),
("pulsar pregunta lo que falta", lambda: dentro("cara/panel.py", "_pregunta("), False),
("la respuesta hablada la recoge", lambda: dentro("jarvis.py", "panel.responde"), False),
("lo destructivo confirma", lambda: dentro("cara/panel.py", "accion.confirmar"), False),
("y el panel enseña que espera", lambda: dentro("cara/hud/hud.js", "preguntando"), False),
("la prueba pasa", lambda: corre([VENV, "pruebas/prueba_dialogo.py"], 120), True),
]),
("el candado", "que no obedezca a cualquiera", [
("el candado en el HUD", lambda: dentro("cara/hud/index.html", "candado"), False),
("lo atiende el nucleo", lambda: dentro("cara/panel.py", "_intenta_desbloquear"), False),
("valida con la contrasena del sistema", lambda: dentro("cara/panel.py",
"GestorRoot"), False),
("con el candado puesto no ejecuta", lambda: dentro("cara/panel.py",
"no hay ordenes"), False),
("y la VOZ tampoco obedece", lambda: dentro("jarvis.py", "(bloqueado)"), False),
("la prueba pasa", lambda: corre([VENV, "pruebas/prueba_candado.py"], 120), True),
]),
("el saber", "conocer COFRE entero", [
("indexador de COFRE", lambda: hay("saber", "indexa.py"), False),
("busqueda por significado", lambda: hay("saber", "busca.py"), False),
("indice construido", lambda: hay("datos", "saber.jsonl"), False),
("el cerebro lo consulta antes de responder", lambda: dentro(
"cerebro/ollama.py", "def _saber"), False),
("filtra el codigo de terceros", lambda: dentro("saber/indexa.py", "IGNORA"), False),
("indexa la documentacion de Linux", lambda: hay("saber", "sistema.py"), False),
("y los servidores SSH", lambda: dentro("saber/sistema.py", "def servidores"), False),
("el cerebro puede ejecutar como root", lambda: dentro("manos/__init__.py",
"necesita_root"), False),
("root ligado al candado", lambda: dentro("cara/panel.py", "root.recuerda"), False),
]),
("el pipeline", "aprender del uso", [
("diario de lo que oye y hace", lambda: hay("manos", "diario.py"), False),
("apunta la VIA de cada frase", lambda: dentro("jarvis.py", 'anota(texto, "ruido")'), False),
("y tambien los aciertos", lambda: dentro("jarvis.py", '"catalogo", accion.id'), False),
("herramienta para revisarlo", lambda: hay("mejora.py"), False),
("propone, NO commitea solo", lambda: dentro("mejora.py", "Esto NO cambia nada"), False),
("mide contra el banco fijo", lambda: hay("datos", "banco.json"), False),
]),
("paridad", "usarlo a diario", [
("icono naranja", lambda: hay("jarvis-naranja.svg"), False),
("icono instalado", lambda: os.path.exists(os.path.expanduser(
"~/.local/share/icons/jarvis-nucleo.png")), False),
("lanzador propio", lambda: hay("arranca.sh"), False),
("ventana propia, no pestaña", lambda: hay("ventana.sh"), False),
# Se miran las lineas de CODIGO, no el fichero entero: el comentario que
# explica por que ya no se usa xdg-open contiene la palabra, y una
# comprobacion ingenua se cree que sigue ahi. Es la tercera vez que la
# barra casi miente por buscar texto suelto en vez de codigo.
("una sola forma de abrirla", lambda: not sin_comentarios(
"arranca.sh", "xdg-open"), False),
("la barra de tareas la reconoce", lambda: "StartupWMClass" in open(
os.path.expanduser("~/.local/share/applications/jarvis-nucleo.desktop")).read(), False),
("entrada en el escritorio", lambda: os.path.exists(os.path.expanduser(
"~/.local/share/applications/jarvis-nucleo.desktop")), False),
("Newelle congelado y aparte", lambda: corre(
["git", "-C", RAIZ, "rev-parse", "jarvis-newelle-final"], 15), False),
]),
]
# ------------------------------------------------------------------ pintarlo
ANCHO = 34
def color(t, c):
return f"\033[{c}m{t}\033[0m" if sys.stdout.isatty() else t
def barra(pct, hecho, total):
lleno = round(ANCHO * pct / 100)
# verde entero, amarillo a medias, gris sin empezar
c = "32" if pct == 100 else ("33" if pct > 0 else "90")
return color("" * lleno, c) + color("·" * (ANCHO - lleno), "90")
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("-v", "--detalle", action="store_true")
p.add_argument("--rapido", action="store_true",
help="sin ejecutar nada, solo mirar ficheros")
a = p.parse_args()
if not os.path.exists(VENV):
print(color(" aviso: no hay venv; algunas comprobaciones no se pueden hacer\n", "33"))
print()
print(color(" JARVIS · migracion a nucleo propio", "1"))
print()
hechas = totales = 0
for i, (nombre, para_que, comprobaciones) in enumerate(HITOS, 1):
activas = [c for c in comprobaciones if not (a.rapido and c[2])]
pasan = []
for texto, prueba, lenta in activas:
try:
ok = bool(prueba())
except Exception:
ok = False
pasan.append((texto, ok, lenta))
n = sum(1 for _, ok, _ in pasan if ok)
pct = 100 * n / len(pasan) if pasan else 0
hechas += n
totales += len(pasan)
marca = color("", "32") if pct == 100 else (" " if pct == 0 else color("·", "33"))
print(f" {marca} {i}. {nombre:12s} {barra(pct, n, len(pasan))} "
f"{pct:3.0f}% {n}/{len(pasan)} {color(para_que, '90')}")
if a.detalle:
for texto, ok, lenta in pasan:
s = color(" ok ", "32") if ok else color(" --", "31")
print(f" {s} {texto}" + (color(" (lenta)", "90") if lenta else ""))
print()
print()
total_pct = 100 * hechas / totales if totales else 0
print(f" {'TOTAL':14s} {barra(total_pct, hechas, totales)} "
f"{total_pct:3.0f}% {hechas}/{totales}")
print()
if a.rapido:
print(color(" (rapido: sin las comprobaciones que ejecutan cosas)\n", "90"))
return 0
if __name__ == "__main__":
sys.exit(main())

55
nucleo/jarvis-naranja.svg Normal file
View file

@ -0,0 +1,55 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<!-- El mismo reactor que jarvis-icon.svg, en naranja.
La paleta no se inventa: es la del tema "ambar" del HUD (style.css),
para que el icono y la pantalla sean el mismo color.
Cian = JARVIS sobre Newelle, congelado.
Naranja = el nucleo independiente. Asi se sabe cual esta abierto. -->
<defs>
<radialGradient id="core" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#fff0d8"/>
<stop offset="45%" stop-color="#ffb347"/>
<stop offset="100%" stop-color="#c8862a"/>
</radialGradient>
<radialGradient id="halo" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ffa040" stop-opacity=".55"/>
<stop offset="70%" stop-color="#c8862a" stop-opacity=".12"/>
<stop offset="100%" stop-color="#3a2810" stop-opacity="0"/>
</radialGradient>
<linearGradient id="ring" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#f5e3c7"/>
<stop offset="100%" stop-color="#92765a"/>
</linearGradient>
</defs>
<circle cx="128" cy="128" r="124" fill="#120c04"/>
<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="#3f2a10" stroke-width="7"/>
<!-- bobinas -->
<g stroke="#efc78f" 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="#7d541f" stroke-width="10"/>
<circle cx="128" cy="128" r="58" fill="none" stroke="#ffa040" stroke-width="4" opacity=".85"/>
<!-- triangulo del nucleo -->
<path d="M128 84 L166 150 L90 150 Z" fill="none" stroke="#ffe0b0" 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.4 KiB

330
nucleo/jarvis.py Executable file
View file

@ -0,0 +1,330 @@
#!/usr/bin/env python3
"""JARVIS. Asistente de voz local, sin framework debajo.
jarvis.py --di "Buenas noches, señor" prueba la boca
jarvis.py --voces las que hay
jarvis.py --accion "cuanto espacio queda" prueba las manos, sin voz
Esto sustituye a la capa que corria dentro de Newelle. Lo que se trajo tal cual
el emparejador, las tareas, el sudo, el HUD esta en `manos/` y `cara/`; lo que
habia que reescribir porque era de Newelle esta en `oido/`, `boca/` y `cerebro/`.
## Por que fuera del flatpak
No es dogma, es que el sandbox costaba mas de lo que daba: escondia `/tmp`,
obligaba a `LD_LIBRARY_PATH` a mano para las libs de CUDA de whisper, metia un
`flatpak-spawn --host` en cada accion, y cambiar una linea de Python pedia
recompilar el paquete entero. Aqui un cambio es editar y reiniciar.
"""
import argparse
import os
import shutil
import subprocess
import sys
import time
AQUI = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, AQUI)
from boca.voz import Boca, VOCES # noqa: E402
from cara.panel import Panel # noqa: E402
from cara.servidor import Cara # noqa: E402
from cerebro.ollama import Cerebro, MODELO # noqa: E402
from manos import Manos # noqa: E402
from manos.diario import anota # noqa: E402
from manos.acciones import Catalogo # noqa: E402
from oido.captura import Captura, fuentes # noqa: E402
from oido.despierta import Centinela, NOMBRE # noqa: E402
from oido.whisper import Oido, prompt_dominio, es_ruido # noqa: E402
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
PROPIO = os.path.expanduser("~/.config/jarvis/acciones.json")
def catalogo() -> Catalogo:
# El del usuario primero: puede redefinir cualquiera del paquete. Mismo
# orden que tenia el panel de Newelle.
return Catalogo([PROPIO, CATALOGO])
def atiende(texto, manos: Manos, cerebro: Cerebro | None, traza=print):
"""La cadena entera para una frase: catalogo primero, cerebro despues.
El orden no es un detalle de eficiencia, es lo que hace usable esto. Las 150
acciones se emparejan sin tocar ningun modelo y contestan al instante; el
cerebro solo ve lo que no encaja. Por eso un modelo lento se soporta: la
mayoria de las ordenes ni pasan por el.
"""
t0 = time.time()
accion, argumento = manos.busca(texto)
if accion is not None:
traza(f" catalogo: {accion.id}" + (f" <- {argumento}" if argumento else ""))
dicho, _ = manos.haz(accion, argumento)
anota(texto, "catalogo", accion.id, argumento, time.time() - t0, dicho)
return dicho
if cerebro is None:
traza(" (sin cerebro conectado)")
anota(texto, "sin-cerebro", seg=time.time() - t0)
return ""
traza(" al cerebro...")
dicho = cerebro.responde(texto, al_ejecutar=lambda i, a: traza(
f" usa {i}" + (f"({a[:52]})" if a else "")))
# La via mas valiosa del diario: lo que el catalogo NO cubre es justo la
# lista de acciones que faltan por escribir.
anota(texto, "cerebro", seg=time.time() - t0, dicho=dicho)
return dicho
def manda_accion(frase: str, boca: Boca | None = None, cerebro=None) -> int:
manos = Manos(catalogo())
dicho = atiende(frase, manos, cerebro, traza=lambda t: print(t))
if not dicho:
print(f' sin accion para "{frase}"')
return 1
print(f" {dicho}")
if boca:
boca.di(dicho)
return 0
def escucha(boca: Boca, modelo="small", fuente=None, callado=False,
cerebro=None, panel=None, centinela=None) -> int:
"""El bucle de verdad: oir, entender, hacer, contestar.
Es la version desnuda de lo que hacia el panel, sin GTK y sin HUD todavia.
Sirve para comprobar que la cadena entera funciona antes de ponerle cara.
"""
manos = Manos(catalogo())
# Con el prompt del NOMBRE, no con el del catalogo entero: sin el, Whisper
# nunca escribe "JARVIS" y el centinela no despierta jamas (ver whisper.py).
oreja = Oido(modelo=modelo)
if not oreja.disponible():
print(f" no encuentro whisper o el modelo {modelo}")
return 1
print(f" arrancando whisper ({modelo}, beam {oreja.beam}, sns, nombre)...")
if not oreja.arranca():
print(" whisper no arranco")
return 1
aviso = (f"esperando a que le llamen: «{NOMBRE.upper()}»"
if centinela and centinela.activo
else "atendiendo todo lo que oiga (--palabra para exigir el nombre)")
print(f" escuchando por {fuente or 'la entrada por defecto'}, {aviso}.")
print(" Ctrl+C para parar.\n")
if panel:
panel.estado("dormido" if centinela and centinela.activo else "escuchando")
# La captura consulta a la boca en cada trozo: mientras JARVIS habla, ese
# audio no se mira siquiera. Es lo que evita que se oiga a si mismo.
oreja_mic = Captura(boca=boca, fuente=fuente)
try:
for wav in oreja_mic.frases():
try:
texto = oreja.transcribe(wav)
finally:
try:
os.unlink(wav)
except OSError:
pass
if es_ruido(texto):
if texto:
print(f" · ruido descartado: {texto!r}")
anota(texto, "ruido")
if panel:
panel.dicho(texto, atendido=False, motivo="ruido")
continue
# ¿Hay una pregunta en el aire? Entonces esto es la respuesta.
#
# Va antes que TODO —antes de la palabra de activacion y antes del
# emparejador— porque si JARVIS acaba de preguntarte que fichero
# buscas, obligarte a decir "JARVIS, informes" seria absurdo: ya
# esta hablando contigo.
if panel and panel.esperando():
print(f" » {texto} (respondiendo a lo preguntado)")
anota(texto, "respuesta")
panel.dicho(texto)
if panel.responde(texto):
continue
# La palabra de activacion, antes de nada: sin esto obedece a
# cualquiera que pase, y a la tele.
if centinela:
atender, texto_orden, recien = centinela.filtra(texto)
if not atender:
if recien:
print(f" » {texto} (le han llamado)")
if panel:
panel.dicho(texto)
panel.estado("escuchando") # ya despierto
boca.di_si_libre("¿Señor?")
else:
print(f" · {texto} (no le han llamado)")
anota(texto, "sin-nombre")
# Se enseña IGUAL, marcado: que se vea que te ha oido
# bien y que lo unico que falta es llamarle.
if panel:
panel.dicho(texto, atendido=False,
motivo=f"diga «{NOMBRE.upper()}» primero")
continue
texto = texto_orden
# El candado tapa el panel, pero lo que hay que impedir es que
# OBEDEZCA: si no, quien pase por delante da ordenes hablando, que
# es justo lo que el candado existe para evitar.
if panel and getattr(panel, "candado", False) and not panel.desbloqueado:
print(f" · {texto} (bloqueado)")
anota(texto, "bloqueado")
panel.dicho(texto, atendido=False, motivo="hace falta la contraseña")
continue
print(f" » {texto}")
if panel:
panel.dicho(texto)
panel.estado("pensando")
dicho = atiende(texto, manos, cerebro)
if not dicho:
if panel:
panel.estado("escuchando")
continue
print(f" {dicho}")
if panel:
panel.respuesta(dicho)
panel.estado("hablando")
if not callado:
boca.di(dicho)
if panel:
# Sigue despierto un rato tras atender, para poder encadenar sin
# repetir el nombre: el rotulo tiene que decir eso y no "dormido".
panel.estado("escuchando" if not centinela or centinela.despierto
else "dormido")
except KeyboardInterrupt:
print("\n parado")
finally:
oreja_mic.para()
oreja.para()
return 0
def panel(boca: Boca, cerebro=None, escuchando=False, modelo="small",
fuente=None, abre=True, centinela=None, sin_candado=False) -> int:
"""Levanta el panel y, si se pide, ademas escucha el microfono."""
manos = Manos(catalogo())
cara = Cara()
p = Panel(cara, boca, manos, cerebro, candado=not sin_candado,
centinela=centinela)
cara.al_pulsar = p.pulsado
url = cara.arranca()
if not url:
return 1
p.arranca()
print(f" panel en {url}")
if abre:
# Una sola forma de abrir la ventana, en ventana.sh, para que sea igual
# se llegue por donde se llegue. Tener aqui su propia lista de
# navegadores fue lo que dejo que arranca.sh abriera una pestaña de
# Firefox cuando JARVIS ya estaba en marcha.
abridor = os.path.join(AQUI, "ventana.sh")
if os.access(abridor, os.X_OK):
subprocess.Popen([abridor, url], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
if not escuchando:
print(" sin microfono (--escucha para que ademas oiga). Ctrl+C para parar.")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\n parado")
p.para()
return 0
return escucha(boca, modelo, fuente, False, cerebro, p, centinela)
def main() -> int:
p = argparse.ArgumentParser(description="JARVIS")
p.add_argument("--di", metavar="TEXTO", help="decir algo en voz alta")
p.add_argument("--voz", default=None, help="con que voz")
p.add_argument("--voces", action="store_true", help="listar las voces")
p.add_argument("--accion", metavar="FRASE", help="ejecutar una orden del catalogo")
p.add_argument("--callado", action="store_true", help="no hablar el resultado")
p.add_argument("--escucha", action="store_true", help="escuchar el microfono")
p.add_argument("--modelo", default="small", help="modelo de whisper")
p.add_argument("--fuente", default=None, help="entrada de audio (ver --fuentes)")
p.add_argument("--fuentes", action="store_true", help="listar las entradas")
p.add_argument("--cerebro", default=MODELO,
help="modelo de ollama; 'no' para trabajar solo con el catalogo")
p.add_argument("--ram", action="store_true",
help="el modelo entero en RAM, sin usar la grafica")
p.add_argument("--panel", action="store_true", help="abrir el panel")
p.add_argument("--sin-abrir", action="store_true",
help="levantar el panel pero no abrir el navegador")
p.add_argument("--palabra", action="store_true",
help=f"exigir que le llamen «{NOMBRE}» antes de cada orden")
p.add_argument("--sin-candado", action="store_true",
help="no pedir la contraseña al abrir (solo para probar)")
a = p.parse_args()
boca = Boca(a.voz or "davefx")
cerebro = None
if a.cerebro and a.cerebro != "no":
cerebro = Cerebro(catalogo(), modelo=a.cerebro, en_ram=a.ram,
manos=Manos(catalogo()))
if a.voces:
hay = boca.disponibles()
for nombre in VOCES:
marca = "·" if nombre in hay else "×"
actual = " <- puesta" if nombre == boca.voz else ""
print(f" {marca} {nombre}{actual}")
return 0
if a.di:
if a.voz and not boca.cambia(a.voz):
print(f" no existe la voz {a.voz}")
return 1
print(f" [{boca.voz}] {a.di}")
if not boca.di(a.di):
print(" no sono. mira que el modelo de la voz este en su sitio")
return 1
return 0
if a.fuentes:
for f in fuentes():
print(f" {f}")
return 0
# La palabra de activacion va APAGADA por defecto.
#
# Decirla en cada frase es un peaje, y medido no evitaba nada: sobre 412
# frases reales oidas —con todo el eco y el ruido ambiente dentro— solo 3
# habrian disparado una accion, y las tres eran ordenes de verdad. Cero
# falsos positivos del ruido, porque el emparejador ya es conservador.
#
# El candado cubre lo otro, que es quien puede darle ordenes.
#
# Se queda disponible con --palabra y en el boton del panel, por si algun
# dia hay una tele cerca y el ruido empieza a colarse.
centinela = Centinela(activo=a.palabra)
if a.panel:
return panel(boca, cerebro, a.escucha, a.modelo, a.fuente,
abre=not a.sin_abrir, centinela=centinela,
sin_candado=a.sin_candado)
if a.escucha:
return escucha(boca, a.modelo, a.fuente, a.callado, cerebro,
centinela=centinela)
if a.accion:
return manda_accion(a.accion, None if a.callado else boca, cerebro)
p.print_help()
return 0
if __name__ == "__main__":
sys.exit(main())

114
nucleo/manos/__init__.py Normal file
View file

@ -0,0 +1,114 @@
"""Lo que JARVIS puede HACER, y el unico sitio por donde pasa.
`Manos` es la puerta: el catalogo entra por aqui, el cerebro entra por aqui y
el panel entrara por aqui. Tener un solo punto importa porque es donde se
decide que se ejecuta y que no, y esa decision no puede estar repartida.
Las cuatro piezas de debajo vienen tal cual de la version sobre Newelle
`acciones.py`, `tareas.py`, `sudo_root.py`, `seleccion.py` y se comprobo al
copiarlas que 374 frases dan exactamente el mismo resultado en los dos sitios.
"""
import os
import shlex
import subprocess
from .acciones import Catalogo, ejecuta, formatea
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
PROPIO = os.path.expanduser("~/.config/jarvis/acciones.json")
# Lo que no se ejecuta por mucho que lo pida el modelo. No es una lista de
# seguridad completa —eso es imposible— sino un tope contra el accidente: el
# modelo es servicial y a veces se inventa una limpieza que nadie pidio.
PROHIBIDO = (
"rm -rf /", "mkfs", "dd if=", ":(){", "shutdown", "reboot", "halt",
"> /dev/sd", "chmod -R 777 /", "chown -R", "userdel", "passwd",
)
TOPE_SALIDA = 4000
class Manos:
def __init__(self, catalogo=None, al_ejecutar=None):
self.catalogo = catalogo or Catalogo([PROPIO, CATALOGO])
self.al_ejecutar = al_ejecutar
# Root para la sesion: cuando el usuario desbloquea el candado con su
# contraseña, esa misma queda aqui y el cerebro puede hacer sudo hasta
# que caduque. Sin desbloquear, no hay root: el candado es la puerta.
from manos.sudo_root import GestorRoot
self.root = GestorRoot([])
# ------------------------------------------------------------- catalogo
def busca(self, texto: str):
return self.catalogo.busca(texto)
def haz(self, accion, argumento="") -> tuple:
"""Ejecuta una accion ya emparejada. Devuelve (lo que se dice, crudo)."""
dicho, salida = ejecuta(accion, [], argumento)
if salida:
dicho = formatea(accion.respuesta, salida, argumento)
return dicho, salida
def por_orden(self, orden: str, al_ejecutar=None) -> str:
"""Para el cerebro: empareja contra el catalogo y ejecuta.
Devuelve texto para el modelo, no para el usuario: si no encaja, se le
dice claramente para que pruebe con el shell en vez de insistir.
"""
if not orden.strip():
return "no me has dicho ninguna orden"
# El modelo a veces cuela un comando aqui de todas formas. Se detecta y
# se le devuelve el motivo, que es mas util que un "no encontrado".
if any(c in orden for c in "|><;&$") or orden.split()[0] in ("df", "ls", "ps", "cat"):
return ("eso es un comando de shell, no una orden del catalogo. "
"Usa la herramienta comando_de_shell.")
accion, argumento = self.catalogo.busca(orden)
if accion is None:
return (f"'{orden}' no esta en el catalogo. Si hace falta, usa "
"comando_de_shell.")
if al_ejecutar or self.al_ejecutar:
(al_ejecutar or self.al_ejecutar)(accion.id, argumento)
dicho, salida = self.haz(accion, argumento)
return (salida or dicho)[:TOPE_SALIDA]
# ---------------------------------------------------------------- shell
def por_shell(self, comando: str, al_ejecutar=None) -> str:
if not comando.strip():
return "no me has dicho ningun comando"
bajo = comando.lower()
for veneno in PROHIBIDO:
if veneno in bajo:
return (f"no ejecuto eso: contiene '{veneno}'. Si de verdad hace "
"falta, que lo pida el usuario expresamente.")
if al_ejecutar or self.al_ejecutar:
(al_ejecutar or self.al_ejecutar)("shell", comando)
from manos.sudo_root import necesita_root
# Los comandos con sudo van por GestorRoot, que da la contraseña de la
# sesion por stdin. Sin ella, `bash -c "sudo ..."` se colgaria pidiendo
# una contraseña que nadie teclea. Y si el candado no se ha abierto, no
# hay contraseña: el root queda cerrado hasta que el usuario desbloquee.
if necesita_root(comando):
if not self.root.sesion_activa():
return ("eso necesita root y el candado no esta abierto. "
"Desbloquee con su contraseña y lo repito.")
try:
r = self.root.ejecuta(comando, timeout=60)
except Exception as e:
return f"no se pudo ejecutar como root: {e}"
else:
try:
r = subprocess.run(["bash", "-c", comando], capture_output=True,
text=True, timeout=60)
except subprocess.TimeoutExpired:
return "el comando tardo mas de un minuto y lo he cortado"
except OSError as e:
return f"no se pudo ejecutar: {e}"
salida = (r.stdout or r.stderr or "").strip()
if not salida:
return "hecho, sin salida" if r.returncode == 0 else f"fallo (codigo {r.returncode})"
return salida[:TOPE_SALIDA]

310
nucleo/manos/acciones.py Normal file
View file

@ -0,0 +1,310 @@
"""Acciones rapidas: caminos predefinidos que se saltan el modelo.
Por que existe esto. Preguntar "cuanto espacio queda" y esperar a que un modelo
de 4B razone, decida llamar a una herramienta, la ejecute y redacte la
respuesta cuesta segundos. Pero esa pregunta no necesita pensarse: es un `df`.
El catalogo empareja la frase con un comando y contesta con una plantilla, y
eso baja de segundos a decimas.
No es entrenar un modelo: es una tabla. Y esa es la gracia, porque una tabla es
predecible, se lee, se corrige y no alucina. Lo que no encaje en la tabla sigue
yendo al modelo como siempre.
Formato del catalogo (JSON):
{
"acciones": [
{
"id": "disco_libre",
"frases": ["cuanto espacio queda", "espacio en disco"],
"acuse": "Un momento, lo miro.",
"comando": "df -h / | tail -1",
"host": true,
"respuesta": "Quedan {campo4} libres de {campo2}.",
"confirmar": false
}
]
}
En `respuesta` se puede usar:
{salida} la salida completa, recortada
{primera} la primera linea
{ultima} la ultima linea no vacia
{campoN} la columna N de la ultima linea (1 en adelante)
{lineas} cuantas lineas ha devuelto
"""
import difflib
import json
import os
import re
import shlex
import subprocess
import urllib.parse
import unicodedata
UMBRAL_PARECIDO = 0.86 # por debajo empieza a confundir ordenes
TIEMPO_LIMITE = 20 # ningun atajo deberia tardar mas
SALIDA_MAXIMA = 600 # lo que se guarda para enseñar y para la plantilla
def normaliza(texto: str) -> str:
"""Minusculas, sin tildes, sin puntuacion y con los espacios apretados.
Whisper devuelve "¿Cuánto espacio queda?" y el catalogo dice "cuanto
espacio queda". Sin normalizar no casaria ni una.
Lo de apretar los espacios no es cosmetico: al sustituir la puntuacion por
un espacio, "cuanto, espacio queda" quedaba con dos seguidos y dejaba de
coincidir con la frase del catalogo. El sintoma era un asistente que a
veces no reacciona a una orden que ha entendido bien, segun donde el
reconocedor decida poner una coma.
"""
texto = unicodedata.normalize("NFD", texto.lower())
texto = "".join(c for c in texto if unicodedata.category(c) != "Mn")
return " ".join(re.sub(r"[^\w\s]", " ", texto).split())
# El envoltorio de cortesia con el que habla cualquiera. Nadie dice "pon la
# pelicula" a secas: dice "necesito que pongas la pelicula" o "puedes ponerme".
# Con eso delante, ninguna frase del catalogo encaja como subcadena y la orden
# se pierde entera.
#
# Medido sobre las 462 frases que el panel anoto por no entender: de las 18 que
# eran ordenes de verdad, la mayoria fallaba solo por esto. Cinco de ellas eran
# el mismo intento de poner una pelicula, dicho de cinco maneras.
CORTESIA = re.compile(
r"^(por favor,?\s+)?"
r"(necesito que( me)?|quiero que( me)?|me gustaria que( me)?|"
r"puedes|podrias|me puedes|me podrias|te importa|hazme el favor de|"
r"a ver si|va(le|)?,? |anda,? |porfa,? |quiero|necesito)\s+",
re.I)
class Accion:
def __init__(self, datos: dict):
self.id = datos.get("id", "")
self.frases = [normaliza(f) for f in datos.get("frases", [])]
self.acuse = datos.get("acuse", "Voy.")
self.comando = datos.get("comando", "")
self.host = bool(datos.get("host", True))
self.respuesta = datos.get("respuesta", "{salida}")
self.confirmar = bool(datos.get("confirmar", False))
self.captura = bool(datos.get("captura", False))
self.grupo = datos.get("grupo", "")
def coincide(self, texto: str):
"""(longitud de la frase que encaja, lo que venia detras).
Se devuelve la longitud y no un si/no para poder quedarse con la
coincidencia mas especifica: "abre la carpeta de descargas" debe ganar
a "abre la carpeta" cuando las dos encajan.
El resto es el argumento: en "busca gatos en youtube" la frase es
"busca" ... y lo demas es lo que hay que buscar. Solo se recoge si la
accion lo pide, para que una frase suelta no arrastre basura.
"""
mejor, resto = 0, ""
for f in self.frases:
if not f or f not in texto:
continue
if len(f) > mejor:
mejor = len(f)
resto = texto.split(f, 1)[1].strip() if self.captura else ""
return mejor, resto
def parecido(a: str, b: str) -> float:
"""Cuanto se parecen dos cadenas, de 0 a 1, por letra y por sonido.
Para los casi-aciertos del reconocedor: "yo tuve" por "yo tube", "casis"
por "oasis". Se usa difflib y no una distancia de edicion propia porque
viene en la biblioteca estandar y aqui no hay que hilar tan fino.
Se queda con el MAYOR de los dos parecidos, el de la letra y el del sonido.
Muchos fallos del reconocedor no son de significado sino de ortografia
"Abre van Kamp" por "abre bandcamp", "poops" por "pubs" y por escrito
quedan lejos aunque suenen igual. Medido contra el banco: sube el acierto
de 79,3 a 84,7 % sin que ninguna de las 50 frases de charla dispare nada.
Que sea el maximo y no la media importa: la media hundiria los aciertos
normales, donde la letra ya casaba bien y el sonido no aporta.
"""
literal = difflib.SequenceMatcher(None, a, b).ratio()
if literal >= 0.98:
return literal # ya casa: no hace falta el sonido
from .fonetica import fonetica
return max(literal,
difflib.SequenceMatcher(None, fonetica(a), fonetica(b)).ratio())
class Catalogo:
def __init__(self, rutas):
self.acciones = []
self.errores = []
for ruta in rutas:
if ruta and os.path.exists(ruta):
self._carga(ruta)
def _carga(self, ruta):
try:
with open(ruta) as f:
datos = json.load(f)
except (OSError, json.JSONDecodeError) as e:
self.errores.append(f"{os.path.basename(ruta)}: {e}")
return
vistos = {a.id for a in self.acciones}
for entrada in datos.get("acciones", []):
accion = Accion(entrada)
if not accion.id or not accion.frases:
self.errores.append(f"{os.path.basename(ruta)}: accion sin id o sin frases")
continue
# el primero que se carga manda: asi el catalogo del usuario puede
# redefinir uno del paquete sin tener que borrarlo
if accion.id not in vistos:
self.acciones.append(accion)
vistos.add(accion.id)
def busca(self, texto: str):
"""(accion mas especifica que encaje, argumento) o (None, "").
Si no encaja nada, se reintenta quitando la cortesia de delante. Se
hace como SEGUNDA pasada y no normalizando desde el principio a
proposito: asi lo que ya funcionaba sigue funcionando igual, y esto
solo añade aciertos donde antes no habia ninguno.
"""
encontrada, argumento = self._busca_directa(normaliza(texto))
if encontrada is not None:
return encontrada, argumento
sin_cortesia = CORTESIA.sub("", normaliza(texto)).strip()
if sin_cortesia and sin_cortesia != normaliza(texto):
return self._busca_directa(sin_cortesia)
return None, ""
def _busca_directa(self, texto: str):
mejor, puntos, argumento = None, 0, ""
for accion in self.acciones:
p, resto = accion.coincide(texto)
if p > puntos:
mejor, puntos, argumento = accion, p, resto
# Nada exacto: se prueba por parecido. El reconocedor se come letras
# ("yo tuve" por "yo tube") y sin esto la orden se pierde entera. El
# umbral es alto a proposito: mas abajo empieza a confundir ordenes
# distintas entre si, que es peor que no reaccionar.
if mejor is None:
self._resto_parecido = ""
mejor, puntos = self._por_parecido(texto)
if mejor is not None and mejor.captura:
argumento = self._resto_parecido
# una accion que captura y no recibe nada no sirve: "busca" a secas
if mejor is not None and mejor.captura and not argumento:
return None, ""
return mejor, argumento
def _por_parecido(self, texto: str, umbral: float = UMBRAL_PARECIDO):
"""La accion cuya frase mas se parezca, si pasa del umbral."""
palabras = texto.split()
mejor, mejor_p = None, umbral
for accion in self.acciones:
if accion.captura:
# Con argumento no vale comparar la frase entera: "busca en
# youtube conciertos de techno" no se parece a "busca en
# youtube". Se compara solo el arranque y lo demas es el
# argumento. Se exigen tres palabras para que un disparador
# corto no se coma media conversacion.
for frase in accion.frases:
trozos = frase.split()
# Dos palabras, no tres. Con tres, disparadores cortos como
# "receta de" no entraban NUNCA en esta fase, y "receta de
# documentos" mal oido se perdia entero. El riesgo de bajar
# a dos lo cubre que la comparacion va anclada al principio:
# solo encaja si el arranque suena a la frase.
if len(trozos) < 2 or len(palabras) <= len(trozos):
continue
arranque = " ".join(palabras[:len(trozos)])
p = parecido(arranque, frase)
if p > mejor_p:
mejor, mejor_p = accion, p
self._resto_parecido = " ".join(palabras[len(trozos):])
continue
# Frase ENTERA contra frase entera, no por ventana deslizante. La
# ventana encontraba un sub-trozo que puntuaba alto aunque la orden
# fuera otra: "que carne el tiempo" (mal oido de "que kernel tengo")
# colaba en la accion del tiempo con 0.90, y se ejecutaba la accion
# equivocada —lo peor que puede pasar en algo que lanza comandos—.
# Una orden se dice completa; si va incrustada en una frase larga,
# el emparejador exacto (por subcadena) ya la coge antes de llegar
# aqui. Comparar el todo con el todo deja fuera esos falsos
# positivos sin perder los casi-aciertos reales.
propio = " ".join(palabras)
for frase in accion.frases:
if not frase:
continue
p = parecido(propio, frase)
if p > mejor_p:
mejor, mejor_p = accion, p
return mejor, mejor_p
def __len__(self):
return len(self.acciones)
def ejecuta(accion: Accion, prefijo_host, argumento: str = "") -> tuple:
"""Corre el comando y devuelve (texto para decir, salida cruda).
prefijo_host es lo que hay que anteponer para salir del sandbox; se pasa
como argumento en vez de importarlo para poder probar esto sin flatpak.
"""
if not accion.comando:
return accion.acuse, ""
# El argumento viene de un microfono, o sea de fuera: va entrecomillado con
# shlex antes de tocar un shell. Sin esto, decir "borra punto y coma rm"
# seria una orden ejecutable.
comando = accion.comando.replace("{argumento}", shlex.quote(argumento))
comando = comando.replace("{argumento_url}",
shlex.quote(urllib.parse.quote_plus(argumento)))
orden = ["bash", "-lc", comando]
if accion.host:
orden = list(prefijo_host) + orden
try:
# errors="replace": un grep o un cat que toque un binario devuelve
# bytes que no son utf-8, y sin esto la orden entera reventaria con
# UnicodeDecodeError. Mejor un caracter raro que un fallo.
r = subprocess.run(orden, capture_output=True, text=True,
errors="replace", timeout=TIEMPO_LIMITE)
salida = (r.stdout or r.stderr or "").strip()
except subprocess.TimeoutExpired:
return "La orden ha tardado demasiado y la he cancelado.", ""
except OSError as e:
return f"No he podido ejecutarlo: {e}", ""
return formatea(accion.respuesta, salida, argumento), salida[:SALIDA_MAXIMA]
def formatea(plantilla: str, salida: str, argumento: str = "") -> str:
lineas = [l for l in salida.splitlines() if l.strip()]
ultima = lineas[-1] if lineas else ""
campos = ultima.split()
valores = {
"argumento": argumento,
"salida": salida[:SALIDA_MAXIMA] if salida else "sin resultado",
"primera": lineas[0] if lineas else "",
"ultima": ultima,
"lineas": len(lineas),
}
for i, campo in enumerate(campos, start=1):
valores[f"campo{i}"] = campo
try:
return plantilla.format(**valores)
except (KeyError, IndexError, ValueError):
# plantilla que pide un campo que no existe: mejor decir la salida
# cruda que soltar una excepcion en mitad de una conversacion
return valores["salida"]

123
nucleo/manos/diario.py Normal file
View file

@ -0,0 +1,123 @@
"""Lo que JARVIS oye y que hace con ello. El combustible de todo lo demas.
Sin esto no hay forma de mejorar nada: no se puede arreglar lo que no se mide,
y hasta ahora el nucleo no apuntaba absolutamente nada.
## Por que este y no el de antes
El diario de la epoca de Newelle guardaba `{"texto": "..."}` y nada mas. Con eso
no se puede hacer casi nada:
- no se sabe **que paso** con la frase, asi que no se distingue una orden que
fallo de una que funciono;
- no se sabe **cuando**, asi que no se ve si alguien repitio la misma cosa de
tres formas seguidas que es la señal mas valiosa que hay;
- y solo guardaba los FALLOS, asi que no habia con que comparar.
Ademas se llenaba de eco y de alucinaciones de Whisper, y la mejora nocturna
acabo generando alias a partir del ruido. De 504 frases, 306 parecian ordenes
sin atender y al mirarlas casi todas eran JARVIS oyendose a si mismo.
## Que se guarda
Todo lo que llega, con su destino. Una linea por frase, JSON:
t cuando
oido lo que transcribio Whisper
via catalogo | cerebro | ruido | bloqueado | respuesta
accion a que accion fue, si fue a alguna
arg con que argumento
seg lo que tardo en contestar
dicho lo que contesto
Guardar TAMBIEN los aciertos es lo que permite comparar: sin ellos, un dia malo
y un dia bueno tienen el mismo aspecto.
## Lo que NO se guarda
Nada de audio. El diario es texto y se puede leer con `less`; si algun dia
guardara los WAV, el asistente pasaria a ser una grabadora de la habitacion y
eso es otra cosa muy distinta de lo que es.
"""
import json
import os
import threading
import time
from datetime import datetime
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DIARIO = os.path.join(AQUI, "datos", "diario.jsonl")
# Un diario que crece sin fin acaba siendo un problema en vez de una ayuda. A
# ~150 bytes por linea, 200.000 son unos 30 MB: mas que suficiente para meses de
# uso y poco para el disco.
TOPE_LINEAS = 200_000
_cerrojo = threading.Lock()
def anota(oido: str, via: str, accion: str = "", arg: str = "",
seg: float = 0.0, dicho: str = "", ruta: str = DIARIO):
"""Apunta una frase y su destino. No falla nunca hacia fuera.
Si escribir el diario diera error, lo ultimo que debe hacer es tirar al
asistente: se pierde una linea y ya.
"""
if not oido:
return
linea = {
"t": datetime.now().isoformat(timespec="seconds"),
"oido": oido[:400],
"via": via,
}
if accion:
linea["accion"] = accion
if arg:
linea["arg"] = arg[:200]
if seg:
linea["seg"] = round(seg, 2)
if dicho:
linea["dicho"] = dicho[:300]
try:
with _cerrojo:
os.makedirs(os.path.dirname(ruta), exist_ok=True)
with open(ruta, "a", encoding="utf-8") as f:
f.write(json.dumps(linea, ensure_ascii=False) + "\n")
except OSError:
pass
def lee(ruta: str = DIARIO, desde=None) -> list:
"""Las lineas del diario, ya en diccionarios. Salta las corruptas."""
salida = []
try:
with open(ruta, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
if desde and d.get("t", "") < desde:
continue
salida.append(d)
except OSError:
pass
return salida
def recorta(ruta: str = DIARIO, tope: int = TOPE_LINEAS):
"""Deja solo las ultimas `tope` lineas."""
lineas = []
try:
with open(ruta, encoding="utf-8") as f:
lineas = f.readlines()
except OSError:
return
if len(lineas) <= tope:
return
try:
with _cerrojo, open(ruta, "w", encoding="utf-8") as f:
f.writelines(lineas[-tope:])
except OSError:
pass

84
nucleo/manos/fonetica.py Normal file
View file

@ -0,0 +1,84 @@
"""Comparar por como SUENA, no por como se escribe.
## Que problema resuelve
Midiendo donde se pierde el acierto salio esto: de 150 ordenes dichas, el 79 %
llega a su accion, un 5 % se oye mal de verdad, y un **15 % se oye bien pero el
emparejador no lo relaciona**. Mirando esos casos uno a uno, no son ordenes
distintas: son la misma orden mal escrita.
dijo "receta de documentos" oyo "Leceta de documentos"
dijo "como van los pubs" oyo "¿Como van los poops?"
dijo "abre bandcamp" oyo "Abre van Kamp"
dijo "abre el drive" oyo "Abril Drive"
dijo "abreme oasis" oyo "Abre mi roasis"
Eso NO se arregla con embeddings: un modelo semantico ve "Leceta" como una
palabra inexistente, no como "receta". Y bajar el umbral de parecido tampoco,
porque el umbral esta alto por una razon hay una cicatriz en el codigo de
cuando "que carne el tiempo" se colaba en la accion del tiempo y ejecutaba lo
que no era.
Lo que sirve es comparar como suena. En castellano hay unas pocas confusiones
que lo explican casi todo, y son las que aplica `fonetica()`.
## Lo que NO hace
No es un Soundex ni un Metaphone: esos comprimen tanto que juntan palabras que
no se parecen, y aqui juntar de mas significa ejecutar la orden equivocada, que
es lo peor que puede hacer esto. Se queda en las equivalencias que de verdad
confunde el reconocedor de voz en castellano, y nada mas.
"""
import re
import unicodedata
# El orden importa: cada regla se aplica sobre el resultado de la anterior.
REGLAS = (
# la hache no suena
(r"h", ""),
# b y v son el mismo sonido: "van Kamp" / "bandcamp"
(r"v", "b"),
# ge/gi suenan como jota
(r"g([ei])", r"j\1"),
# gue/gui: la u es muda
(r"gu([ei])", r"g\1"),
# que/qui igual, y qu -> k
(r"qu([ei])", r"k\1"),
(r"q", "k"),
# ce/ci en seseo suenan como ese; ca/co/cu como ka
(r"c([ei])", r"s\1"),
(r"c", "k"),
# z siempre ese (seseo): asi "poops"/"pubs" y "haz"/"has" caen juntos
(r"z", "s"),
# x entre vocales suena ks, pero al principio suele ser ese
(r"^x", "s"),
(r"x", "ks"),
# ll e y son el mismo sonido para casi todo el mundo
(r"ll", "y"),
# w es de fuera: suena como u o como b segun la palabra; se unifica a b
(r"w", "b"),
# la erre doble y la simple no se distinguen al transcribir
(r"rr", "r"),
# la ñ se escribe de mil formas cuando el reconocedor duda
(r"ñ", "n"),
# las dobles no aportan: "poops" -> "pops"
(r"(.)\1+", r"\1"),
# las vocales finales se comen mucho; y la ese final tambien
(r"s$", ""),
)
def fonetica(texto: str) -> str:
"""Como suena, aproximadamente, para poder comparar dos oidos distintos."""
t = unicodedata.normalize("NFD", (texto or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn" or c == "̃")
t = unicodedata.normalize("NFC", t)
t = re.sub(r"[^a-zñ\s]", " ", t)
palabras = []
for p in t.split():
for patron, cambio in REGLAS:
p = re.sub(patron, cambio, p)
if p:
palabras.append(p)
return " ".join(palabras)

115
nucleo/manos/medidas.py Normal file
View file

@ -0,0 +1,115 @@
"""Las medidas de eficiencia de JARVIS, sacadas del diario.
El diario ya apunta por cada frase: cuanto tardo (`seg`), por donde fue (`via`)
y cuando (`t`). De ahi salen las metricas de rendimiento sin instrumentar nada
del camino caliente: esto solo LEE, y se calcula fuera del hilo que contesta.
## Que se mide y por que importa cada una
latencia p50/p95/max del tiempo de respuesta. La mediana dice como va de
normal; el p95 dice como va cuando va mal, que es lo que se nota.
reparto que fraccion va al CEREBRO (lento, ~2-3 s) y cual al CATALOGO
(instantaneo, salta el modelo). Es la palanca de eficiencia mas
grande: cuanto mas catalogo, mas rapido el conjunto.
ruido que fraccion de lo que oye es Whisper oyendose a si mismo. No es
velocidad, es calidad de la escucha, y envenena todo lo demas.
volumen frases en las ultimas 24 h. Contexto para lo anterior: un p95 malo
con 4 frases no dice nada.
rag hit@5/hit@1 del ultimo benchmark del RAG (informe_rag.txt). Es la
unica que no sale del diario: es una medida de test, periodica.
Todo sobre una VENTANA reciente (las ultimas MUESTRA frases), no sobre el diario
entero: interesa como va AHORA, no la media historica que nunca cambia.
"""
import os
import re
from datetime import datetime, timedelta
from manos.diario import lee
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
INFORME_RAG = os.path.join(AQUI, "datos", "informe_rag.txt")
MUESTRA = 600 # las ultimas N frases: "como va ahora"
CEREBRO = "cerebro"
def _percentil(valores, q):
if not valores:
return 0.0
v = sorted(valores)
k = (len(v) - 1) * q
lo, hi = int(k), min(int(k) + 1, len(v) - 1)
return v[lo] + (v[hi] - v[lo]) * (k - lo)
def _rag():
"""hit@5 y hit@1 del ultimo informe del RAG, si existe."""
try:
txt = open(INFORME_RAG, encoding="utf-8").read()
except OSError:
return None
# el bloque CANDIDATO es el que se promociono; si no, el ultimo que haya
def pct(clave):
# " hit@5 43/45 (95%)" -> 95
ms = re.findall(clave + r"\s+\d+/\d+\s+\((\d+)%\)", txt)
return int(ms[-1]) if ms else None
h5, h1 = pct("hit@5"), pct("hit@1")
if h5 is None:
return None
return {"hit5": h5, "hit1": h1 or 0}
def lee_medidas():
"""El dict de medidas, con las unidades ya listas para el HUD."""
diario = lee()[-MUESTRA:]
if not diario:
return {"n": 0}
segs = [d["seg"] for d in diario if isinstance(d.get("seg"), (int, float)) and d["seg"] > 0]
seg_cerebro = [d["seg"] for d in diario
if d.get("via") == CEREBRO and isinstance(d.get("seg"), (int, float))]
seg_catalogo = [d["seg"] for d in diario
if d.get("via") == "catalogo" and isinstance(d.get("seg"), (int, float))]
vias = {}
for d in diario:
vias[d.get("via", "?")] = vias.get(d.get("via", "?"), 0) + 1
total = len(diario)
def pct_via(v):
return round(100 * vias.get(v, 0) / total) if total else 0
# volumen: cuantas en las ultimas 24 h, y por hora las ultimas 12 para la traza
ahora = datetime.now()
hace24 = ahora - timedelta(hours=24)
vol24 = 0
porhora = [0] * 12
for d in diario:
try:
t = datetime.fromisoformat(d.get("t", ""))
except (ValueError, TypeError):
continue
if t >= hace24:
vol24 += 1
h = int((ahora - t).total_seconds() // 3600)
if 0 <= h < 12:
porhora[11 - h] += 1
medidas = {
"n": total,
"lat_p50": round(_percentil(segs, 0.50), 2),
"lat_p95": round(_percentil(segs, 0.95), 2),
"lat_max": round(max(segs), 2) if segs else 0.0,
"lat_cerebro": round(sum(seg_cerebro) / len(seg_cerebro), 2) if seg_cerebro else 0.0,
"lat_catalogo": round(sum(seg_catalogo) / len(seg_catalogo), 2) if seg_catalogo else 0.0,
"pct_cerebro": pct_via("cerebro"),
"pct_catalogo": pct_via("catalogo"),
"pct_ruido": pct_via("ruido"),
"vol24": vol24,
"porhora": porhora,
}
rag = _rag()
if rag:
medidas["rag"] = rag
return medidas

147
nucleo/manos/preguntar.py Normal file
View file

@ -0,0 +1,147 @@
"""Que preguntar cuando falta un dato para ejecutar una accion.
Pulsar una accion en el panel no puede limitarse a recitar la frase que la
dispara "diga «busca el fichero» y a continuacion lo que busca": eso es un
manual de instrucciones, y uno pulsa justo porque no quiere leerse el manual.
Lo natural es que pregunte lo que falta y lo haga.
busca el fichero -> ¿Que fichero, señor?
manda la ventana a la pantalla -> ¿A que pantalla, señor?
mata el proceso -> ¿Que proceso, señor?
## Como sale la pregunta
Del propio catalogo, no de una lista escrita a mano ni de un modelo. Las frases
del catalogo son regulares verbo + articulo + objeto asi que basta con buscar
el ULTIMO articulo y quedarse con lo que va detras.
Y se pregunta por el OBJETO, no conjugando el verbo. "¿Que fichero, señor?" es
tan natural como "¿Que fichero busco, señor?" y no obliga a conjugar: pasar de
"busca" a "busco", de "leeme" a "leo" o de "pon" a "pongo" pide un diccionario
de verbos irregulares para ganar nada.
Si la preposicion viene delante del articulo, se conserva: sin ella, "manda la
ventana a la pantalla" preguntaria "¿Que pantalla?" y se pierde el "a", que es
justo lo que dice que la respuesta es un destino.
"""
import re
import unicodedata
ARTICULOS = {"el", "la", "los", "las", "un", "una", "unos", "unas"}
# Las contracciones ya llevan la preposicion dentro.
CONTRACCIONES = {"al": "a", "del": "de"}
PREPOSICIONES = {"a", "de", "en", "con", "por", "para", "sobre", "hacia", "desde"}
# Cuando no hay de donde sacarlo. Vale para "esta corriendo" o "tengo instalado",
# donde el objeto no aparece en la frase.
GENERICA = "¿El que, señor?"
def _sin_tildes(t: str) -> str:
t = unicodedata.normalize("NFD", t)
return "".join(c for c in t if unicodedata.category(c) != "Mn")
def pregunta_por(accion) -> str:
"""La pregunta que rellena el hueco de una accion que espera argumento.
La clave: el argumento va SIEMPRE al final de la frase, porque asi lo
recoge el emparejador. Asi que la pregunta se decide mirando como TERMINA.
"""
frases = getattr(accion, "frases", None) or []
if not frases:
return GENERICA
palabras = _sin_tildes(frases[0].lower()).split()
if not palabras:
return GENERICA
ultima = palabras[-1]
# 1. Termina en preposicion: el hueco va justo detras.
# "busca el precio de" -> ¿El precio de que, señor?
# "busca imagenes de" -> ¿Imagenes de que, señor?
if ultima in PREPOSICIONES:
trozo = []
# Sin la primera palabra: es el verbo, y en la pregunta sobra.
# "busca imagenes de" tiene que dar "¿Imagenes de que?", no
# "¿Busca imagenes de que?".
for p in reversed(palabras[1:-1]):
if p in ARTICULOS or p in CONTRACCIONES:
trozo.insert(0, p)
break
if p in PREPOSICIONES:
break
trozo.insert(0, p)
if len(trozo) >= 2:
break
if trozo:
frase = " ".join(trozo).capitalize()
return f"¿{frase} {ultima} que, señor?"
return GENERICA
# 2. "busca en <sitio>": lo que falta es QUE buscar, no el sitio.
# Sin esta regla, "busca en la wikipedia" preguntaba "¿En que
# wikipedia?", que no tiene sentido: solo hay una.
if "en" in palabras:
i = palabras.index("en")
sitio = " ".join(palabras[i:])
verbo = palabras[0]
if verbo.startswith("busca"):
return f"¿Que busco {sitio}, señor?"
return f"¿Que {sitio}, señor?"
# 3. Lo normal: articulo + objeto al final.
# "busca el fichero" -> ¿Que fichero, señor?
# "manda la ventana a la pantalla" -> ¿A que pantalla, señor?
for i in range(len(palabras) - 1, -1, -1):
p = palabras[i]
objeto = palabras[i + 1] if i + 1 < len(palabras) else ""
if not objeto:
continue
if p in CONTRACCIONES:
return f"¿{CONTRACCIONES[p].capitalize()} que {objeto}, señor?"
if p in ARTICULOS:
anterior = palabras[i - 1] if i > 0 else ""
if anterior in PREPOSICIONES:
return f"¿{anterior.capitalize()} que {objeto}, señor?"
return f"¿Que {objeto}, señor?"
return GENERICA
def confirmacion(accion) -> str:
"""Lo que se pregunta antes de algo que no tiene vuelta atras.
La orden se CITA en vez de meterla en la frase. Las del catalogo estan en
imperativo "cierra el navegador" y encajarlas da cosas como "Voy a cierra
el navegador". Conjugarlas al infinitivo pediria un diccionario de verbos
irregulares (cierra->cerrar, pon->poner, ve->ir) para ganar poco: entre
comillas se lee bien y ademas queda claro que es literalmente la orden.
"""
frase = (getattr(accion, "frases", None) or [""])[0]
return f"«{frase}». Esto no tiene vuelta atras. ¿Confirma, señor?"
# Lo que cuenta como si y como no. Se comparan sin tildes y sin puntuacion,
# porque llegan de Whisper: "sí", "Si.", "SÍ" y "sip" son lo mismo.
SI = {"si", "sip", "claro", "vale", "venga", "adelante", "confirmo", "hazlo",
"por supuesto", "correcto", "afirmativo", "eso es", "dale", "ok", "okey"}
NO = {"no", "nop", "nada", "dejalo", "olvidalo", "cancela", "cancelalo",
"para", "mejor no", "negativo", "ni hablar", "espera"}
def es_si(texto: str):
"""True si es un si, False si es un no, None si no es ni una cosa ni otra."""
limpio = _sin_tildes(re.sub(r"[¡!¿?.,;]", "", (texto or "").lower())).strip()
if not limpio:
return None
if limpio in SI:
return True
if limpio in NO:
return False
# Tambien al principio: "si, hazlo" o "no, dejalo"
primera = limpio.split()[0]
if primera in SI:
return True
if primera in NO:
return False
return None

95
nucleo/manos/seleccion.py Normal file
View file

@ -0,0 +1,95 @@
"""Elegir de una lista por voz, y buscar dentro de un fichero (Ctrl+F).
La capa que le faltaba a las acciones de fichero. Buscar y soltar el primer
resultado no sirve cuando hay varios: hace falta enseñar la lista y poder decir
"abre el tercero". Y para buscar DENTRO de un fichero como con Ctrl+F, hace
falta recordar cual era.
Aqui va la logica pura interpretar "el tercero", detectar las ordenes de
seleccion y de busqueda interna; el estado (la ultima lista, el ultimo
fichero) y la ejecucion viven en el panel.
"""
import re
# Como se dice cada posicion. Se aceptan el ordinal, el numero en palabra y la
# cifra, porque el reconocedor devuelve cualquiera de los tres.
ORDINALES = {
1: ["primero", "primera", "uno", "1"],
2: ["segundo", "segunda", "dos", "2"],
3: ["tercero", "tercera", "tres", "3"],
4: ["cuarto", "cuarta", "cuatro", "4"],
5: ["quinto", "quinta", "cinco", "5"],
6: ["sexto", "sexta", "seis", "6"],
7: ["septimo", "septima", "siete", "7"],
8: ["octavo", "octava", "ocho", "8"],
9: ["noveno", "novena", "nueve", "9"],
10: ["decimo", "decima", "diez", "10"],
}
_PALABRA_A_N = {p: n for n, ps in ORDINALES.items() for p in ps}
# "el ultimo" es comodo y no es un numero
_ULTIMO = {"ultimo", "ultima", "final"}
def posicion(texto: str):
"""Devuelve el numero (1..N) que se ha dicho, -1 para 'el ultimo', o None.
Se queda con la PRIMERA posicion mencionada, para que "abre el tercero"
de 3 y no se lie con otros numeros que puedan venir detras.
"""
for palabra in texto.split():
if palabra in _ULTIMO:
return -1
if palabra in _PALABRA_A_N:
return _PALABRA_A_N[palabra]
return None
# Ordenes que actuan sobre la lista ya mostrada. El verbo dice que hacer con lo
# elegido; la posicion, cual.
_ABRIR = re.compile(r"\b(abre|abreme|abrir)\b")
_LEER = re.compile(r"\b(lee|leeme|leer|muestrame|ensename)\b")
_SELECCION = re.compile(r"\b(el|la|numero|opcion|resultado)\b")
def orden_de_seleccion(texto: str):
"""(accion, posicion) si el texto elige de la lista, o None.
accion: "abrir" o "leer". posicion: 1..N o -1 (ultimo).
Ejemplos: "abre el tercero", "lee el numero dos", "el ultimo", "abre el 3".
"""
pos = posicion(texto)
if pos is None:
return None
# tiene que sonar a seleccion, no a otra cosa que lleve un numero
if not (_SELECCION.search(texto) or _ABRIR.search(texto) or _LEER.search(texto)):
return None
if _LEER.search(texto):
return ("leer", pos)
# por defecto, elegir es abrir
return ("abrir", pos)
# Ctrl+F: buscar un termino dentro del ultimo fichero abierto.
_DENTRO = re.compile(
r"\b(busca|buscame|encuentra)\b.*\b(dentro|en el fichero|en el archivo|en el texto)\b")
def orden_buscar_dentro(texto: str):
"""El termino a buscar dentro del fichero, o None.
"busca dentro configuracion", "busca en el texto la palabra error".
Se queda con lo que va despues de 'dentro'/'en el ...'.
"""
if not _DENTRO.search(texto):
return None
# cortar por el separador y quedarse con el resto
for sep in ("dentro de", "dentro", "en el fichero", "en el archivo", "en el texto",
"la palabra"):
if sep in texto:
resto = texto.split(sep, 1)[1].strip()
resto = re.sub(r"^(la palabra|el termino|de|la|el|los|las)\s+",
"", resto).strip()
if resto:
return resto
return None

134
nucleo/manos/sudo_root.py Normal file
View file

@ -0,0 +1,134 @@
"""Ejecutar ordenes como root de forma controlada.
El problema que resuelve. Newelle convierte `sudo` en `sudo -S` (leer la
contraseña de la entrada estandar), pero en el panel de voz no hay una entrada
donde teclearla, asi que se queda colgado esperando una contraseña que no
llega. Y las acciones marcadas para confirmar no tenian dialogo: solo decian
"confirme antes" y no ejecutaban nada.
Como se resuelve, y las decisiones de seguridad detras:
- La contraseña NUNCA se escribe en disco. Vive en memoria de este proceso solo
si el usuario activa el permiso de sesion, y se pasa a `sudo -S` por stdin.
- El permiso de sesion CADUCA: a los 15 minutos de la ultima orden se olvida
sola, y tambien al cerrar el panel. No es un "root para siempre".
- CADA orden con root muestra su comando exacto en un dialogo antes de correr.
El permiso de sesion solo ahorra re-teclear la contraseña, nunca salta el ver
y aprobar lo que se va a ejecutar. La voz no puede autoaprobar root.
Alternativa descartada: apoyarse solo en el timestamp de sudo (`sudo -v`). Es
mas limpio en teoria lo gestiona el sistema pero a traves de flatpak-spawn el
tty cambia entre llamadas y el cacheo no es fiable. Mantener la contraseña en
memoria del proceso, con caducidad y sin tocar disco, funciona siempre y el
usuario opta a ello de forma explicita.
"""
import re
import subprocess
import time
# A los 15 minutos sin usarla, la contraseña en memoria se olvida. Igual que el
# valor por defecto de sudo, para no sorprender.
CADUCIDAD = 15 * 60
# Detecta una invocacion real de sudo: al principio del comando o justo tras un
# separador (;, |, &, &&, ||, salto de linea), no la palabra "sudo" dicha como
# argumento ("echo sudo no cuenta"). Errar aqui solo pediria una contraseña de
# mas, no es peligroso, pero molesta.
_SUDO = re.compile(r"(?:^|[;&|\n]|&&|\|\|)\s*sudo(?:\s|$)")
def necesita_root(comando: str) -> bool:
"""True si el comando lanza algo con sudo."""
return bool(comando and _SUDO.search(comando))
class GestorRoot:
"""Guarda —o no— la contraseña de sesion, y ejecuta con ella.
Una instancia por panel. Al cerrar el panel se llama a olvida().
"""
def __init__(self, prefijo_host=None):
# prefijo para salir del sandbox; se pasa como argumento para poder
# probar esto sin flatpak
self._prefijo = list(prefijo_host or [])
self._password = None
self._ultimo_uso = 0.0
# -------------------------------------------------------------- sesion
def sesion_activa(self) -> bool:
"""Hay contraseña recordada y aun no ha caducado."""
if self._password is None:
return False
if time.monotonic() - self._ultimo_uso > CADUCIDAD:
self.olvida()
return False
return True
def olvida(self):
"""Borra la contraseña de memoria. Se llama al cerrar el panel y al
caducar. Se sobreescribe antes de soltarla, por si acaso."""
if self._password is not None:
self._password = "\x00" * len(self._password)
self._password = None
self._ultimo_uso = 0.0
def recuerda(self, password: str):
self._password = password
self._ultimo_uso = time.monotonic()
# ------------------------------------------------------------ ejecutar
def valida(self, password: str) -> bool:
"""Comprueba la contraseña con `sudo -S -v`, sin ejecutar nada mas.
-k borra cualquier timestamp previo para que la comprobacion sea de
verdad; -v solo actualiza credenciales. Si la contraseña es correcta
devuelve True; nunca lanza ningun comando del usuario.
"""
try:
r = subprocess.run(
self._prefijo + ["sudo", "-S", "-k", "-p", "", "-v"],
input=password + "\n", capture_output=True, text=True,
timeout=10)
return r.returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def ejecuta(self, comando: str, password: str = None, timeout: int = 30):
"""Ejecuta el comando dando la contraseña a los sudo que lleve.
password: si se pasa, se usa esa vez; si no, la de la sesion. La
contraseña se pasa por stdin a `sudo -S`. No se registra en ningun
sitio.
Devuelve (salida, codigo). codigo -1 = no habia contraseña disponible.
"""
clave = password if password is not None else self._password
if clave is None:
return "No hay contraseña disponible para root.", -1
# que todos los sudo del comando lean de stdin sin imprimir el prompt
preparado = re.sub(r"(^|[;&|]|\s)sudo(\s)",
r"\1sudo -S -p '' \2", comando)
try:
r = subprocess.run(
self._prefijo + ["bash", "-lc", preparado],
input=clave + "\n", capture_output=True, text=True,
timeout=timeout)
salida = (r.stdout or r.stderr or "").strip()
# sudo escribe su prompt/errores en stderr aunque -p sea vacio;
# se limpia lo que es ruido de sudo, no del comando
salida = "\n".join(
l for l in salida.splitlines()
if l.strip() and not l.startswith(("[sudo]", "Sorry, try again")))
self._ultimo_uso = time.monotonic()
return salida, r.returncode
except subprocess.TimeoutExpired:
return "La orden con root ha tardado demasiado.", 124
except OSError as e:
return f"No se pudo ejecutar: {e}", 1
finally:
# no dejar la copia local viva mas de lo necesario
clave = "\x00" * len(clave)

346
nucleo/manos/tareas.py Normal file
View file

@ -0,0 +1,346 @@
"""Tareas en paralelo: cada orden corre por su cuenta y se puede parar.
Hasta ahora una orden era una llamada a `ejecuta()`: un `subprocess.run` con
veinte segundos de limite que bloqueaba el hilo hasta terminar. Servia porque
todos los atajos eran instantaneos, pero deja fuera justo lo que uno querria
mandar y olvidarse: un escaneo, una busqueda por todo el disco, una descarga.
Y no habia forma de arrepentirse a mitad.
Aqui cada orden es una Tarea con nombre, estado y un proceso propio del que se
guarda el grupo, para poder matarlo entero. Tres cosas que costaron decidir:
- **Se mata el grupo, no el proceso.** El comando real es `bash -lc "..."`, asi
que matar el bash deja vivos a sus hijos el `find`, el `nmap` corriendo y
comiendo disco con la tarjeta ya cerrada. Con `start_new_session=True` el
proceso estrena grupo y `killpg` se los lleva a todos.
- **Fuera del sandbox tambien vale.** Comprobado: matar el `flatpak-spawn`
intermediario propaga la señal al proceso del host (2 procesos vivos -> 0).
No hace falta perseguir el PID del otro lado.
- **La salida se lee segun sale, no al final.** Con `run()` no hay nada que
enseñar hasta que termina, y una tarjeta de un escaneo de tres minutos que
esta en blanco los tres minutos no informa de nada. Se lee linea a linea y se
guardan las ultimas, que es lo que cabe en la tarjeta.
"""
import os
import shlex
import signal
import subprocess
import threading
import time
import urllib.parse
# Lo que se guarda de la salida para enseñar en la tarjeta y para la plantilla
# de respuesta. Una tarjeta pequeña no da para mas, y guardar la salida entera
# de un `find /` es meterse megas en memoria por nada.
LINEAS_VISTA = 6
SALIDA_MAXIMA = 600
# Con boton de parar, el limite deja de ser una proteccion y pasa a ser un
# estorbo: veinte segundos matan cualquier escaneo util. Se deja un tope alto
# como red para lo que se cuelgue de verdad y no vuelva nunca.
LIMITE_LARGO = 900 # 15 minutos
# Cada cuanto se avisa al HUD mientras sale texto. Sin freno, un comando
# hablador manda cientos de mensajes por segundo al WebView y la interfaz se
# atasca dibujando texto que nadie llega a leer.
CADENCIA_AVISO = 0.25
CORRIENDO, HECHA, PARADA, FALLO, CADUCADA = (
"corriendo", "hecha", "parada", "fallo", "caducada")
class Tarea:
"""Una orden en marcha. Solo datos; quien la mueve es el gestor."""
def __init__(self, ident, titulo, accion_id, comando):
self.id = ident
self.titulo = titulo
self.accion_id = accion_id
self.comando = comando
# La accion de origen viaja con la tarea (y no en un diccionario
# aparte del panel) porque una tarea sin comando termina DENTRO de
# lanza(), antes de que quien la lanzo tenga el id para apuntarla: el
# aviso de "terminada" llegaria a un registro todavia vacio.
self.accion = None
self.argumento = ""
self.estado = CORRIENDO
self.inicio = time.monotonic()
self.fin = None
self.lineas = [] # las ultimas LINEAS_VISTA de salida
self.salida = "" # la salida acumulada, recortada
self.codigo = None
self._proc = None
@property
def segundos(self) -> float:
return (self.fin or time.monotonic()) - self.inicio
@property
def viva(self) -> bool:
return self.estado == CORRIENDO
def resumen(self) -> dict:
"""Lo que viaja al HUD. Sin objetos: esto acaba en un json.dumps."""
return {
"id": self.id,
"titulo": self.titulo,
"accion": self.accion_id,
"estado": self.estado,
"segundos": round(self.segundos, 1),
"lineas": self.lineas[-LINEAS_VISTA:],
"codigo": self.codigo,
}
class GestorTareas:
"""Lanza, vigila y para tareas. Un unico sitio donde vive el registro.
al_cambiar(tarea) se llama desde el hilo de la tarea cada vez que hay algo
nuevo que enseñar. Quien lo reciba se encarga de saltar al hilo de la
interfaz: aqui no se sabe nada de GTK a proposito, para poder probarlo sin
levantar la aplicacion.
"""
def __init__(self, al_cambiar=None, prefijo_host=()):
self._al_cambiar = al_cambiar or (lambda tarea: None)
self._prefijo_host = _con_watch_bus(prefijo_host)
self._tareas = {}
self._siguiente = 0
self._cerrojo = threading.Lock()
# -------------------------------------------------------------- registro
def _nueva(self, titulo, accion_id, comando) -> Tarea:
with self._cerrojo:
self._siguiente += 1
ident = f"t{self._siguiente}"
tarea = Tarea(ident, titulo, accion_id, comando)
self._tareas[ident] = tarea
return tarea
def get(self, ident):
with self._cerrojo:
return self._tareas.get(ident)
def activas(self) -> list:
with self._cerrojo:
return [t for t in self._tareas.values() if t.viva]
def todas(self) -> list:
with self._cerrojo:
return list(self._tareas.values())
def _avisa(self, tarea):
try:
self._al_cambiar(tarea)
except Exception as e: # el HUD no tumba la tarea
print(f"JARVIS: aviso de tarea fallido: {e}")
# ---------------------------------------------------------------- lanzar
def lanza(self, accion, argumento="", titulo=None) -> Tarea:
"""Arranca la accion en su propio proceso y devuelve la tarea ya viva."""
comando = accion.comando or ""
if comando:
comando = comando.replace("{argumento}", shlex.quote(argumento))
comando = comando.replace(
"{argumento_url}",
shlex.quote(urllib.parse.quote_plus(argumento)))
tarea = self._nueva(titulo or _titulo_de(accion, argumento),
accion.id, comando)
tarea.accion = accion
tarea.argumento = argumento
self._avisa(tarea)
if not comando:
# Acciones que solo hablan (no tienen comando): nacen terminadas.
self.termina(tarea.id, HECHA, "")
return tarea
orden = ["bash", "-lc", comando]
if accion.host:
orden = self._prefijo_host + orden
threading.Thread(target=self._corre, args=(tarea, orden),
daemon=True).start()
return tarea
def adopta(self, titulo, accion_id="") -> Tarea:
"""Una tarjeta para trabajo que se ejecuta fuera del gestor.
Lo que pasa por el dialogo de root corre por su camino (GestorRoot, con
su contraseña), pero se merece tarjeta igual: si no, pedir algo con
sudo hace desaparecer la señal de que hay algo en marcha.
"""
tarea = self._nueva(titulo, accion_id, "")
self._avisa(tarea)
return tarea
def _corre(self, tarea, orden):
try:
# start_new_session: el proceso estrena grupo, para poder matar el
# arbol entero y no solo el bash de arriba.
proc = subprocess.Popen(
orden, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, errors="replace", start_new_session=True)
except OSError as e:
self.termina(tarea.id, FALLO, f"No he podido ejecutarlo: {e}")
return
tarea._proc = proc
limite = threading.Timer(LIMITE_LARGO, self._caduca, args=(tarea.id,))
limite.daemon = True
limite.start()
ultimo_aviso = 0.0
acumulado = []
try:
for linea in proc.stdout:
linea = linea.rstrip("\n")
if not linea.strip():
continue
acumulado.append(linea)
tarea.lineas = acumulado[-LINEAS_VISTA:]
ahora = time.monotonic()
if ahora - ultimo_aviso >= CADENCIA_AVISO:
ultimo_aviso = ahora
self._avisa(tarea)
except Exception as e:
print(f"JARVIS: leyendo la salida de {tarea.id}: {e}")
finally:
limite.cancel()
codigo = proc.wait()
salida = "\n".join(acumulado).strip()
if tarea.estado == PARADA: # la paro alguien: no se toca
self._avisa(tarea)
return
if tarea.estado == CADUCADA:
self._avisa(tarea)
return
self.termina(tarea.id, HECHA if codigo == 0 else FALLO, salida, codigo)
# ------------------------------------------------------------- terminar
def termina(self, ident, estado, salida="", codigo=None):
tarea = self.get(ident)
if tarea is None:
return
tarea.estado = estado
tarea.fin = time.monotonic()
tarea.codigo = codigo
if salida:
tarea.salida = salida[:SALIDA_MAXIMA]
if not tarea.lineas:
tarea.lineas = salida.splitlines()[-LINEAS_VISTA:]
else:
tarea.salida = "\n".join(tarea.lineas)[:SALIDA_MAXIMA]
self._avisa(tarea)
def para(self, ident) -> bool:
"""Corta la tarea. Primero por las buenas, luego a la fuerza."""
tarea = self.get(ident)
if tarea is None or not tarea.viva:
return False
tarea.estado = PARADA
proc = tarea._proc
if proc is not None and proc.poll() is None:
_mata_grupo(proc)
tarea.fin = time.monotonic()
self._avisa(tarea)
return True
def _caduca(self, ident):
tarea = self.get(ident)
if tarea is None or not tarea.viva:
return
tarea.estado = CADUCADA
if tarea._proc is not None and tarea._proc.poll() is None:
_mata_grupo(tarea._proc)
tarea.fin = time.monotonic()
self._avisa(tarea)
def cierra(self, ident) -> bool:
"""Quita la tarjeta. Si seguia viva, la para antes: cerrar es cerrar."""
tarea = self.get(ident)
if tarea is None:
return False
if tarea.viva:
self.para(ident)
with self._cerrojo:
self._tareas.pop(ident, None)
return True
def para_todas(self):
for tarea in self.activas():
self.para(tarea.id)
def _con_watch_bus(prefijo) -> list:
"""Que lo lanzado fuera del sandbox no sobreviva a la aplicacion.
Una tarea larga corre en el host por flatpak-spawn, y ahi el proceso ya no
es hijo de nadie que se entere de que la app se ha cerrado: un escaneo
lanzado y olvidado seguiria comiendo maquina despues de cerrar la ventana,
sin ventana desde la que pararlo. `--watch-bus` ata su vida a la conexion
de bus de quien lo lanzo, que es justo lo que se quiere.
"""
prefijo = list(prefijo)
if not prefijo:
return prefijo
if any("flatpak-spawn" in p for p in prefijo) and "--watch-bus" not in prefijo:
# detras del propio flatpak-spawn, antes de sus argumentos
for i, p in enumerate(prefijo):
if "flatpak-spawn" in p:
prefijo.insert(i + 1, "--watch-bus")
break
return prefijo
def _mata_grupo(proc):
"""SIGTERM al grupo, y SIGKILL a los tres segundos si sigue ahi.
Se da margen a proposito: un `wget` a medias cierra el fichero y un `nmap`
escupe lo que llevaba. Matar a lo bruto de entrada deja restos.
"""
try:
grupo = os.getpgid(proc.pid)
except OSError:
return
try:
os.killpg(grupo, signal.SIGTERM)
except OSError:
return
def remata():
if proc.poll() is None:
try:
os.killpg(grupo, signal.SIGKILL)
except OSError:
pass
t = threading.Timer(3.0, remata)
t.daemon = True
t.start()
def _titulo_de(accion, argumento) -> str:
"""El rotulo de la tarjeta: corto, y con el argumento si lo hay.
El id de la accion con guiones bajos ("buscar_fichero") es de programador;
en la tarjeta se lee "buscar fichero", y con lo que se pidio detras, que es
lo que distingue dos busquedas a la vez.
"""
base = (accion.id or "accion").replace("_", " ")
if argumento:
arg = argumento.strip()
if len(arg) > 22:
arg = arg[:21] + ""
return f"{base}: {arg}"
return base

182
nucleo/mejora.py Executable file
View file

@ -0,0 +1,182 @@
#!/usr/bin/env python3
"""Que ha aprendido JARVIS del uso, y que conviene cambiar.
./mejora.py que hay en el diario
./mejora.py --propone que acciones o frases faltan
./mejora.py --mide cuanto acierta ahora mismo
## En que se diferencia de la mejora nocturna que se retiro
La de antes corria sola de madrugada, se modificaba el codigo y se commiteaba.
Nueve horas, 39 rondas, cero commits utiles. Fallo por tres cosas, y las tres
estan corregidas aqui:
1. **Minaba ruido.** Su fuente era un registro donde el 60 % era JARVIS
oyendose a si mismo y alucinaciones de Whisper. Ahora la guarda del eco y
el filtro de ruido limpian antes, y el diario apunta la VIA de cada frase,
asi que el ruido esta separado y etiquetado en vez de mezclado.
2. **Su medida tenia mas ruido que su mejora.** Generaba audio nuevo cada
ronda: ±1,9 puntos de ruido contra +1,45 de mejora acumulada, o sea que
"¿esto mejora o empeora?" se decidia a cara o cruz. Ahora se mide contra
`datos/banco.json`, que es fijo y determinista.
3. **Actuaba sola.** Un bucle que se reescribe y se aprueba a si mismo acaba
pasando siempre. Esto PROPONE y para: lo que se aplica lo decide una
persona mirandolo.
Y una cuarta, de fondo: aquella tocaba el codigo. Esta solo mira el catalogo,
que es lo unico que se puede validar de verdad con el banco.
"""
import argparse
import collections
import os
import sys
AQUI = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, AQUI)
from manos import Manos # noqa: E402
from manos.acciones import normaliza, parecido # noqa: E402
from manos.diario import lee # noqa: E402
def color(t, c):
return f"\033[{c}m{t}\033[0m" if sys.stdout.isatty() else t
# ------------------------------------------------------------------ mirar
def resumen(lineas):
if not lineas:
print(" el diario esta vacio: usa JARVIS un rato y vuelve")
return 1
vias = collections.Counter(l.get("via", "?") for l in lineas)
tot = sum(vias.values())
print(f"\n {tot} frases desde {lineas[0]['t'][:10]}\n")
print(f" {'via':14s} {'n':>5s} {'%':>7s}")
print(" " + "-" * 32)
ORDEN = ["catalogo", "cerebro", "respuesta", "ruido", "sin-nombre",
"bloqueado", "sin-cerebro"]
for v in ORDEN + [k for k in vias if k not in ORDEN]:
if v in vias:
print(f" {v:14s} {vias[v]:5d} {100*vias[v]/tot:6.1f} %")
atendidas = vias["catalogo"] + vias["cerebro"]
if atendidas:
print(f"\n de lo ATENDIDO ({atendidas}), el catalogo cogio "
f"{100*vias['catalogo']/atendidas:.0f} %")
segs = [l.get("seg", 0) for l in lineas if l.get("via") == "catalogo"]
segc = [l.get("seg", 0) for l in lineas if l.get("via") == "cerebro"]
if segs:
print(f" catalogo: {sum(segs)/len(segs):.2f} s de media")
if segc:
print(f" cerebro: {sum(segc)/len(segc):.1f} s de media")
ahorro = (sum(segc)/len(segc) - (sum(segs)/len(segs) if segs else 0))
print(f"\n cada accion que se añada al catalogo ahorra ~{ahorro:.0f} s")
top = collections.Counter(l["accion"] for l in lineas
if l.get("via") == "catalogo" and l.get("accion"))
if top:
print("\n lo que mas usas:")
for a, n in top.most_common(8):
print(f" {n:4d} {a}")
return 0
# --------------------------------------------------------------- proponer
def propone(lineas, manos):
"""Lo que fue al cerebro y podria ser una accion del catalogo."""
# "sin-cerebro" cuenta igual: es lo mismo —una frase que el catalogo no
# cubrio— y solo se diferencia en que no habia modelo conectado para
# atenderla. El hueco del catalogo es el mismo.
alCerebro = [l for l in lineas if l.get("via") in ("cerebro", "sin-cerebro")]
if not alCerebro:
print("\n nada ha ido al cerebro todavia: no hay hueco que rellenar")
return 0
# Agrupar lo parecido: si has pedido lo mismo de tres formas, es UNA accion
# que falta, no tres. Y que lo hayas repetido es justo la señal de que
# importa.
grupos = []
for l in alCerebro:
t = normaliza(l["oido"])
if len(t.split()) < 2:
continue
for g in grupos:
if parecido(t, g["patron"]) > 0.62:
g["frases"].append(l["oido"])
break
else:
grupos.append({"patron": t, "frases": [l["oido"]],
"dicho": l.get("dicho", "")})
grupos.sort(key=lambda g: -len(g["frases"]))
print(f"\n {len(alCerebro)} frases fueron al cerebro, en {len(grupos)} grupos.")
print(" Las que mas se repiten son las acciones que faltan:\n")
for g in grupos[:12]:
n = len(g["frases"])
marca = color(f"{n:3d} veces", "33" if n > 1 else "90")
print(f" {marca} {g['frases'][0][:64]}")
for f in g["frases"][1:4]:
print(f" {color('tambien: ' + f[:56], '90')}")
if g["dicho"]:
print(f" {color('contesto: ' + g['dicho'][:56], '90')}")
print()
repetidas = [g for g in grupos if len(g["frases"]) > 1]
print(f" {len(repetidas)} se han pedido mas de una vez: por ahi empezaria.\n")
print(color(" Esto NO cambia nada. Para añadir una accion se edita\n"
" datos/_generar.py —que es la FUENTE— y se regenera.\n", "90"))
return 0
# ------------------------------------------------------------------ medir
def mide(manos):
"""El acierto del emparejador contra el banco fijo."""
import json
banco = os.path.join(AQUI, "datos", "banco.json")
try:
with open(banco, encoding="utf-8") as f:
casos = json.load(f)["casos"]
except OSError:
print(" no hay banco: pruebas/banco.py --crear")
return 1
bien = otra = 0
for c in casos:
a, _ = manos.busca(c["oido"])
if a is None:
continue
if a.id == c["id"]:
bien += 1
else:
otra += 1
print(f"\n acierto {100*bien/len(casos):.1f} % sobre {len(casos)} frases")
print(f" ordenes que acaban en la accion equivocada: {otra}")
print(color("\n Este numero es comparable entre dias: el banco esta grabado\n"
" una vez y no cambia. Era lo que le faltaba a la mejora\n"
" nocturna, cuyo ruido de medida era mayor que su mejora.\n", "90"))
return 0
def main() -> int:
p = argparse.ArgumentParser(description="Que ha aprendido JARVIS del uso")
p.add_argument("--propone", action="store_true")
p.add_argument("--mide", action="store_true")
a = p.parse_args()
manos = Manos()
lineas = lee()
if a.mide:
return mide(manos)
if a.propone:
return propone(lineas, manos)
return resumen(lineas)
if __name__ == "__main__":
sys.exit(main())

0
nucleo/oido/__init__.py Normal file
View file

188
nucleo/oido/captura.py Normal file
View file

@ -0,0 +1,188 @@
"""Escuchar el microfono y recortar lo que es habla.
## Por que pw-record y no pyaudio
Newelle usa pyaudio, que necesita portaudio compilado. En esta maquina no esta,
asi que instalarlo pediria sudo. `pw-record` y `parec` ya vienen con PipeWire y
sirven igual: se lanzan como subproceso y se lee su salida estandar.
Y hay una ventaja que no es casual: la fuente se elige con un argumento. Cuando
se ponga el cancelador de eco de PipeWire, apuntar ahi es cambiar una cadena, no
tocar codigo de audio.
## La guarda contra el eco
Esta es la razon de ser de este fichero. En Newelle, el 21 % largo de lo que
JARVIS oye y no entiende es su propia voz. Tiene mecanismo para evitarlo pero
con tres agujeros, y aqui se cierran los tres:
1. **Se consulta a la boca en cada trozo**, no una bandera que alguien actualiza
desde otro hilo. Si `boca.hablando` es cierto, ese audio se tira sin mirar.
2. **La cola de 350 ms** vive en la boca (`COLA_MS`): cuando el reproductor
termina, el sonido sigue llegando al microfono un rato.
3. **El prebufer se vacia** al salir de "hablando". Newelle guarda 1 s circular
y lo antepone al detectar voz; ese segundo era justo el final de la frase de
JARVIS, asi que se colaba aunque la guarda funcionase.
"""
import collections
import os
import signal
import struct
import subprocess
import tempfile
import wave
FRECUENCIA = 16000 # lo que quiere whisper.cpp
CANALES = 1
ANCHO = 2 # s16
TROZO = 512 # muestras: lo que pide Silero de una vez
# Cuanto silencio hace falta para dar la frase por terminada. Muy corto corta a
# quien piensa a media frase; muy largo hace que JARVIS parezca lento.
SILENCIO_PARA_CERRAR = 0.8
# Cuanto se guarda de ANTES de detectar voz. La primera silaba siempre llega
# antes de que el detector se entere.
PREBUFER_S = 0.5
# Frases mas cortas que esto no son ordenes, son ruidos.
MINIMO_S = 0.35
# Y mas largas que esto es que el detector se ha quedado enganchado.
MAXIMO_S = 25.0
def _grabador(fuente=None):
"""La orden para capturar crudo a la salida estandar."""
if fuente:
pw = ["pw-record", "--target", fuente]
else:
pw = ["pw-record"]
return pw + ["--rate", str(FRECUENCIA), "--channels", str(CANALES),
"--format", "s16", "-"]
class Captura:
"""Trozos de microfono -> frases sueltas en WAV."""
def __init__(self, boca=None, fuente=None, agresividad=0.5):
self.boca = boca # para saber si JARVIS esta hablando
self.fuente = fuente
self.agresividad = agresividad
self._proc = None
self._vad = None
self._energia = None
# ------------------------------------------------------------ el detector
def _detector(self):
if self._vad is not None or self._energia is not None:
return
try:
from pysilero_vad import SileroVoiceActivityDetector
self._vad = SileroVoiceActivityDetector()
except Exception:
# Respaldo por energia: peor, pero mejor que no escuchar. Se avisa,
# porque la diferencia se nota y conviene saber en cual se esta.
print("oido: sin Silero, usando deteccion por energia")
self._energia = True
def _hay_voz(self, trozo: bytes) -> bool:
if self._vad is not None:
try:
return self._vad(trozo) >= self.agresividad
except Exception:
return False
muestras = struct.unpack(f"{len(trozo) // 2}h", trozo)
if not muestras:
return False
media = sum(m * m for m in muestras) / len(muestras)
return (media ** 0.5) / 32768.0 > 0.015
# -------------------------------------------------------------- escuchar
def frases(self, para_cuando=None):
"""Generador: devuelve la ruta de un WAV por cada frase oida.
El WAV es temporal y de quien lo recibe: se borra cuando quiera.
"""
self._detector()
self._proc = subprocess.Popen(
_grabador(self.fuente), stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, start_new_session=True)
bytes_trozo = TROZO * ANCHO
por_segundo = FRECUENCIA / TROZO
prebufer = collections.deque(maxlen=max(1, int(PREBUFER_S * por_segundo)))
cerrar_tras = int(SILENCIO_PARA_CERRAR * por_segundo)
tope = int(MAXIMO_S * por_segundo)
frase, callados, hablando = [], 0, False
try:
while True:
if para_cuando and para_cuando():
break
trozo = self._proc.stdout.read(bytes_trozo)
if not trozo or len(trozo) < bytes_trozo:
break
# --- la guarda: si JARVIS habla, esto es su propia voz -------
if self.boca is not None and self.boca.hablando:
# Tirar tambien lo acumulado: si estaba a media frase cuando
# JARVIS empezo a hablar, lo que quede lleva su voz encima.
prebufer.clear()
frase, callados, hablando = [], 0, False
continue
prebufer.append(trozo)
voz = self._hay_voz(trozo)
if voz and not hablando:
hablando = True
frase = list(prebufer) # con lo de antes de detectarla
callados = 0
elif hablando:
frase.append(trozo)
callados = 0 if voz else callados + 1
if hablando and (callados >= cerrar_tras or len(frase) >= tope):
dur = len(frase) * TROZO / FRECUENCIA
ruta = self._a_wav(frase) if dur >= MINIMO_S else None
frase, callados, hablando = [], 0, False
prebufer.clear()
if ruta:
yield ruta
finally:
self.para()
def _a_wav(self, trozos) -> str:
fd, ruta = tempfile.mkstemp(suffix=".wav", prefix="jarvis-oido-")
os.close(fd)
with wave.open(ruta, "wb") as w:
w.setnchannels(CANALES)
w.setsampwidth(ANCHO)
w.setframerate(FRECUENCIA)
w.writeframes(b"".join(trozos))
return ruta
def para(self):
if not self._proc:
return
try:
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
self._proc.wait(timeout=3)
except Exception:
pass
self._proc = None
def fuentes() -> list:
"""Las entradas que hay, para poder elegir la del cancelador de eco."""
try:
r = subprocess.run(["pactl", "list", "short", "sources"],
capture_output=True, text=True, timeout=10)
except (OSError, subprocess.TimeoutExpired):
return []
salida = []
for linea in r.stdout.splitlines():
campos = linea.split("\t")
if len(campos) > 1 and not campos[1].endswith(".monitor"):
salida.append(campos[1])
return salida

143
nucleo/oido/despierta.py Normal file
View file

@ -0,0 +1,143 @@
"""La palabra de activacion: "JARVIS".
Sin esto, el asistente obedece a cualquier cosa que se diga en la habitacion y
a la tele. Con esto, solo escucha de verdad durante un rato despues de oir su
nombre.
## Por que sobre el texto y no sobre el audio
Lo obvio seria un detector como openwakeword, que mira la forma de onda antes de
transcribir. Aqui se hace despues, sobre lo que Whisper ya escribio, y es a
proposito:
- **Ya tenemos la transcripcion.** El audio pasa por Whisper de todas formas
porque la captura la dispara el detector de voz; no hay ahorro que ganar.
- **Un modelo menos.** openwakeword son mas dependencias, otro modelo en
memoria y otro afinado que mantener.
- **Y sobre todo: Whisper oye "JARVIS" de mil maneras.** Medido en el registro
de uso real, el nombre sale como "Jarvis", "Yarvis", "Charles", "Chavez",
"Harvest"... Sobre el texto eso se arregla comparando como SUENA, que es lo
que ya hace `manos/fonetica.py` para el catalogo. Sobre la onda habria que
reentrenar.
Lo que se pierde: la transcripcion corre siempre, tambien cuando nadie ha
llamado. En una maquina donde Whisper va en la grafica y tarda dos segundos, es
un coste que ya estabamos pagando.
## Como se comporta
Se llama una vez y se queda despierto un rato, para poder encadenar ordenes sin
repetir el nombre en cada frase. Y si la frase que trae el nombre lleva ademas
la orden "JARVIS, cuanto espacio queda" se atiende esa misma, sin obligar a
decirlo dos veces.
"""
import difflib
import re
import time
# Absolutos, como el resto del nucleo: jarvis.py mete su carpeta en sys.path y
# todos los paquetes son de primer nivel. Con `..manos` esto solo se podria
# importar desde fuera, y las pruebas lo cargan suelto.
from manos.acciones import normaliza
from manos.fonetica import fonetica
NOMBRE = "jarvis"
# Cuanto sigue escuchando tras oir su nombre. Corto obliga a repetirlo en cada
# frase; largo devuelve al problema de origen, que obedezca a la tele.
DESPIERTO_S = 25.0
# Lo que Whisper escribe cuando oye "JARVIS". Salen del registro real de uso,
# no de imaginarlas.
PARECIDAS = (
"jarvis", "yarvis", "charvis", "jarbis", "yarbis", "harvis", "jervis",
"jarvi", "yarvi", "javis", "jarves", "charles", "chavez", "harvest",
"jarvix", "sharvis", "arvis",
)
# Se compara palabra a palabra, asi que el umbral puede ser alto sin perder
# aciertos: "yarvis" contra "jarvis" da 0,83 por letra y sube por sonido.
UMBRAL = 0.80
_LIMPIA = re.compile(r"^[\s,.:;¡!¿?]+|[\s,.:;¡!¿?]+$")
# Lo que suele acompañar al nombre sin ser una orden. Si al quitar el nombre
# solo queda esto, es que llamaron y ya: "oye jarvis" no es "haz oye".
LLAMADAS = {"oye", "eh", "hey", "ey", "hola", "escucha", "atiende", "perdona",
"por favor", "porfa", "venga", "a ver"}
def _suena_al_nombre(palabra: str) -> bool:
p = _LIMPIA.sub("", palabra)
if len(p) < 4:
return False
if p in PARECIDAS:
return True
for candidata in (NOMBRE,) + PARECIDAS[:6]:
if difflib.SequenceMatcher(None, p, candidata).ratio() >= UMBRAL:
return True
if difflib.SequenceMatcher(None, fonetica(p), fonetica(candidata)).ratio() >= UMBRAL:
return True
return False
def parte_el_nombre(texto: str):
"""(lo llamaron, lo que queda de la frase sin el nombre).
Se devuelve el resto porque "JARVIS, cuanto espacio queda" tiene que
ejecutarse en la misma frase: obligar a decir el nombre y luego la orden
aparte es exactamente lo que hace que un asistente canse.
"""
palabras = normaliza(texto).split()
if not palabras:
return False, ""
for i, p in enumerate(palabras):
if _suena_al_nombre(p):
sobra = [q for q in palabras[:i] + palabras[i + 1:]
if q not in LLAMADAS]
return True, " ".join(sobra).strip()
return False, ""
class Centinela:
"""Decide si una frase hay que atenderla o dejarla pasar."""
def __init__(self, activo=True, ventana=DESPIERTO_S):
self.activo = activo
self.ventana = ventana
self._hasta = 0.0
@property
def despierto(self) -> bool:
return not self.activo or time.monotonic() < self._hasta
def despierta(self):
self._hasta = time.monotonic() + self.ventana
def duerme(self):
self._hasta = 0.0
def filtra(self, texto: str):
"""(hay que atenderla, texto a atender, acaba de despertar).
- Sin palabra de activacion configurada: se atiende todo.
- Dormido y sin nombre: se ignora.
- Dormido y con nombre: despierta. Si la frase traia ademas la orden,
se devuelve; si solo traia el nombre, se contesta con un acuse.
- Despierto: se atiende y se renueva la ventana, para poder encadenar.
"""
if not self.activo:
return True, texto, False
llamado, resto = parte_el_nombre(texto)
if llamado:
recien = not self.despierto
self.despierta()
return (True, resto, recien) if resto else (False, "", recien)
if self.despierto:
self.despierta() # encadenar sin repetir el nombre
return True, texto, False
return False, "", False

343
nucleo/oido/whisper.py Normal file
View file

@ -0,0 +1,343 @@
"""Transcribir: arrancar whisper-server y mandarle los WAV.
Newelle dedicaba 804 lineas a esto porque tenia que descargar modelos, elegir
entre CLI y servidor, salir del sandbox y ofrecerlo todo en una pantalla de
ajustes. Nosotros ya tenemos el binario compilado con CUDA y el modelo en el
disco: queda arrancar un proceso y hacer un POST.
## Los cuatro flags que Newelle no pasaba
Esto es lo importante de este fichero, y sale de leer el codigo de upstream:
`whispercpp_handler.py` arranca el servidor con `-m -t --host --port -l` y nada
mas. Los defectos de `whisper-server` no son los mismos que los de
`whisper-cli`, asi que se estaba transcribiendo peor de lo que la propia
whisper.cpp hace por defecto:
--beam-size 5 --best-of 5 whisper-server trae beam-size -1, que es
decodificacion GREEDY. El CLI usa 5. Es la causa
mas probable de transcripciones como "esta con el
tanutusto" en un audio limpio.
-sns suprime los tokens que no son habla. Es
literalmente el flag que evita los "[Musica]" y
"(aplausos)", que son el 16,2 % de las frases que
JARVIS no entendio.
--prompt + --carry-initial-prompt
vocabulario del dominio. MEDIDO Y DESCARTADO por
defecto: ver abajo.
El prompt se genera DESDE el catalogo (`prompt_dominio`), no escrito a mano, o
se queda viejo en cuanto se añada una accion.
## Lo que dijo la medida, que no fue lo que yo esperaba
40 frases del catalogo dichas por Piper con tres voces, transcritas con cada
configuracion y contando cuantas llegan a su accion:
como lo hace Newelle (greedy, sin sns) 50,0 % 0,98 s
+ beam 5 55,0 % 1,01 s
+ beam 5 + sns 55,0 % 1,01 s
+ beam 5 + sns + prompt 52,5 % 1,12 s
Tres lecturas, por orden de importancia:
1. **El prompt EMPEORA.** Era el riesgo anotado Whisper empieza a "oir" las
palabras que le has puesto donde no las hay y se cumplio. Va apagado por
defecto; se deja el codigo porque con un prompt mas corto podria ganar, pero
hay que demostrarlo antes de encenderlo.
2. **`-sns` no se puede medir con este banco.** El audio de Piper no tiene
silencios ni musica, asi que no hay nada que suprimir. Se deja puesto porque
no cuesta nada y ataca un problema real que solo se ve con microfono: el
16,2 % de "[Musica]" del registro de uso.
3. **El beam sube 5 puntos y cuesta 30 ms.** Es lo unico claramente a favor, y
aun asi con 40 muestras esos 5 puntos son dos frases: esta en el limite del
ruido.
"""
import atexit
import json
import os
import re
import signal
import socket
import subprocess
import time
import urllib.error
import urllib.request
RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
BINARIO = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin",
"whisper-server")
MODELOS = os.path.expanduser(
"~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models")
# Las libs de ggml estan al lado del binario y CUDA en /usr/local. Fuera del
# flatpak esto es una linea; dentro era media pagina de flatpak-spawn.
LIBS = "/usr/local/cuda/lib64"
# Un prompt largo no ayuda: Whisper empieza a "oir" esas palabras donde no las
# hay. Se corta.
TOPE_PROMPT = 220
# El prompt MINIMO, y el unico que va puesto por defecto: solo el nombre.
#
# Sin el, Whisper no escribe "JARVIS" nunca: se lo come y lo sustituye por la
# palabra española que mejor le cuadre acusticamente. Medido diciendoselo con
# Piper:
#
# "Jarvis, cuanto espacio queda" -> "¿Sabeis cuanto espacio queda?"
# "Yarvis, que hora es" -> "¿Ya viste que hora es?"
# "Jarvis pon musica" -> "Carbis Pond Musica."
#
# Y ninguna de esas se parece al nombre ni por letra ni por sonido, asi que el
# centinela no despertaba JAMAS. Era la causa de que el asistente no contestara.
#
# Con este prompt de cinco palabras, cuatro de cuatro. Y ojo al matiz, porque
# contradice lo de arriba solo en apariencia: lo que empeora es el prompt LARGO
# con todo el vocabulario del catalogo. Este cuesta una frase de ochenta —90,0 a
# 88,8 % de acierto general, dentro del ruido— y arregla el nombre entero.
PROMPT_NOMBRE = "JARVIS. Hablando con JARVIS."
def prompt_dominio(catalogo=None) -> str:
"""Vocabulario que JARVIS espera oir, sacado del catalogo.
Se cogen las palabras poco comunes las que un modelo de castellano general
no espera y no las frases enteras: el prompt es un sesgo, no una lista de
ordenes, y llenarlo de "cuanto espacio queda" haria que las oyera en
cualquier ruido.
"""
comunes = {
"abre", "cierra", "busca", "pon", "quita", "sube", "baja", "dime",
"cuanto", "cuanta", "cuantos", "que", "el", "la", "los", "las", "de",
"del", "en", "con", "por", "para", "un", "una", "y", "o", "a", "al",
"me", "se", "lo", "es", "esta", "hay", "mi", "tu", "su",
}
# Por grupos y en rueda, no por orden del catalogo. Recorriendolo en orden,
# el tope de caracteres se lo comian entero las acciones de disco —que van
# primeras— y no llegaba ni una palabra de firefox, ventanas o pentest.
por_grupo, vistas = {}, set()
for a in (catalogo.acciones if catalogo else []):
for frase in a.frases:
for p in frase.split():
if len(p) < 4 or p in comunes or p in vistas:
continue
vistas.add(p)
por_grupo.setdefault(a.grupo or "otras", []).append(p)
palabras = []
grupos = list(por_grupo.values())
for i in range(max((len(g) for g in grupos), default=0)):
for g in grupos:
if i < len(g):
palabras.append(g[i])
# Los nombres propios importan mas que nada y no salen del catalogo tal cual
fijas = ["JARVIS", "firefox", "nmap", "ollama", "whisper", "terminal",
"escritorio", "pantalla", "wifi", "bluetooth", "github", "youtube"]
texto = ", ".join(fijas + palabras)
return texto[:TOPE_PROMPT].rsplit(",", 1)[0]
def _mata_huerfanos():
"""whisper-server de arranques anteriores que se quedaron con la grafica.
Se buscan por la RUTA COMPLETA del binario, no por el nombre: `pkill -f
whisper` casaria con el propio pkill y con cualquier cosa que lleve la
palabra, y eso ya ha costado depuraciones. Y se leen los cmdline de /proc en
vez de llamar a pgrep, que es lo mismo pero sin proceso de por medio.
"""
yo = os.getpid()
for pid in os.listdir("/proc"):
if not pid.isdigit() or int(pid) == yo:
continue
try:
with open(f"/proc/{pid}/cmdline", "rb") as f:
orden = f.read().split(b"\0")
except OSError:
continue
if orden and orden[0].decode(errors="ignore") == BINARIO:
try:
os.killpg(os.getpgid(int(pid)), signal.SIGTERM)
except (OSError, ProcessLookupError):
pass
def _puerto_libre() -> int:
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
class Oido:
"""whisper-server vivo, y un metodo para transcribir un WAV."""
def __init__(self, modelo="small", idioma="es", hilos=8, prompt=PROMPT_NOMBRE,
beam=5, gpu=True):
self.modelo = modelo
self.idioma = idioma
self.hilos = hilos
self.prompt = prompt
self.beam = beam
self.gpu = gpu
self._proc = None
self._puerto = None
@property
def ruta_modelo(self) -> str:
return os.path.join(MODELOS, f"ggml-{self.modelo}.bin")
def disponible(self) -> bool:
return os.path.exists(BINARIO) and os.path.exists(self.ruta_modelo)
# ------------------------------------------------------------- arrancar
def arranca(self, espera=40) -> bool:
if self._proc and self._proc.poll() is None:
return True
if not self.disponible():
return False
# Los huerfanos de arranques anteriores, antes de nada.
#
# whisper-server se lanza en su propio grupo de procesos para poder
# matarlo entero, pero eso mismo hace que sobreviva si el nucleo muere
# de golpe (un kill, un cuelgue, cerrar la terminal). Y cada uno se
# queda con ~880 MiB de una tarjeta de 4 GB: al cuarto, el siguiente ya
# no arranca y el sintoma es "whisper no arranco", que no apunta a
# ningun sitio. Medido en esta maquina: cuatro huerfanos, 3,5 GB.
_mata_huerfanos()
self._puerto = _puerto_libre()
orden = [
BINARIO,
"-m", self.ruta_modelo,
"-t", str(self.hilos),
"--host", "127.0.0.1",
"--port", str(self._puerto),
"-l", self.idioma,
# los cuatro que Newelle no pasaba
"-sns",
"--beam-size", str(self.beam),
"--best-of", "5",
]
if self.prompt:
orden += ["--prompt", self.prompt, "--carry-initial-prompt"]
if not self.gpu:
orden.append("--no-gpu")
entorno = {**os.environ,
"LD_LIBRARY_PATH": LIBS + ":" + os.environ.get("LD_LIBRARY_PATH", "")}
self._proc = subprocess.Popen(
orden, env=entorno, cwd=os.path.dirname(BINARIO),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
# grupo propio, para poder matarlo entero como hace tareas.py
start_new_session=True)
# Y que se vaya con nosotros: sin esto se queda con su parte de la
# grafica hasta el siguiente reinicio.
atexit.register(self.para)
# Esperar a que escuche de verdad, no a que el proceso exista: cargar
# el modelo en la grafica tarda, y un POST antes de tiempo da un error
# de conexion que parece un fallo de configuracion.
limite = time.monotonic() + espera
while time.monotonic() < limite:
if self._proc.poll() is not None:
return False
try:
with socket.create_connection(("127.0.0.1", self._puerto), 0.5):
return True
except OSError:
time.sleep(0.3)
self.para()
return False
def para(self):
if not self._proc:
return
try:
os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM)
self._proc.wait(timeout=5)
except Exception:
try:
os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL)
except Exception:
pass
self._proc = None
@property
def vivo(self) -> bool:
return bool(self._proc and self._proc.poll() is None)
# ---------------------------------------------------------- transcribir
def transcribe(self, wav: str) -> str:
if not self.vivo and not self.arranca():
return ""
try:
with open(wav, "rb") as f:
datos = f.read()
except OSError:
return ""
lim = "----jarvis"
cuerpo = (
f"--{lim}\r\n"
f'Content-Disposition: form-data; name="file"; filename="a.wav"\r\n'
f"Content-Type: audio/wav\r\n\r\n"
).encode() + datos + f"\r\n--{lim}--\r\n".encode()
req = urllib.request.Request(
f"http://127.0.0.1:{self._puerto}/inference", data=cuerpo,
headers={"Content-Type": f"multipart/form-data; boundary={lim}"})
try:
with urllib.request.urlopen(req, timeout=120) as r:
return json.loads(r.read().decode()).get("text", "").strip()
except (urllib.error.URLError, json.JSONDecodeError, OSError):
return ""
# --------------------------------------------------------------- el filtro
# Lo que Whisper devuelve cuando no habia nadie hablando. No es un fallo suyo:
# se entreno con subtitulos de YouTube, donde el silencio se anota asi.
SOLO_ETIQUETA = re.compile(r"^\s*[\[\(\*].{0,40}[\]\)\*]\s*[.!¡]*\s*$")
MULETILLAS = (
"suscribete", "suscríbete", "gracias por ver", "hasta la proxima",
"hasta la próxima", "no olvides suscribirte", "dale like",
)
# Anotaciones que Whisper escribe cuando no hay nadie hablando. Salen de contar
# el registro real de JARVIS, no de imaginarlas: son las que mas se repiten
# entre las frases cortas que no llegan a ninguna accion.
#
# A veces vienen entre corchetes —y entonces las coge SOLO_ETIQUETA— y a veces
# sueltas, que es para lo que sirve esta lista.
ANOTACIONES = {
"musica", "música", "motor", "risas", "risa", "aplausos", "cantando",
"ruido", "silencio", "tos", "suspiro", "aplauso", "llanto", "gritos",
"ah", "ahh", "ahhh", "eh", "ehh", "mmm", "hmm", "uf", "ay",
}
# Lo que SI se deja pasar aunque no lleve a ninguna accion: "hola", "adios",
# "gracias", "buenas". Una persona las dice de verdad y merecen respuesta; lo
# unico que cuesta dejarlas pasar es una consulta al modelo. Filtrar de mas
# aqui es peor que filtrar de menos: JARVIS parecería sordo a un saludo.
def es_ruido(texto: str) -> bool:
"""Si esto es Whisper alucinando en vez de alguien hablando.
Se aplica ANTES del emparejador y antes de anotar el fallo. Sin esto, el
16,2 % del registro de "no entendido" son "[Musica]" y "(aplausos)", y la
mejora nocturna acaba generando alias a partir de ellos.
Conservador a proposito. Lo que se cuela solo cuesta una consulta al modelo;
lo que se filtra de mas es una orden que JARVIS ignora, y eso se vive como
que no funciona.
"""
if not texto or not texto.strip():
return True
if SOLO_ETIQUETA.match(texto):
return True
limpio = texto.lower().strip(" .,!¡?¿*-—")
if limpio in ANOTACIONES:
return True
return any(m in limpio for m in MULETILLAS)

169
nucleo/pruebas/banco.py Executable file
View file

@ -0,0 +1,169 @@
#!/usr/bin/env python3
"""Banco fijo para medir el emparejador sin volver a grabar.
banco.py --crear [n] graba y transcribe una vez (lento)
banco.py --mide puntua el catalogo actual (instantaneo)
banco.py --fallos enseña que se sigue fallando
## Por que hace falta
La mejora nocturna medía generando audio nuevo cada ronda: nueve horas, 39
rondas, +1,45 puntos de mejora contra ±1,9 puntos de ruido de medida. El ruido
era mayor que la mejora, asi que "¿esto mejora o empeora?" se decidia a cara o
cruz. Hoy ha vuelto a pasar: los flags de Whisper parecian dar +5 puntos con 40
muestras y con 150 no dan nada.
Aqui se graba UNA vez con semilla fija y se guardan las TRANSCRIPCIONES. A
partir de ahi, probar un cambio en el emparejador es instantaneo y determinista:
la misma entrada siempre, asi que dos medidas son comparables de verdad.
## La trampa que ya costo una conclusion equivocada
El 24 % del catalogo son acciones que piden argumento. Diciendolas sueltas
"busca el texto" el emparejador NO las dispara, y hace bien: no son una orden.
Un banco que las dice sueltas mide 56 % donde la realidad es 79 %, y hace
parecer que el emparejador esta roto cuando lo que esta roto es el banco.
Por eso `--crear` les añade argumento.
"""
import argparse
import collections
import difflib
import json
import os
import random
import subprocess
import sys
import tempfile
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from manos.acciones import Catalogo, normaliza # noqa: E402
from oido.whisper import Oido, es_ruido # noqa: E402
RAIZ = os.path.dirname(AQUI)
PIPER = os.path.join(RAIZ, "config", "jarvis-piper.sh")
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
BANCO = os.path.join(AQUI, "datos", "banco.json")
# Fijas a proposito: son lo que hace el banco reproducible. Cambiarlas obliga a
# rehacerlo, y esa es justamente la señal de que ya no es comparable.
VOCES = ["es_ES-davefx-medium", "es_MX-claude-high", "es_ES-sharvard-medium"]
SEMILLA = 20260815
ARGUMENTOS = ["informes", "descargas", "musica", "el proyecto", "documentos"]
def crear(n: int) -> int:
cat = Catalogo([CATALOGO])
rnd = random.Random(SEMILLA)
conf = [a for a in cat.acciones if a.frases]
casos = []
for a in rnd.sample(conf, min(n, len(conf))):
frase = rnd.choice(a.frases)
if a.captura:
frase += " " + rnd.choice(ARGUMENTOS)
casos.append({"id": a.id, "frase": frase, "voz": rnd.choice(VOCES),
"captura": a.captura})
print(f" grabando {len(casos)} frases...", flush=True)
tmp = tempfile.mkdtemp(prefix="banco")
for i, c in enumerate(casos):
wav = os.path.join(tmp, f"{i:03d}.wav")
subprocess.run([PIPER, wav, c["frase"]],
env={**os.environ, "JARVIS_PIPER_VOZ": c["voz"],
"JARVIS_PIPER_TONO": "1.0"},
capture_output=True, timeout=90)
c["wav"] = wav if os.path.exists(wav) and os.path.getsize(wav) else None
oreja = Oido(modelo="small", beam=5)
if not oreja.arranca():
print(" whisper no arranco")
return 2
print(" transcribiendo...", flush=True)
for i, c in enumerate(casos):
c["oido"] = oreja.transcribe(c["wav"]) if c["wav"] else ""
c.pop("wav", None)
if (i + 1) % 40 == 0:
print(f" {i+1}/{len(casos)}", flush=True)
oreja.para()
subprocess.run(["rm", "-rf", tmp])
casos = [c for c in casos if c.get("oido")]
os.makedirs(os.path.dirname(BANCO), exist_ok=True)
with open(BANCO, "w", encoding="utf-8") as f:
json.dump({"semilla": SEMILLA, "modelo": "small", "casos": casos},
f, ensure_ascii=False, indent=1)
print(f" guardado: {len(casos)} transcripciones en {BANCO}")
return 0
def carga():
try:
with open(BANCO, encoding="utf-8") as f:
return json.load(f)["casos"]
except (OSError, json.JSONDecodeError, KeyError):
return []
def mide(detalle=False) -> int:
casos = carga()
if not casos:
print(" no hay banco: crealo con --crear")
return 1
cat = Catalogo([CATALOGO])
bien = 0
oye_mal, no_relaciona, otra, ruido = [], [], [], []
for c in casos:
t = c["oido"]
if es_ruido(t):
ruido.append(c)
continue
a, _ = cat.busca(t)
if a is not None and a.id == c["id"]:
bien += 1
continue
p = difflib.SequenceMatcher(None, normaliza(t), normaliza(c["frase"])).ratio()
if p < 0.75:
oye_mal.append((c, t, p))
elif a is None:
no_relaciona.append((c, t, p))
else:
otra.append((c, t, a.id))
n = len(casos)
print(f" {n} frases · acierto {100*bien/n:.1f} %\n")
print(f" {'que pasa':28s} {'n':>4s} {'%':>7s}")
print(" " + "-" * 44)
for nombre, k in [("bien", bien), ("oye bien, NO RELACIONA", len(no_relaciona)),
("OYE MAL la frase", len(oye_mal)),
("oye bien, OTRA accion", len(otra)),
("lo tira por ruido", len(ruido))]:
print(f" {nombre:28s} {k:4d} {100*k/n:6.1f} %")
if detalle:
print("\n --- oye bien pero no relaciona ---")
for c, t, p in no_relaciona:
print(f" {c['id']:24s} dijo: {c['frase']}")
print(f" {'':24s} oyo: {t} ({p:.2f})")
print("\n --- se la lleva otra accion (lo peor: ejecuta lo que no es) ---")
for c, t, o in otra:
print(f" {c['id']:24s} -> {o} oyo: {t}")
return 0
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--crear", action="store_true")
p.add_argument("--mide", action="store_true")
p.add_argument("--fallos", action="store_true")
p.add_argument("n", nargs="?", type=int, default=150)
a = p.parse_args()
if a.crear:
return crear(a.n)
return mide(detalle=a.fallos)
if __name__ == "__main__":
sys.exit(main())

133
nucleo/pruebas/prueba_candado.py Executable file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""El candado: que tape, que rechace lo que no es, y que NO obedezca.
./venv/bin/python pruebas/prueba_candado.py
Lo que de verdad importa aqui no es que el panel se ponga negro eso es
cosmetica sino que con el candado puesto **no se ejecute nada**. El panel tapado
pero obedeciendo seria peor que no tener candado, porque da sensacion de
seguridad sin darla.
No se prueba con la contraseña de verdad: no la tenemos ni hace falta. Se
comprueba que una mala se rechaza y que mientras tanto no pasa ni una orden.
"""
import os
import sys
import threading
import time
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from boca.voz import Boca # noqa: E402
from cara.panel import Panel # noqa: E402
from manos import Manos # noqa: E402
fallos = []
def afirma(condicion, queja):
print(f" {'OK ' if condicion else 'MAL'} {queja}")
if not condicion:
fallos.append(queja)
class CaraFalsa:
"""Se queda con lo que el panel manda, sin levantar ningun servidor."""
def __init__(self):
self.mensajes = []
self.conectados = 1
def manda(self, tipo, datos):
self.mensajes.append((tipo, datos))
def de(self, tipo):
return [d for t, d in self.mensajes if t == tipo]
class BocaMuda(Boca):
"""Como la de verdad pero sin sonar: la prueba no tiene que hablar."""
def __init__(self):
super().__init__("davefx")
self.dicho = []
def di(self, texto):
self.dicho.append(texto); return True
def di_si_libre(self, texto):
self.dicho.append(texto); return True
def main() -> int:
manos = Manos()
cara = CaraFalsa()
boca = BocaMuda()
p = Panel(cara, boca, manos, candado=True)
print("\n al abrir")
p.arranca()
time.sleep(0.2)
afirma(not p.desbloqueado, "nace bloqueado")
afirma(bool(cara.de("bloqueo")), "pide la contraseña nada mas abrir")
print("\n con el candado puesto NO obedece")
antes = len(cara.mensajes)
for orden in ("prueba", "voz:siguiente", "parar"):
p.pulsado("accion", orden)
time.sleep(1.0)
afirma(not boca.dicho, f"no ha hablado ({boca.dicho})")
# Desde que la tanda va por tareas, lo que se ve al ejecutar son mensajes
# "tarea" (una tarjeta por orden), no "respuesta".
hechas = [d for t, d in cara.mensajes[antes:] if t in ("tarea", "respuesta")]
afirma(not hechas, f"no ha ejecutado nada ({len(hechas)} tareas o respuestas)")
# Solo se miran las respuestas a lo pulsado: la telemetria sigue latiendo
# en su hilo cada segundo y se cuela entre medias.
contestaciones = [t for t, _ in cara.mensajes[antes:] if t != "telemetria"]
afirma(contestaciones and all(t == "bloqueo" for t in contestaciones),
f"a cada intento contesta pidiendo la contraseña ({contestaciones})")
print("\n una contraseña mala se rechaza")
antes = len(cara.mensajes)
p.pulsado("accion", "desbloquear:no-es-esta-contrasena-12345")
time.sleep(0.5)
afirma(not p.desbloqueado, "sigue bloqueado")
ultimos = cara.de("bloqueo")[-1:] or [{}]
afirma(ultimos[0].get("error") is True, "y lo dice como error")
afirma(not cara.de("desbloqueado"), "no manda 'desbloqueado'")
print("\n una vacia ni se intenta")
antes = len(cara.mensajes)
p.pulsado("accion", "desbloquear:")
afirma(len(cara.mensajes) == antes, "no molesta a sudo con una clave vacia")
print("\n sin candado, obedece")
cara2, boca2 = CaraFalsa(), BocaMuda()
p2 = Panel(cara2, boca2, manos, candado=False)
p2.arranca()
afirma(p2.desbloqueado, "nace desbloqueado")
afirma(not cara2.de("bloqueo"), "no pide contraseña")
p2.pulsado("accion", "prueba")
time.sleep(3.0)
tarjetas = {d["id"] for d in cara2.de("tarea")}
afirma(bool(tarjetas), f"ejecuta de verdad ({len(tarjetas)} tarjetas)")
afirma(any(d["estado"] == "hecha" for d in cara2.de("tarea")),
"y las tareas llegan a terminar")
print("\n y se puede volver a echar")
p2.bloquea()
afirma(not p2.desbloqueado, "bloquea() cierra aunque arrancara sin candado")
antes = len(cara2.mensajes)
p2.pulsado("accion", "prueba")
time.sleep(0.6)
afirma(not [d for t, d in cara2.mensajes[antes:] if t in ("tarea", "respuesta")],
"y despues de cerrar ya no obedece")
p.para(); p2.para()
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())

122
nucleo/pruebas/prueba_cara.py Executable file
View file

@ -0,0 +1,122 @@
#!/usr/bin/env python3
"""El panel: que lo que manda el nucleo sea lo que el HUD sabe leer.
./venv/bin/python pruebas/prueba_cara.py
## Por que existe esta prueba
Montando la telemetria, `ps` devolvia el uso de CPU como cadena y el nucleo la
mandaba tal cual. El HUD hace `p.cpu.toFixed(0)`, y una cadena no tiene
`toFixed`: la excepcion abortaba el manejador ENTERO y se llevaba por delante
todo lo que se pinta despues. En pantalla se veia que CPU, RAM y grafica
salian en "--", y el fallo no apuntaba a los procesos por ningun lado.
Ese es el problema de un puente sin contrato: un campo con el tipo equivocado
apaga media pantalla y no dice donde. Aqui se fija el contrato que campo, de
que tipo y en que unidad comprobando ademas que `hud.js` lo sigue usando asi,
para que enterarse no dependa de mirar la pantalla.
"""
import json
import os
import re
import sys
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from cara.panel import Telemetria # noqa: E402
HUD = os.path.join(AQUI, "cara", "hud")
fallos = []
def afirma(condicion, queja):
print(f" {'OK ' if condicion else 'MAL'} {queja}")
if not condicion:
fallos.append(queja)
def numero(v):
return isinstance(v, (int, float)) and not isinstance(v, bool)
def main() -> int:
print("\n el puente esta puesto")
with open(os.path.join(HUD, "index.html"), encoding="utf-8") as f:
html = f.read()
afirma("puente.js" in html, "index.html carga puente.js")
# Se comparan las etiquetas <script>, no el texto suelto: el comentario que
# hay encima menciona hud.js y una busqueda ingenua lo encuentra antes.
scripts = re.findall(r'<script\s+src="([^"]+)"', html)
afirma("puente.js" in scripts and "hud.js" in scripts
and scripts.index("puente.js") < scripts.index("hud.js"),
"y lo carga ANTES que hud.js, que pregunta por window.webkit al arrancar")
with open(os.path.join(HUD, "puente.js"), encoding="utf-8") as f:
puente = f.read()
afirma("WebSocket" in puente, "el puente habla por WebSocket")
afirma("messageHandlers" in puente,
"suplanta window.webkit, asi hud.js no se toca")
afirma("cola" in puente, "guarda lo pulsado mientras reconecta")
print("\n la telemetria: campos, tipos y unidades")
d = Telemetria().lee()
Telemetria().lee() # el segundo tiene ya las diferencias de cpu
afirma(numero(d.get("cpu")), "cpu es un numero")
afirma(isinstance(d.get("nucleos"), list) and all(numero(x) for x in d["nucleos"]),
f"nucleos es una lista de numeros ({len(d.get('nucleos', []))})")
for clave in ("ram", "swap"):
v = d.get(clave, {})
afirma(numero(v.get("usada")) and numero(v.get("total")),
f"{clave} lleva usada y total")
afirma(0 < v.get("total", 0) < 1024,
f"{clave} viene en GB, no en MB ({v.get('total', 0):.1f})")
disco = d.get("disco", {})
afirma(0 < disco.get("total", 0) < 200,
f"disco viene en TB, no en GB ({disco.get('total', 0):.2f})")
afirma(isinstance(d.get("carga"), list),
"carga es una lista: el HUD hace carga.map()")
afirma(d.get("uptime", 0) > 60,
"uptime va en segundos: el HUD decide solo si pone minutos o dias")
if "gpu" in d:
g = d["gpu"]
afirma(all(numero(g.get(k)) for k in ("uso", "vram_usada", "vram_total")),
"la grafica manda uso, vram_usada y vram_total")
afirma(0 < g.get("vram_total", 0) < 128,
f"la vram viene en GB ({g.get('vram_total', 0):.1f})")
print("\n los procesos: el campo que apago media pantalla")
procs = d.get("procesos", [])
afirma(bool(procs), f"llegan procesos ({len(procs)})")
for p in procs[:3]:
afirma(numero(p.get("cpu")) and numero(p.get("mem")),
f"{p.get('nombre','?')}: cpu y mem son numeros, no cadenas")
afirma(all("pid" in p for p in procs), "todos traen pid, que el HUD pone en el title")
print("\n y el HUD sigue esperando eso mismo")
with open(os.path.join(HUD, "hud.js"), encoding="utf-8") as f:
js = f.read()
# Si alguien cambia hud.js para leer otro campo, esta prueba no lo sabria
# mirando solo al nucleo: se comprueba en el propio JavaScript.
for campo in ("datos.cpu", "datos.ram", "datos.swap", "datos.disco",
"datos.nucleos", "datos.procesos"):
afirma(campo in js, f"hud.js lee {campo}")
afirma("p.cpu.toFixed" in js,
"hud.js llama a toFixed sobre el cpu de cada proceso (por eso tiene que ser numero)")
# Todo lo que se manda tiene que poder viajar por el socket.
print("\n serializable")
try:
json.dumps(d)
afirma(True, "la telemetria entera cabe en JSON")
except (TypeError, ValueError) as e:
afirma(False, f"la telemetria no es JSON: {e}")
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())

133
nucleo/pruebas/prueba_dialogo.py Executable file
View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""Pulsar una accion PREGUNTA lo que falta y la hace.
./venv/bin/python pruebas/prueba_dialogo.py
Antes, pulsar en "sabe hacer" recitaba la frase que dispara la accion. Eso es un
manual de instrucciones, y uno pulsa justo porque no quiere leerse el manual.
Lo que se comprueba, por orden de importancia:
1. que lo destructivo NO se dispare de un clic
2. que lo destructivo CON argumento pregunte las DOS cosas
3. que un "no" cancele de verdad
4. que la pregunta caduque, para que una frase suelta media conversacion
despues no se tome por la respuesta
"""
import os
import sys
import time
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from cara.panel import Panel # noqa: E402
from manos import Manos # noqa: E402
from manos.preguntar import pregunta_por, es_si # noqa: E402
fallos = []
def afirma(condicion, queja):
print(f" {'OK ' if condicion else 'MAL'} {queja}")
if not condicion:
fallos.append(queja)
class CaraFalsa:
conectados = 1
def __init__(self): self.m = []
def manda(self, t, d): self.m.append((t, d))
class BocaMuda:
voz = "davefx"
def __init__(self): self.dicho = []
def di(self, t): self.dicho.append(t); return True
def di_si_libre(self, t): return self.di(t)
def disponibles(self): return ["davefx"]
def nuevo():
cara, boca = CaraFalsa(), BocaMuda()
return Panel(cara, boca, Manos(), candado=False), boca
def main() -> int:
print("\n las preguntas salen del catalogo")
manos = Manos()
conArg = [a for a in manos.catalogo.acciones if a.captura]
genericas = [a for a in conArg if pregunta_por(a) == "¿El que, señor?"]
afirma(len(conArg) >= 30, f"{len(conArg)} acciones piden argumento")
afirma(len(genericas) <= 10,
f"solo {len(genericas)} caen en la pregunta generica, de {len(conArg)}")
afirma(all(pregunta_por(a).endswith("señor?") for a in conArg),
"todas preguntan en el registro de la casa")
afirma("cierra" not in [pregunta_por(a).split()[0].strip("¿").lower()
for a in conArg],
"y ninguna empieza por el verbo de la orden")
print("\n con argumento: pregunta y ejecuta con lo que le digas")
p, boca = nuevo()
p.pulsado("accion", "explica:buscar_fichero"); time.sleep(0.2)
afirma(bool(p.esperando()), "se queda esperando respuesta")
afirma(any("fichero" in d for d in boca.dicho), f"pregunta cual: {boca.dicho}")
p.responde("informes"); time.sleep(1.5)
tareas = [t.titulo for t in p.tareas.todas()]
afirma(any("informes" in t for t in tareas), f"lo ejecuta con el argumento: {tareas}")
afirma(not p.esperando(), "y deja de esperar")
p.para()
print("\n lo destructivo NO se dispara de un clic")
p, boca = nuevo()
p.pulsado("accion", "explica:ff_firefox_cerrar"); time.sleep(0.3)
afirma(not p.tareas.todas(), "no ha lanzado nada al pulsar")
afirma(bool(p.esperando()), "esta esperando confirmacion")
p.responde("no"); time.sleep(0.4)
afirma(not p.tareas.todas(), "y con un 'no' sigue sin lanzar nada")
p.para()
print("\n destructivo CON argumento: las dos preguntas")
p, boca = nuevo()
p.pulsado("accion", "explica:proceso_matar"); time.sleep(0.2)
afirma(bool(p.esperando()), "pregunta el argumento")
p.responde("firefox"); time.sleep(0.3)
afirma(bool(p.esperando()),
"y DESPUES pide confirmacion, no ejecuta directo")
afirma(any("firefox" in d for d in boca.dicho),
"la confirmacion dice sobre que va, no un cheque en blanco")
p.responde("no"); time.sleep(0.4)
afirma(not p.tareas.todas(), "un 'no' lo cancela")
p.para()
print("\n lo que no pregunta nada, se hace")
p, boca = nuevo()
p.pulsado("accion", "explica:disco_libre"); time.sleep(1.5)
afirma(bool(p.tareas.todas()), "una accion sin argumento ni riesgo se ejecuta")
afirma(not p.esperando(), "sin preguntar nada")
p.para()
print("\n la pregunta caduca")
p, boca = nuevo()
p.ESPERA_S = 0.5
p.pulsado("accion", "explica:buscar_fichero"); time.sleep(0.2)
afirma(bool(p.esperando()), "recien preguntada, espera")
time.sleep(0.8)
afirma(not p.esperando(),
"pasado el plazo la olvida: una frase suelta media conversacion "
"despues no puede tomarse por la respuesta")
p.para()
print("\n el si y el no, como los dice la gente")
for t, esperado in [("si", True), ("Sí.", True), ("vale", True),
("claro que si", True), ("no", False), ("dejalo", False),
("no, mejor no", False), ("informes", None),
("el fichero de agosto", None)]:
afirma(es_si(t) is esperado, f"{t!r} -> {es_si(t)}")
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())

159
nucleo/pruebas/prueba_eco.py Executable file
View file

@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""La prueba que Newelle no pasa: que JARVIS no se oiga a si mismo.
En Newelle, mas del 21 % de lo que oye y no entiende es su propia voz. Esta
prueba lo comprueba de verdad, con el microfono y los altavoces, en dos partes:
A. Suena una frase POR FUERA de JARVIS (un reproductor a pelo, como si
hablara una persona). Tiene que captarla. Si no, el microfono no llega a
los altavoces y la parte B no demuestra nada.
B. La misma frase, pero dicha POR JARVIS. NO tiene que captarla.
Las dos partes juntas son la prueba: A sola no dice nada, B sola podria pasar
simplemente porque el microfono no oye nada.
./venv/bin/python pruebas/prueba_eco.py
Necesita altavoces encendidos y a un volumen normal. Si estan en silencio, la
parte A falla y avisa.
"""
import os
import subprocess
import sys
import threading
import time
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from boca.voz import Boca # noqa: E402
from oido.captura import Captura # noqa: E402
from oido.whisper import Oido, es_ruido # noqa: E402
FRASE = "El sistema esta operativo y a su disposicion, señor."
ESPERA = 14 # cuanto se escucha en cada parte
def _se_parece(oido: str, dicho: str) -> bool:
"""Si lo transcrito es la frase que se dijo, aunque venga maltratada.
Se compara por palabras y no por parecido de cadena: un eco recortado
("operativo y a su disposicion") no se parece a la frase entera como
cadena, pero comparte casi todas sus palabras, y eso es inequivoco.
"""
import unicodedata
def palabras(t):
t = unicodedata.normalize("NFD", t.lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return {p.strip(".,¡!¿?") for p in t.split() if len(p) > 3}
a, b = palabras(oido), palabras(dicho)
return bool(b) and len(a & b) >= 2
def escucha_un_rato(captura, oreja, segundos):
"""Devuelve lo que se haya oido en ese rato."""
oido = []
fin = time.monotonic() + segundos
hilo_paro = threading.Event()
def bucle():
for wav in captura.frases(para_cuando=lambda: hilo_paro.is_set()):
try:
t = oreja.transcribe(wav)
finally:
try:
os.unlink(wav)
except OSError:
pass
if t and not es_ruido(t):
oido.append(t)
h = threading.Thread(target=bucle, daemon=True)
h.start()
while time.monotonic() < fin:
time.sleep(0.2)
hilo_paro.set()
captura.para()
h.join(timeout=5)
return oido
def main() -> int:
boca = Boca("davefx")
oreja = Oido(modelo="small")
if not oreja.disponible():
print(" no esta whisper o el modelo; no se puede probar")
return 2
print(" arrancando whisper...")
if not oreja.arranca():
print(" whisper no arranco")
return 2
# Se genera el WAV una vez para poder reproducirlo por los dos caminos con
# exactamente el mismo audio.
wav = boca._genera(FRASE)
if not wav:
print(" no se pudo generar la voz")
return 2
fallos = 0
# --- A: suena por fuera, tiene que oirse -----------------------------
print("\n A · suena por fuera (como si hablara alguien)")
captura = Captura(boca=boca)
def suelta_fuera():
time.sleep(2.5)
subprocess.run(["paplay", wav], capture_output=True)
threading.Thread(target=suelta_fuera, daemon=True).start()
oido_a = escucha_un_rato(captura, oreja, ESPERA)
for t in oido_a:
print(f" oido: {t!r}")
if oido_a:
print(" OK el microfono llega a los altavoces")
else:
print(" MAL no se oyo nada: ¿altavoces en silencio? sin esto la")
print(" parte B no demuestra nada")
fallos += 1
time.sleep(1.5)
# --- B: lo dice JARVIS, NO tiene que oirse ---------------------------
print("\n B · lo dice JARVIS (la guarda tiene que taparlo)")
captura = Captura(boca=boca)
def suelta_jarvis():
time.sleep(2.5)
boca.di(FRASE)
threading.Thread(target=suelta_jarvis, daemon=True).start()
oido_b = escucha_un_rato(captura, oreja, ESPERA)
# Lo que se juzga es si se colo LA FRASE, no si se transcribio algo.
#
# Esto costo una conclusion equivocada: probando con colas de 350, 700 y
# 1100 ms el resultado no era monotono —1100 fallaba y 700 no—, lo cual es
# imposible si el unico factor fuera el eco. Mirando lo que se colaba, eran
# "¡Adios!" y un parrafo inventado: Whisper ALUCINANDO sobre el silencio,
# que es otro problema distinto y da igual lo larga que sea la guarda.
eco = [t for t in oido_b if _se_parece(t, FRASE)]
alucinado = [t for t in oido_b if t not in eco]
for t in eco:
print(f" ECO: {t!r}")
for t in alucinado:
print(f" (whisper alucinando sobre el silencio: {t[:52]!r})")
if not eco:
print(" OK JARVIS no se oye a si mismo")
else:
print(" MAL se ha oido hablar: la guarda no tapa")
fallos += 1
oreja.para()
print(f"\n {'todo en orden' if not fallos else str(fallos) + ' fallan'}")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())

173
nucleo/pruebas/prueba_manos.py Executable file
View file

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""El emparejador: que siga acertando y, sobre todo, que no se invente ordenes.
./venv/bin/python pruebas/prueba_manos.py
Porta las comprobaciones de `pruebas/08_acciones.sh` de la epoca de Newelle y
añade las que hacen falta ahora que el emparejador compara tambien por sonido.
La regla que ordena este fichero: **equivocarse cuesta mas que no reaccionar.**
Esto lanza comandos en la maquina; que no entienda una orden es molesto, que
ejecute la que no es puede costar trabajo perdido. Por eso las comprobaciones de
falsos positivos son las que mandan, y por eso el suelo de acierto va aparte y
mas bajo de lo medido: para que una mejora legitima no obligue a tocar el test,
pero una regresion de verdad lo tire.
"""
import json
import os
import shlex
import sys
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from manos.acciones import Catalogo # noqa: E402
from manos.fonetica import fonetica # noqa: E402
from oido.whisper import es_ruido # noqa: E402
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
BANCO = os.path.join(AQUI, "datos", "banco.json")
# Medido el 15 de agosto de 2026: 84,7 %. El suelo se pone tres puntos por
# debajo para dejar sitio al ruido de un banco de 150, donde una frase es 0,67.
SUELO = 81.0
# Lo que NO puede disparar una accion jamas. Las cinco primeras vienen del test
# 08; el resto son conversacion que empieza igual que una orden, que es
# exactamente el riesgo de comparar por sonido.
CHARLA = [
"necesito un abrazo", "quiero contarte una cosa", "necesito pensar",
"puedes explicarme como funciona el kernel", "me gustaria que fueramos amigos",
"que tal estas", "como te encuentras hoy", "cuentame un chiste",
"que opinas de la musica electronica", "me apetece un cafe",
"hoy hace un dia estupendo", "estoy pensando en cambiar de trabajo",
"quien fue alan turing", "explicame la teoria de la relatividad",
"me gusta mucho como suena tu voz", "cuanto tiempo llevas funcionando",
"no se que hacer este fin de semana", "que peliculas me recomiendas",
"he tenido un dia muy largo", "sabes cantar",
"no quiero que abras nada", "no busques nada por ahora",
"antes buscabas mejor las cosas", "el disco duro de mi hermano se rompio",
"la memoria me falla ultimamente", "que temperatura hace en la calle",
"me han abierto una cuenta en el banco", "cierro los ojos y pienso",
"mi ventana da al patio", "la pantalla del movil se ha roto",
"ese proceso judicial fue largo", "tengo un fichero de esos antiguos",
"el kernel de la cuestion es otro", "hablame de la red de metro",
"pon atencion a lo que te digo", "sube el animo que no pasa nada",
"baja la voz que estan durmiendo", "abre tu corazon",
"escanea el documento con la impresora", "mata el tiempo como puedas",
"el volumen de trabajo es enorme", "que hora era cuando llegaste",
"las descargas de agua fueron fuertes", "mi carpeta de fotos favorita",
"el brillo de sus ojos", "conecta conmigo emocionalmente",
"quiero que sepas que me importa", "necesito que me escuches",
"puedes decirme si estoy equivocado", "me gustaria saber tu opinion",
]
# Como habla la gente de verdad. Sin quitar la cortesia no encajaba ninguna:
# era el fallo de casi todas las ordenes reales que se perdian.
CON_CORTESIA = [
("Necesito que pongas la película de Iron Man.", "pelicula_abrir"),
("¿Puedes poner la película de Iron Man?", "pelicula_abrir"),
("Necesito que me abras una terminal.", "terminal_abrir"),
("¿Podrías abrir una terminal?", "terminal_abrir"),
("Me puedes decir cuánto espacio queda.", "disco_libre"),
]
fallos = []
def afirma(condicion, queja):
if condicion:
print(f" OK {queja}")
else:
print(f" MAL {queja}")
fallos.append(queja)
def main() -> int:
cat = Catalogo([CATALOGO])
print("\n el catalogo")
afirma(len(cat.acciones) >= 150, f"{len(cat.acciones)} acciones cargadas")
afirma(not cat.errores, f"sin errores de carga ({len(cat.errores)})")
ids = [a.id for a in cat.acciones]
afirma(len(ids) == len(set(ids)), "no hay ids repetidos")
afirma(all(a.acuse for a in cat.acciones), "todas tienen acuse")
afirma(all("{argumento" in a.comando for a in cat.acciones if a.captura),
"las que capturan argumento lo usan en el comando")
afirma(all(a.confirmar for a in cat.acciones if "rm -rf" in a.comando),
"nada que borre se ejecuta sin confirmar")
print("\n entiende como habla la gente")
for frase, esperado in CON_CORTESIA:
a, _ = cat.busca(frase)
afirma(a is not None and a.id == esperado,
f"'{frase[:38]}' -> {esperado}"
+ ("" if a is None or a.id == esperado else f" (fue a {a.id})"))
print("\n y NO se inventa ordenes")
coladas = [(c, cat.busca(c)[0]) for c in CHARLA]
coladas = [(c, a.id) for c, a in coladas if a is not None]
afirma(not coladas,
f"ninguna de las {len(CHARLA)} frases de charla dispara nada"
+ ("" if not coladas else f" — se colo {coladas[0][0]!r} -> {coladas[0][1]}"))
print("\n el argumento viene de un microfono: no puede ser una orden")
for veneno in ["gatos; rm -rf /", 'algo " ; touch /tmp/jarvis_pwned ; "']:
a, arg = cat.busca(f"busca en internet {veneno}")
if a is None:
fallos.append(f"no encaja la busqueda con {veneno!r}")
print(f" MAL no encaja la busqueda con {veneno!r}")
continue
comando = a.comando.replace("{argumento_url}", shlex.quote(arg))
comando = comando.replace("{argumento}", shlex.quote(arg))
afirma("; rm" not in comando and "; touch" not in comando,
f"entrecomillado: {veneno[:24]!r}")
print("\n el filtro de alucinaciones de Whisper")
for t in ["[Música]", "(aplausos)", "[motor]", "¡Suscríbete!", ""]:
afirma(es_ruido(t), f"descarta {t!r}")
for t in ["cuanto espacio queda", "abre la terminal"]:
afirma(not es_ruido(t), f"deja pasar {t!r}")
print("\n la comparacion por sonido")
# Lo que se afirma es que ACERCA, no que iguale: `parecido()` se queda con
# el mayor de los dos parecidos, asi que basta con que el del sonido suba.
# Exigir igualdad seria pedirle a esto que fuera un Metaphone, y no lo es
# a proposito: comprimir mas junta ordenes que no se parecen.
import difflib
def sube(a, b):
letra = difflib.SequenceMatcher(None, a, b).ratio()
sonido = difflib.SequenceMatcher(None, fonetica(a), fonetica(b)).ratio()
return sonido > letra
afirma(sube("abre van kamp", "abre bandcamp"), "'van kamp' se acerca a 'bandcamp'")
afirma(fonetica("haz") == fonetica("has"), "'haz' suena igual que 'has'")
afirma(fonetica("llave") == fonetica("yave"), "'yave' suena igual que 'llave'")
afirma(fonetica("bino") == fonetica("vino"), "la b y la v son el mismo sonido")
# Y lo que importa de verdad: que no acerque lo que no se parece.
afirma(not sube("abre la terminal", "apaga la pantalla"),
"y NO acerca ordenes distintas")
print("\n el banco fijo")
if not os.path.exists(BANCO):
print(" ·· no hay banco; crealo con pruebas/banco.py --crear")
else:
casos = json.load(open(BANCO, encoding="utf-8"))["casos"]
bien = otra = 0
for c in casos:
a, _ = cat.busca(c["oido"])
if a is None:
continue
if a.id == c["id"]:
bien += 1
else:
otra += 1
pct = 100 * bien / len(casos)
afirma(pct >= SUELO, f"acierto {pct:.1f} % sobre {len(casos)} (suelo {SUELO})")
afirma(otra == 0, f"ninguna orden acaba en la accion equivocada ({otra})")
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())

243
nucleo/pruebas/prueba_voz.py Executable file
View file

@ -0,0 +1,243 @@
#!/usr/bin/env python3
"""La cadena de voz entera, con altavoces y microfono de verdad.
./venv/bin/python pruebas/prueba_voz.py
Las otras pruebas miran piezas: que el emparejador acierte, que el candado
cierre, que el puente mande los campos que el HUD lee. Ninguna contesta a la
pregunta que de verdad importa **si le hablo, ¿me contesta?** y por eso el
fallo de que oia bien pero descartaba en silencio llego hasta el usuario.
Esta habla por los altavoces y escucha por el microfono, como una persona. Es
lenta y necesita el volumen puesto, pero es la unica que prueba lo que se usa.
## Como funciona
Se genera la frase con Piper, se reproduce POR FUERA de la boca de JARVIS con
un reproductor a pelo, para que la guarda del eco no la tape y se mira que la
cadena entera reaccione. Que el microfono llega a los altavoces ya lo demuestra
prueba_eco.py; aqui se da por hecho y se avisa si falla.
## Lo que se comprueba, en orden
1. sin llamarle se oye pero NO se atiende, y se dice por que
2. llamandole se atiende y contesta
3. encadenando la siguiente sin repetir el nombre
4. como lo oye Whisper "Yarvis" y "Charles" tambien valen
"""
import os
import subprocess
import sys
import threading
import time
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, AQUI)
from boca.voz import Boca # noqa: E402
from manos import Manos # noqa: E402
from oido.captura import Captura # noqa: E402
from oido.despierta import Centinela, NOMBRE # noqa: E402
from oido.whisper import Oido, es_ruido # noqa: E402
from jarvis import atiende # noqa: E402
ESPERA = 16 # cuanto se escucha por cada frase dicha
fallos = []
ajenas = [] # lo que se oyo y no era de la prueba: ruido de la sala
def _se_parece(oido: str, dicho: str) -> bool:
"""Si lo transcrito es la frase que se reprodujo, aunque venga maltratada.
Hace falta porque la prueba usa el microfono de la habitacion: si hay una
tele, una conversacion o un video sonando, el bucle oye ESO y una prueba
ingenua lo da por bueno. Ha pasado una pasada dio por valida "¡No te
olvides de todo ese chico!"— y una prueba que se cree cualquier ruido no
prueba nada.
"""
import unicodedata
def palabras(t):
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return {p.strip(".,¡!¿?«»") for p in t.split() if len(p) > 3}
a, b = palabras(oido), palabras(dicho)
if not b:
return False
return len(a & b) >= max(1, min(2, len(b) - 1))
def afirma(condicion, queja):
print(f" {'OK ' if condicion else 'MAL'} {queja}")
if not condicion:
fallos.append(queja)
class Escucha:
"""El bucle de voz de jarvis.py, reducido a lo que hace falta probar."""
def __init__(self, boca, oreja, centinela):
self.boca, self.oreja, self.centinela = boca, oreja, centinela
self.manos = Manos()
self.oido = [] # (texto, atendido, motivo)
self.dicho = [] # lo que ha contestado
self._para = threading.Event()
self._hilo = None
def arranca(self):
self._hilo = threading.Thread(target=self._bucle, daemon=True)
self._hilo.start()
def _bucle(self):
cap = Captura(boca=self.boca)
for wav in cap.frases(para_cuando=lambda: self._para.is_set()):
try:
texto = self.oreja.transcribe(wav)
finally:
try:
os.unlink(wav)
except OSError:
pass
if es_ruido(texto):
continue
atender, orden, _ = self.centinela.filtra(texto)
if not atender:
self.oido.append((texto, False, "no le han llamado"))
continue
self.oido.append((texto, True, ""))
respuesta = atiende(orden, self.manos, None, traza=lambda t: None)
if respuesta:
self.dicho.append(respuesta)
cap.para()
def para(self):
self._para.set()
if self._hilo:
self._hilo.join(timeout=6)
def suelta(self, frase, espera=ESPERA):
"""Dice la frase POR FUERA de JARVIS y espera a que reaccione.
Solo cuenta lo que se PARECE a lo reproducido: si hay ruido en la
habitacion, el bucle lo oye tambien y darlo por bueno haria pasar la
prueba sin haber probado nada.
"""
antes_oido, antes_dicho = len(self.oido), len(self.dicho)
wav = self.boca._genera(frase)
if not wav:
return None, None
time.sleep(0.6)
subprocess.run(["paplay", wav], capture_output=True)
fin = time.monotonic() + espera
mio = None
while time.monotonic() < fin:
for entrada in self.oido[antes_oido:]:
if _se_parece(entrada[0], frase):
mio = entrada
break
if mio and (not mio[1] or len(self.dicho) > antes_dicho):
break
time.sleep(0.2)
nuevo_dicho = self.dicho[antes_dicho:]
ajeno = [o[0] for o in self.oido[antes_oido:] if o is not mio]
if ajeno:
ajenas.extend(ajeno)
print(f" (ruido de la habitacion, ignorado: {ajeno[0][:44]!r})")
return mio, (nuevo_dicho[-1] if nuevo_dicho else None)
def main() -> int:
boca = Boca("davefx")
oreja = Oido(modelo="small")
if not oreja.disponible():
print(" no esta whisper o el modelo; no se puede probar")
return 2
print(" arrancando whisper...")
if not oreja.arranca():
print(" whisper no arranco")
return 2
centinela = Centinela(activo=True)
esc = Escucha(boca, oreja, centinela)
esc.arranca()
time.sleep(1.5)
print("\n 0 · con la palabra APAGADA (el defecto): atiende directo")
centinela.activo = False
oido, dicho = esc.suelta("Cuanta memoria queda.")
if oido:
print(f" oyo: {oido[0]!r}")
if dicho:
print(f" dijo: {dicho}")
afirma(oido is not None and oido[1],
"sin exigir el nombre, atiende lo que oiga")
afirma(dicho is not None, "y contesta")
centinela.activo = True
centinela.duerme()
print("\n 1 · con la palabra puesta y sin llamarle: oye pero no atiende")
oido, dicho = esc.suelta("Cuanto espacio queda en el disco.")
if oido is None:
print(" ·· no se oyo nada: ¿altavoces en silencio? sin esto el resto")
print(" de la prueba no demuestra nada")
fallos.append("el microfono no capta los altavoces")
else:
print(f" oyo: {oido[0]!r}")
afirma(not oido[1], "lo oye pero no lo atiende, porque no le han llamado")
afirma(dicho is None, "y no contesta")
centinela.duerme()
print(f"\n 2 · llamandole por su nombre")
oido, dicho = esc.suelta(f"{NOMBRE.capitalize()}, cuanto espacio queda.")
if oido:
print(f" oyo: {oido[0]!r}")
if dicho:
print(f" dijo: {dicho}")
afirma(oido is not None and oido[1], "lo atiende")
afirma(dicho is not None, "y contesta algo")
afirma(dicho is not None and any(c.isdigit() for c in dicho),
"con una cifra de verdad, no una excusa")
print("\n 3 · encadenando, sin repetir el nombre")
oido, dicho = esc.suelta("Cuanta memoria queda.")
if oido:
print(f" oyo: {oido[0]!r}")
afirma(oido is not None and oido[1],
"sigue despierto y atiende sin que le vuelvan a llamar")
print("\n 4 · como lo oye Whisper de verdad")
centinela.duerme()
oido, dicho = esc.suelta("Yarvis, que hora es.")
if oido:
print(f" oyo: {oido[0]!r}")
afirma(oido is not None and oido[1],
"'Yarvis' tambien le despierta (Whisper escribe el nombre de mil formas)")
esc.para()
oreja.para()
# Una prueba que usa el microfono de la sala no puede fallar en silencio por
# culpa de la sala. Si ha entrado mucho ruido, el resultado no dice nada del
# codigo y hay que decirlo, no soltar una pared de MAL.
if fallos and len(ajenas) >= 2:
print(f"\n ┌─ SALA RUIDOSA ─────────────────────────────────────────")
print(f" │ Han entrado {len(ajenas)} frases que no eran de la prueba.")
print(f" │ Con audio sonando cerca del microfono, este resultado NO")
print(f" │ dice nada del codigo. Ejemplos de lo que se colo:")
for a in ajenas[:3]:
print(f"{a[:52]!r}")
print(f" │ Repitelo en silencio.")
print(f" └────────────────────────────────────────────────────────")
print(f"\n {len(fallos)} fallan, pero no son concluyentes\n")
return 2
print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n")
return 1 if fallos else 0
if __name__ == "__main__":
sys.exit(main())

175
nucleo/pruebas/repasa_acciones.py Executable file
View file

@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Ejecuta TODAS las acciones del catalogo y dice cuales funcionan de verdad.
Por que hace falta: hasta hoy los comandos libres del modelo corrian dentro del
sandbox, donde no hay ni el sistema ni los discos. Se arreglo (virtualization a
false), pero las 134 acciones del catalogo nunca se habian ejecutado todas
seguidas contra la maquina real: la suite prueba que las FRASES caen en la
accion correcta, no que el comando de cada accion haga algo util.
Que se ejecuta y que no:
- Las destructivas (`confirmar: true`) y las que piden root NO se ejecutan.
En uso normal pasan por un dialogo que aprueba una persona; aqui no hay
persona, y aprobarlas solas seria justo lo que el diseño evita.
- Las que abren ventanas (firefox, xdg-open) se ejecutan solo con --abrir,
porque llenan la pantalla. Sin esa opcion se comprueba que el programa
existe, que es lo que puede fallar.
- El resto se ejecuta entero y se mira que conteste algo con sentido.
python repasar_acciones.py [--abrir] [--grupo sistema] [--json salida.json]
"""
import argparse
import json
import os
import re
import subprocess
import sys
import time
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RAIZ = os.path.dirname(AQUI) # la capa JARVIS, para los argumentos de prueba
CATALOGO = os.path.join(AQUI, "datos", "acciones.json")
# Argumentos de mentira para las acciones que capturan uno. Se eligen inocuos:
# nada que borre, mueva o mande nada fuera.
ARGUMENTOS = {
"buscar_fichero": "README.md",
"buscar_carpeta": "config",
"grep_recursivo": "JARVIS",
"leer_fichero": "README.md", # un NOMBRE, que es lo que se dice por voz
"abrir_carpeta": RAIZ,
"tamano_carpeta": os.path.join(RAIZ, "config"),
"buscar_youtube": "musica",
"buscar_wikipedia": "madrid",
"buscar_github": "newelle",
"traducir": "hola",
"buscar_web": "que hora es",
"matar_proceso": "proceso_que_no_existe_jarvis",
"ping": "127.0.0.1",
}
POR_DEFECTO = "prueba"
# Lo que abre o MUEVE ventanas. ventanas.sh reordena el escritorio del usuario:
# ejecutarlo en un repaso automatico le cambia las ventanas de sitio sin avisar.
VENTANA = re.compile(r"\b(firefox|xdg-open|gnome-terminal|nautilus|vlc|mpv|eog|"
r"gedit|gnome-text-editor|totem|ventanas\.sh|xdotool|wmctrl)\b")
def carga():
with open(CATALOGO, encoding="utf-8") as f:
d = json.load(f)
return d.get("acciones", d if isinstance(d, list) else [])
def prefijo_host():
"""Las acciones llevan host:true y en uso salen del sandbox. Aqui ya
estamos fuera, asi que no hace falta prefijo."""
return []
def ejecuta(accion, argumento, tope=25):
import shlex
import urllib.parse
comando = accion.get("comando") or ""
comando = comando.replace("{argumento}", shlex.quote(argumento))
comando = comando.replace("{argumento_url}",
shlex.quote(urllib.parse.quote_plus(argumento)))
t0 = time.monotonic()
try:
r = subprocess.run(["bash", "-lc", comando], capture_output=True,
text=True, errors="replace", timeout=tope)
salida = (r.stdout or r.stderr or "").strip()
return r.returncode, salida, time.monotonic() - t0
except subprocess.TimeoutExpired:
return -1, f"(no termino en {tope}s)", time.monotonic() - t0
except OSError as e:
return -2, f"(no se pudo ejecutar: {e})", time.monotonic() - t0
def programa_existe(comando):
"""El primer ejecutable de la orden, para las que abren o mueven ventanas.
Algunos son ordenes del PATH (firefox) y otros son scripts nuestros con
ruta completa (config/ventanas.sh): `command -v` solo sirve para los
primeros, y con los segundos hay que mirar el fichero.
"""
m = VENTANA.search(comando)
if not m:
return None
nombre = m.group(1)
if nombre.endswith(".sh"):
# sacar la ruta entera tal como aparece en el comando
for trozo in comando.split():
if trozo.endswith(nombre):
ruta = os.path.expandvars(os.path.expanduser(trozo))
return os.access(ruta, os.X_OK)
return False
return subprocess.run(["bash", "-lc", f"command -v {nombre}"],
capture_output=True).returncode == 0
def main():
p = argparse.ArgumentParser()
p.add_argument("--abrir", action="store_true",
help="ejecutar tambien las que abren ventanas")
p.add_argument("--grupo")
p.add_argument("--json")
p.add_argument("--tope", type=int, default=25)
args = p.parse_args()
acciones = carga()
if args.grupo:
acciones = [a for a in acciones if a.get("grupo") == args.grupo]
resultados = []
print(f" repasando {len(acciones)} acciones"
f"{' del grupo ' + args.grupo if args.grupo else ''}\n", flush=True)
for a in acciones:
ident = a.get("id", "?")
comando = a.get("comando") or ""
arg = ARGUMENTOS.get(ident, POR_DEFECTO if a.get("captura") else "")
if not comando:
estado, detalle = "solo habla", a.get("acuse", "")[:50]
elif a.get("confirmar") or "sudo" in comando:
# no se aprueban solas: en uso las aprueba una persona
estado, detalle = "saltada", "destructiva o con root"
elif VENTANA.search(comando) and not args.abrir:
hay = programa_existe(comando)
estado = "programa ok" if hay else "FALTA PROGRAMA"
detalle = VENTANA.search(comando).group(1)
else:
codigo, salida, tardo = ejecuta(a, arg, args.tope)
una_linea = " ".join(salida.split())[:70]
if codigo == 0:
estado = "ok" if salida else "ok (sin salida)"
else:
estado = f"FALLO ({codigo})"
detalle = f"{tardo:4.1f}s {una_linea}"
resultados.append({"id": ident, "grupo": a.get("grupo"),
"estado": estado, "detalle": detalle})
marca = "!" if "FALLO" in estado or "FALTA" in estado else " "
print(f" {marca} {ident:26s} {estado:16s} {detalle}", flush=True)
print()
from collections import Counter
c = Counter(r["estado"].split(" (")[0].split(" ")[0] for r in resultados)
for k, v in c.most_common():
print(f" {v:3d} {k}")
malas = [r for r in resultados if "FALLO" in r["estado"] or "FALTA" in r["estado"]]
print(f"\n {len(resultados) - len(malas)} de {len(resultados)} responden bien")
if malas:
print(" fallan: " + ", ".join(r["id"] for r in malas))
if args.json:
with open(args.json, "w", encoding="utf-8") as f:
json.dump(resultados, f, ensure_ascii=False, indent=2)
return 1 if malas else 0
if __name__ == "__main__":
sys.exit(main())

0
nucleo/saber/__init__.py Normal file
View file

159
nucleo/saber/busca.py Normal file
View file

@ -0,0 +1,159 @@
"""Buscar en el saber de COFRE lo que hace falta para contestar una pregunta.
Esto es la mitad "recuperar" de RAG: dada una pregunta, devuelve los fragmentos
de los apuntes que mas se le parecen, para pasarselos al cerebro. El cerebro
construye la respuesta desde ESE texto, no desde lo que recuerde, que es lo que
evita que se invente flags.
## Por que se carga una vez y se guarda
El indice son 2.866 vectores. Cargarlo y encodear la pregunta cuesta, pero solo
la primera vez: el modelo y los vectores se quedan en memoria. Una consulta
despues es un producto escalar contra 2.866 filas, milisegundos.
## Por que no una base de datos vectorial
FAISS, Chroma y compañia son para millones de vectores. Con 2.866, numpy y un
producto escalar es mas rapido de arrancar, no añade una dependencia pesada, y
el indice es un jsonl que se puede abrir y leer con los ojos.
"""
import json
import os
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
INDICE = os.path.join(AQUI, "datos", "saber.jsonl")
_modelo = None
_entradas = None
_matriz = None
def _carga():
global _modelo, _entradas, _matriz
if _entradas is not None:
return _entradas is not False
if not os.path.exists(INDICE):
_entradas = False
return False
import numpy as np
from model2vec import StaticModel
entradas, vectores = [], []
with open(INDICE, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
v = d.pop("v", None)
if v is None:
continue
entradas.append(d)
vectores.append(v)
_entradas = entradas
_matriz = np.array(vectores, dtype="float32")
# normalizar una vez: asi la similitud es un simple producto escalar
normas = np.linalg.norm(_matriz, axis=1, keepdims=True)
_matriz = _matriz / np.clip(normas, 1e-9, None)
_modelo = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
return True
def _norm(t: str) -> str:
import re
import unicodedata
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return re.sub(r"[^a-z0-9 ]", " ", t)
# El texto normalizado de cada entrada y su nombre, cacheados para el bono.
_texto_norm = None
_nombre_norm = None
def _bono_palabras(palabras):
"""Un vector de bonos, uno por entrada, segun cuantas palabras compartan."""
import numpy as np
global _texto_norm, _nombre_norm
if _texto_norm is None:
_texto_norm = [set(_norm(e["texto"]).split()) for e in _entradas]
_nombre_norm = [_norm(e.get("herramienta", "")) for e in _entradas]
bono = np.zeros(len(_entradas), dtype="float32")
for i, (palabras_e, nombre) in enumerate(zip(_texto_norm, _nombre_norm)):
comunes = len(palabras & palabras_e)
b = 0.08 * comunes # cada palabra compartida suma
if nombre and nombre in palabras: # y nombrar la herramienta, mucho
b += 0.5
bono[i] = b
return bono
def busca(pregunta: str, cuantos: int = 5, perfil: str = None) -> list:
"""Los fragmentos mas parecidos a la pregunta, mejor primero.
Cada uno lleva su similitud (0-1), de que herramienta y fichero viene, y el
texto. perfil filtra por carpeta de COFRE si se quiere acotar.
"""
if not _carga() or not pregunta.strip():
return []
import numpy as np
q = _modelo.encode([pregunta])[0]
q = q / max(float(np.linalg.norm(q)), 1e-9)
sim = _matriz @ q
# Impulso por palabras exactas (busqueda hibrida).
#
# Los embeddings estaticos fallan cuando la pregunta va en castellano y el
# texto en ingles —"cambiar permisos" no se parece a "change file mode
# bits"—. Pero si la pregunta NOMBRA la herramienta ("como uso chmod") o
# comparte palabras con el texto, eso es una señal fuerte que la similitud
# semantica sola no aprovecha. Se suma un bono por cada palabra de la
# pregunta que aparezca, y uno grande si coincide el nombre de la
# herramienta: asi "usa nmap para..." lleva nmap al primer puesto.
palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
if palabras:
bono = _bono_palabras(palabras)
sim = sim + bono
orden = np.argsort(-sim)[: cuantos * 6]
salida, vistos = [], set()
for i in orden:
e = _entradas[i]
if perfil and e.get("perfil") != perfil:
continue
# sin duplicados: OSCP_APUNTES es copia de los apuntes de primer nivel.
# Se comparan los primeros 120 caracteres, que basta para reconocerlos.
firma = e["texto"][:120]
if firma in vistos:
continue
vistos.add(firma)
salida.append({**e, "sim": round(float(sim[i]), 3)})
if len(salida) >= cuantos:
break
return salida
def contexto(pregunta: str, cuantos: int = 5, minimo: float = 0.35) -> str:
"""Lo mismo, pero ya montado como texto para meterle al cerebro.
Se corta por debajo de `minimo` de similitud: pasarle al modelo fragmentos
que no vienen a cuento es lo que le hace mezclar herramientas que no tienen
nada que ver. Mejor darle poco y bueno que mucho y ruidoso.
"""
trozos = [t for t in busca(pregunta, cuantos) if t["sim"] >= minimo]
if not trozos:
return ""
lineas = ["De los apuntes de COFRE del usuario:\n"]
for t in trozos:
fuente = t.get("fichero") or f"{t['perfil']}/{t['herramienta']}"
lineas.append(f"--- {fuente} ---\n{t['texto']}\n")
return "\n".join(lineas)
def disponible() -> bool:
return os.path.exists(INDICE)

View file

@ -0,0 +1,110 @@
# Enriquecer el RAG a nivel de pentesting profesional
El RAG del naranja ya funcionaba, pero se dejaba fuera lo mejor que hay en el
disco. Medido antes de empezar: **de las 72 notas del `OSCP_Vault` —la
metodologia OSCP escrita a mano, con comandos reales— había 0 en el índice.**
`indexa.py` las descartaba por dos motivos a la vez: el nombre (`SQL
injection.md` no lleva "notas" ni "guia") y la profundidad (viven a 5 niveles).
Este pipeline las rescata, multiplica su recuperabilidad con preguntas
generadas, y **mide** que el resultado es mejor antes de tocar nada.
## Correrlo
```bash
./correr_noche.sh # las cuatro fases, y decide si promociona
./correr_noche.sh --sin-promo # igual, pero deja el vivo intacto
```
Es reanudable: cada fase se salta si ya está hecha, así que si se corta,
relanzarlo continúa. La fase larga (síntesis) es reanudable fragmento a
fragmento.
## Las cuatro fases
| | Script | Qué hace | Coste |
|---|---|---|---|
| 1 | `cosecha.py` | rescata la metodología de COFRE que faltaba | segundos, CPU |
| 2 | `sintetiza.py` | preguntas en castellano por cada fragmento | **horas**, modelo local |
| 3 | `reindexa.py` | une todo y calcula vectores → `saber.jsonl.nuevo` | minutos, CPU |
| 4 | `evalua.py` | mide recuperación vivo vs candidato | segundos |
### 1 · Cosecha
Donde `indexa.py` es prudente (solo ficheros que *parecen* documentación, 3
niveles), esto es agresivo con el oro: las carpetas de metodología (OSCP vaults,
apuntes, cheatsheets) enteras, sin límite de profundidad. Fuera de ahí pone un
**tope por herramienta**: si un repo tiene más de 25 documentos, es una base de
datos (exploitdb son 29.925 ficheros), no unos apuntes, y solo se coge su
README. Así el oro no se ahoga en ruido.
### 2 · Síntesis — indexación multi-representación
El punto flaco de los embeddings estáticos: "cómo saco una shell reversa" no se
parece vectorialmente a `bash -i >& /dev/tcp/...`. La solución es **embeber la
pregunta y devolver el fragmento**. Por cada fragmento, el modelo local escribe
las preguntas que responde; se indexa la pregunta, pero lo que se le muestra al
cerebro es el fragmento **literal**.
Clave: el modelo **solo escribe preguntas, nunca reescribe comandos**. Si
inventa una pregunta rara, esa entrada recupera peor y ya está; no corrompe una
respuesta. Y el fragmento crudo sigue en el índice por su lado.
### 3 · Reindexado
Une el índice vivo (comandos Linux, glosario, fichas), la cosecha y la síntesis;
deduplica por prefijo; calcula los vectores con el mismo `model2vec` de siempre.
Escribe a `saber.jsonl.nuevo`. **No toca el vivo.** `busca.py` no cambia.
### 4 · Evaluación
`eval_set.jsonl` son ~45 preguntas de pentesting con las palabras que un
fragmento correcto debe contener. Mide `hit@1`, `hit@5` y similitud media, del
índice vivo contra el candidato. Determinista y reproducible, sin juez-LLM.
## La decisión, y por qué es segura
El índice vivo **solo se sustituye si la evaluación dice que el candidato es
mejor**, y antes se guarda `saber.jsonl.antes-<fecha>`. Si empeora, se queda el
vivo y el candidato espera revisión. Siempre reversible con un `cp`.
## Ficheros que genera (en `../../datos/`)
cosecha.jsonl la metodología rescatada, sin vectores
sintesis.jsonl las preguntas generadas (+ .hecho, el marcador)
saber.jsonl.nuevo el índice candidato, con vectores
informe_rag.txt los números de la evaluación
noche_rag.log el registro de la noche
saber.jsonl.antes-* copia del índice anterior, si se promocionó
## Iteraciones posteriores a la primera noche
- **Troceado que no tira comandos cortos** (`cosecha.py`). El troceado por
encabezado descartaba toda sección de menos de 60 caracteres, y eso perdía
el oro: `## Detección time-based` + `' AND SLEEP(5)-- -` son 50. Ahora una
sección corta se pega a la siguiente y cada fragmento lleva delante el título
de la nota. `SLEEP` pasó de 0 a 13 apariciones en el índice; hit@1 80→82 %.
El salto en la métrica es pequeño porque las preguntas que quedan son
cross-lingual, y de eso no salva el troceado.
- **`experimento_embedder.py`** — mide, sin tocar nada, si un transformer (e5,
bge) supera al model2vec estático. **Resultado (16 ago): NO compensa.**
`intfloat/multilingual-e5-base` empató con el estático (hit@1 86 %, hit@5
95 % los dos): arregla las 2 preguntas que el estático fallaba, pero rompe
otras 2 distintas. A cambio pediría ~50-150 ms por consulta en CPU y una
dependencia de torch en cada búsqueda. Medido antes de cambiar → **el
estático se queda.** El trabajo de recuperación lo hace la búsqueda híbrida
(coseno + bono por palabras), no el embedder, y por eso subir el embedder no
mueve la aguja. Reproducible: `./experimento_embedder.py`.
- **Re-síntesis limpia** sobre el troceado nuevo (`SABER_BASE` en `reindexa.py`
reconstruye desde el índice original en vez de apilar, para no inflar). Da a
los comandos rescatados (SLEEP y demás) su gancho-pregunta en castellano, que
es lo que de verdad ataca las consultas cross-lingual —no el embedder—.
## Nota sobre la tarjeta
La síntesis usa el modelo local por `127.0.0.1:11434`. Fija `qwen3.5:4b-jarvis`
(el naranja, que no inventa comandos). Si dejas el TUI verde abierto, ollama
tiene que cambiar de modelo en cada petición y la noche va más lenta: para la
síntesis más rápida, cierra el verde.

View file

@ -0,0 +1,92 @@
#!/usr/bin/env bash
# El pipeline de la noche: mejora el RAG hasta nivel pentesting profesional.
#
# ./correr_noche.sh corre las cuatro fases y decide
# ./correr_noche.sh --sin-promo igual, pero NO sustituye el indice vivo
#
# ── Que hace, en orden ─────────────────────────────────────────────────────
#
# 1. COSECHA rescata la metodologia de COFRE que el indexador se dejaba
# (el OSCP_Vault entero, 0 de 72 notas estaban en el indice).
# 2. SINTETIZA por cada fragmento, preguntas en castellano que lo encuentran.
# Es la fase larga: horas, con el modelo local. Reanudable.
# 3. REINDEXA une vivo + cosecha + sintesis y calcula los vectores. Escribe
# a saber.jsonl.nuevo. NO toca el indice vivo.
# 4. EVALUA mide recuperacion del vivo contra el candidato, con numeros.
#
# ── No rompe nada ──────────────────────────────────────────────────────────
#
# El indice vivo solo se sustituye si evalua DICE que el candidato es mejor, y
# antes se guarda una copia con fecha. Si el candidato empeora, se queda el
# vivo y el candidato espera revision. Reversible siempre.
#
# Cada fase se salta si ya esta hecha, asi que relanzarlo continua donde iba.
set -uo pipefail
AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
RAIZ=$(cd -P "$AQUI/../.." && pwd) # .../nucleo
DATOS="$RAIZ/datos"
PY="$RAIZ/venv/bin/python"
[ -x "$PY" ] || PY=python3
LOG="$DATOS/noche_rag.log"
# El modelo NO se fija aqui a proposito: sintetiza.py mira que tiene ollama
# cargado y usa ESE, para no forzar cambios en la tarjeta de 4 GB. Si el verde
# esta abierto, generara con el verde; si no, cae al naranja. Solo se fuerza si
# exportas JARVIS_MODELO tu. Aun asi, para la noche mas rapida y limpia, lo
# suyo es cerrar el TUI verde: con un solo modelo ollama no recarga nada.
PROMO=si
[ "${1:-}" = "--sin-promo" ] && PROMO=no
fecha() { date '+%Y-%m-%d %H:%M:%S'; }
fase() { echo "" | tee -a "$LOG"; echo "[$(fecha)] === FASE $* ===" | tee -a "$LOG"; }
echo "[$(fecha)] arranca el pipeline de la noche (modelo $JARVIS_MODELO)" | tee -a "$LOG"
# ── 1. COSECHA ─────────────────────────────────────────────────────────────
fase "1/4 COSECHA"
if [ -s "$DATOS/cosecha.jsonl" ]; then
echo " ya hecha ($(wc -l <"$DATOS/cosecha.jsonl") fragmentos), se salta" | tee -a "$LOG"
else
"$PY" "$AQUI/cosecha.py" 2>&1 | tee -a "$LOG"
fi
# ── 2. SINTETIZA (la larga) ────────────────────────────────────────────────
fase "2/4 SINTETIZA (horas; reanudable)"
# hecha del todo = el .hecho llego al ultimo fragmento
TOTAL=$(wc -l <"$DATOS/cosecha.jsonl")
HECHO=$(cat "$DATOS/sintesis.jsonl.hecho" 2>/dev/null || echo -1)
if [ "$HECHO" -ge "$((TOTAL - 1))" ] 2>/dev/null; then
echo " ya completa ($(wc -l <"$DATOS/sintesis.jsonl" 2>/dev/null || echo 0) preguntas)" | tee -a "$LOG"
else
"$PY" "$AQUI/sintetiza.py" 2>&1 | tee -a "$LOG"
fi
# ── 3. REINDEXA ────────────────────────────────────────────────────────────
fase "3/4 REINDEXA"
"$PY" "$AQUI/reindexa.py" 2>&1 | tee -a "$LOG"
# ── 4. EVALUA ──────────────────────────────────────────────────────────────
fase "4/4 EVALUA"
INFORME="$DATOS/informe_rag.txt"
"$PY" "$AQUI/evalua.py" 2>&1 | tee "$INFORME" | tee -a "$LOG"
VEREDICTO=$(grep -oE 'VEREDICTO (PROMOCIONAR|MANTENER)' "$INFORME" | awk '{print $2}' | tail -1)
# ── Decision ───────────────────────────────────────────────────────────────
fase "DECISION"
if [ "$PROMO" = no ]; then
echo " --sin-promo: dejo el candidato en saber.jsonl.nuevo sin tocar el vivo" | tee -a "$LOG"
elif [ "$VEREDICTO" = PROMOCIONAR ]; then
COPIA="$DATOS/saber.jsonl.antes-$(date +%Y%m%d-%H%M)"
cp "$DATOS/saber.jsonl" "$COPIA"
mv "$DATOS/saber.jsonl.nuevo" "$DATOS/saber.jsonl"
echo " PROMOCIONADO. El candidato es mejor y ya es el indice vivo." | tee -a "$LOG"
echo " copia del anterior: $COPIA" | tee -a "$LOG"
echo " para deshacer: cp '$COPIA' '$DATOS/saber.jsonl'" | tee -a "$LOG"
else
echo " MANTENIDO el vivo: el candidato no mejora la evaluacion." | tee -a "$LOG"
echo " candidato en saber.jsonl.nuevo e informe en informe_rag.txt para revisar." | tee -a "$LOG"
fi
echo "[$(fecha)] pipeline terminado" | tee -a "$LOG"

282
nucleo/saber/enriquece/cosecha.py Executable file
View file

@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Cosecha la metodologia de pentesting que el indexador normal se deja fuera.
./cosecha.py recorre COFRE y escribe datos/cosecha.jsonl
./cosecha.py --cuenta dice que cosecharia, sin escribir
## Por que existe, aparte de indexa.py
`indexa.py` es conservador a proposito: solo mira ficheros cuyo NOMBRE parece
documentacion (readme, notas, guia...) y no baja mas de 3 niveles. Eso funciona
para las herramientas, pero **tira la mejor metodologia que hay en el disco**:
- El `OSCP_Vault` son 72 notas OSCP escritas a mano `SQL injection.md`,
`Reverse shell.md`, `LFI a RCE.md`, `msfvenom.md`... con comandos reales y
enlaces cruzados. Ninguna entra: el nombre no lleva "notas/guia", y ademas
viven en `OSCP_APUNTES/OSCP_Vault/02 - Explotacion web/`, a 5 niveles.
- Igual con las cheatsheets y apuntes sueltos de AD, tunneling, privesc.
Medido antes de escribir esto: **0 de 72 notas del Vault estaban en el indice.**
Este cosechador es agresivo donde el otro es prudente: en los perfiles
ofensivos coge TODO .md/.txt que no sea ruido evidente, a la profundidad que
haga falta, y marca de que TEMA es por la carpeta que lo contiene (las del Vault
van numeradas: "01 - Enumeracion", "02 - Explotacion web"...).
No pisa a indexa.py: escribe a un fichero aparte. La union y el deduplicado los
hace reindexa.py.
"""
import argparse
import json
import os
import re
import sys
import unicodedata
def _norm(t):
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return re.sub(r"[^a-z0-9 ]", " ", t)
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI)) # .../nucleo
COFRE = os.path.expanduser("~/COFRE")
SALIDA = os.path.join(RAIZ, "datos", "cosecha.jsonl")
# Los perfiles ofensivos: aqui vive el saber de pentesting. En estos se cosecha
# a lo ancho. (LINUX se queda para indexa.py, que ya lo hace bien con man pages.)
PERFILES = ["PENTESTERS", "PHISHERS", "REVERSERS", "DEFACERS", "SNEAKERS",
"HARD-WARERS", "PROTECTORS", "ZAPPERS", "AUTOMATAS"]
# Carpetas de oro: se recorren ENTERAS, sin limite de profundidad, porque su
# valor esta justo en las notas hondas.
ORO = re.compile(r"(OSCP_APUNTES|OSCP_Vault|TELMO_OSCP|RedTeam-Tools|"
r"apuntes|cheat|vault|metodolog|notas)", re.I)
# Lo que no se mira nunca: dependencias, control de versiones, binarios.
IGNORA = re.compile(
r"(^|/)(node_modules|\.git|\.obsidian|vendor|dist|build|__pycache__|"
r"\.venv|venv|site-packages|target|\.cache|\.github)(/|$)", re.I)
# Ficheros que son plantilla o licencia, no saber.
RUIDO = re.compile(r"(code_of_conduct|contributing|changelog|license|copying|"
r"pull_request|issue_template|\.min\.|package-lock)", re.I)
# Fuera de las carpetas de ORO, solo se coge lo que PARECE documentacion por el
# nombre. Es la misma idea que indexa.py, un poco mas ancha (tutorial, writeup,
# walkthrough). Sin esto entra cada .md de cada exploit.
DOC = re.compile(r"(readme|install|usage|apuntes|notas|trucos|howto|tutorial|"
r"walkthrough|writeup|write-up|guide|guia|cheat|manual|tips|"
r"metodolog|documentation|docs?)", re.I)
# Tope de ficheros por herramienta fuera del ORO. exploitdb tiene 29.925 .md:
# es una base de datos, no unos apuntes, y meterla entera ahoga el oro. Si una
# herramienta pasa de esto, se la trata como repo a granel y solo se coge su
# documentacion de primer nivel (README).
CAP_FICHEROS = 25
MIN_FRAGMENTO = 60
MAX_FRAGMENTO = 1200
MAX_PROFUNDIDAD = 4 # niveles bajo el perfil, salvo en carpetas de ORO
# Tope de tamano por fichero. Una nota de metodologia son unos pocos KB; un .txt
# de 300 MB es una wordlist (SecLists y similares llevan .txt de gigabytes) y
# leerlo entero revienta la maquina —la swap de este equipo es de 976 MiB—. Por
# encima de esto no es saber, es un diccionario, y no se lee.
MAX_BYTES = 512 * 1024
def _profundidad(ruta):
return ruta.rstrip("/").count("/")
def _tema(ruta):
"""El tema, deducido de la carpeta. Las del Vault van numeradas."""
for parte in reversed(ruta.split(os.sep)):
# "02 - Explotacion web" -> "explotacion web"
m = re.match(r"^\d{2}\s*[-.]\s*(.+)$", parte)
if m:
return m.group(1).strip().lower()
return ""
def _herramienta(ruta, perfil):
rel = os.path.relpath(ruta, os.path.join(COFRE, perfil)).split(os.sep)
return rel[0] if len(rel) > 1 else perfil
def _prefija(titulo, seccion):
"""Lleva el titulo de la nota delante de la seccion, salvo que ya lo tenga.
Sin esto, un fragmento como "## Deteccion time-based\n' AND SLEEP(5)" no
sabe de que va ¿time-based de que?. Con "(SQL injection) ..." delante, el
vector y el cerebro tienen el contexto. Se evita duplicar si el titulo ya
aparece en la cabecera de la seccion."""
if titulo and _norm(titulo) not in _norm(seccion[:80]):
return f"({titulo}) {seccion}"
return seccion
def trocea(texto):
"""En fragmentos por encabezado, fusionando los cortos y con el titulo.
Antes se partia por encabezado y se TIRABA toda seccion de menos de 60
caracteres. Eso perdia justo lo mejor: una seccion "## Deteccion time-based"
con `' AND SLEEP(5)-- -` son 50 caracteres, y es oro. Ahora una seccion
corta se PEGA a la siguiente en vez de tirarse, y cada fragmento lleva
delante el titulo de la nota para no quedar sin contexto.
"""
texto = re.sub(r"\A---\n.*?\n---\n", "", texto, flags=re.S)
m = re.search(r"^#\s+(.+)", texto, re.M)
titulo = m.group(1).strip() if m else ""
crudas = [s.strip() for s in re.split(r"\n(?=#{1,3}\s)", texto) if s.strip()]
# fusionar hacia adelante mientras la seccion sea demasiado corta
fundidas, i = [], 0
while i < len(crudas):
sec = crudas[i]
while len(sec) < MIN_FRAGMENTO and i + 1 < len(crudas):
i += 1
sec += "\n\n" + crudas[i]
fundidas.append(sec)
i += 1
# si la ultima quedo corta, se dobla sobre la anterior. Se saca primero y
# se indexa despues: fundidas.pop() en el lado derecho ya habria acortado
# la lista y fundidas[-2] se saldria de rango.
if len(fundidas) >= 2 and len(fundidas[-1]) < MIN_FRAGMENTO:
cola = fundidas.pop()
fundidas[-1] += "\n\n" + cola
for sec in fundidas:
if len(sec) < MIN_FRAGMENTO:
continue # una nota entera diminuta
if len(sec) <= MAX_FRAGMENTO:
yield _prefija(titulo, sec)
else:
buf = ""
for parrafo in re.split(r"\n\s*\n", sec):
if len(buf) + len(parrafo) > MAX_FRAGMENTO and buf:
yield _prefija(titulo, buf.strip())
buf = ""
buf += parrafo + "\n\n"
if len(buf.strip()) >= MIN_FRAGMENTO:
yield _prefija(titulo, buf.strip())
def _candidato(ruta, fich, en_oro):
"""Un fichero vale si es texto, no es ruido, no es enorme, y —fuera del
oro su nombre parece documentacion."""
if not fich.lower().endswith((".md", ".txt")):
return False
if RUIDO.search(fich):
return False
if not en_oro and not DOC.search(fich):
return False
try:
return os.path.getsize(ruta) <= MAX_BYTES
except OSError:
return False
def ficheros(perfil):
"""Los ficheros a cosechar de un perfil, con el tope por herramienta.
Se recorre cada herramienta (subcarpeta de primer nivel) por separado para
poder contar sus ficheros: si pasa del cap y no es oro, es un repo a granel
(exploitdb) y solo se coge su README, no sus 30.000 entradas.
"""
base = os.path.join(COFRE, perfil)
if not os.path.isdir(base):
return
# sueltos en la raiz del perfil (apuntes-ad-pentest.md, trucos_trampas.md)
for fich in sorted(os.listdir(base)):
ruta = os.path.join(base, fich)
if os.path.isfile(ruta) and _candidato(ruta, fich, en_oro=True):
yield ruta, True
for herr in sorted(os.listdir(base)):
raiz_h = os.path.join(base, herr)
if not os.path.isdir(raiz_h) or herr.startswith("."):
continue
candidatos = []
for raiz, dirs, fichs in os.walk(raiz_h):
if IGNORA.search(raiz):
dirs[:] = []
continue
en_oro = bool(ORO.search(raiz))
if not en_oro and _profundidad(raiz) - _profundidad(raiz_h) > MAX_PROFUNDIDAD:
dirs[:] = []
continue
for fich in sorted(fichs):
ruta = os.path.join(raiz, fich)
if _candidato(ruta, fich, en_oro):
candidatos.append((ruta, en_oro, _profundidad(ruta)))
del_oro = [c for c in candidatos if c[1]]
resto = [c for c in candidatos if not c[1]]
# el oro entra siempre y entero
for ruta, en_oro, _ in del_oro:
yield ruta, True
# el resto, con tope: si es un repo a granel, solo lo mas alto
if len(resto) > CAP_FICHEROS:
resto.sort(key=lambda c: c[2]) # los menos hondos primero
resto = resto[:CAP_FICHEROS]
for ruta, en_oro, _ in resto:
yield ruta, False
def construye(solo_cuenta=False):
salida = []
for perfil in PERFILES:
n_doc = n_frag = 0
for ruta, en_oro in ficheros(perfil):
try:
with open(ruta, encoding="utf-8", errors="ignore") as f:
texto = f.read()
except OSError:
continue
if len(texto.strip()) < MIN_FRAGMENTO:
continue
tema = _tema(ruta)
herr = _herramienta(ruta, perfil)
rel = os.path.relpath(ruta, COFRE)
hubo = False
for trozo in trocea(texto):
salida.append({
"tipo": "metodologia" if en_oro else "apunte",
"herramienta": herr, "perfil": perfil,
"fichero": rel, "tema": tema, "texto": trozo,
})
n_frag += 1
hubo = True
n_doc += hubo
print(f" {perfil:14s} {n_doc:5d} docs {n_frag:6d} fragmentos", flush=True)
oro = sum(1 for e in salida if e["tipo"] == "metodologia")
print(f"\n total: {len(salida)} fragmentos ({oro} de metodologia)", flush=True)
if solo_cuenta:
return 0
os.makedirs(os.path.dirname(SALIDA), exist_ok=True)
with open(SALIDA, "w", encoding="utf-8") as f:
for e in salida:
f.write(json.dumps(e, ensure_ascii=False) + "\n")
print(f" escrito: {SALIDA}", flush=True)
return 0
def main():
p = argparse.ArgumentParser()
p.add_argument("--cuenta", action="store_true")
a = p.parse_args()
return construye(solo_cuenta=a.cuenta)
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,45 @@
{"pregunta": "como saco una shell reversa desde bash", "espera": ["bash -i", "/dev/tcp", "reverse", "nc "]}
{"pregunta": "generar un payload de reverse shell con msfvenom", "espera": ["msfvenom", "-p ", "lhost", "payload"]}
{"pregunta": "estabilizar una shell tty para que funcione bien", "espera": ["python", "pty", "stty", "script /dev/null"]}
{"pregunta": "bypass de login por inyeccion sql", "espera": ["or '1'='1", "or 1=1", "-- -", "union select"]}
{"pregunta": "detectar sql injection a ciegas basada en tiempo", "espera": ["sleep", "waitfor", "time-based", "benchmark"]}
{"pregunta": "de una sqli a ejecucion de comandos", "espera": ["xp_cmdshell", "load_file", "into outfile", "rce"]}
{"pregunta": "explotar un local file inclusion para conseguir rce", "espera": ["lfi", "php://", "log poisoning", "/proc/self/environ", "data://"]}
{"pregunta": "probar server side template injection", "espera": ["ssti", "{{7*7", "jinja", "${", "twig"]}
{"pregunta": "payload basico de xss para robar cookie", "espera": ["<script", "document.cookie", "onerror", "alert("]}
{"pregunta": "inyeccion de comandos en un parametro web", "espera": ["command injection", ";", "| ", "$(", "`", "&&"]}
{"pregunta": "saltarme un filtro de subida de ficheros", "espera": ["file upload", "magic bytes", ".phtml", "content-type", "double extension"]}
{"pregunta": "descubrir directorios y ficheros ocultos en una web", "espera": ["gobuster", "ffuf", "feroxbuster", "dirb", "wordlist"]}
{"pregunta": "path traversal para leer etc passwd", "espera": ["../", "directory traversal", "/etc/passwd", "%2e"]}
{"pregunta": "enumerar un servidor smb", "espera": ["smbclient", "enum4linux", "smbmap", "crackmapexec", "139", "445"]}
{"pregunta": "escaneo de puertos y servicios con nmap", "espera": ["nmap", "-sv", "-sc", "-p-", "-a "]}
{"pregunta": "como paso de usuario normal a root en linux", "espera": ["privesc", "sudo -l", "suid", "linpeas", "gtfobins"]}
{"pregunta": "escalada de privilegios en windows", "espera": ["winpeas", "privesc", "seimpersonate", "potato", "token"]}
{"pregunta": "ataque dcsync para sacar hashes del dominio", "espera": ["dcsync", "secretsdump", "mimikatz", "lsadump"]}
{"pregunta": "pass the hash para autenticarme sin la contrasena", "espera": ["pass-the-hash", "pass the hash", "-hashes", "ntlm", "pth"]}
{"pregunta": "kerberoasting para sacar tickets de servicio", "espera": ["kerberoast", "getuserspns", "spn", "ticket"]}
{"pregunta": "usar bloodhound para mapear el active directory", "espera": ["bloodhound", "sharphound", "neo4j", "collector"]}
{"pregunta": "dumpear credenciales de memoria con mimikatz", "espera": ["mimikatz", "sekurlsa", "logonpasswords", "lsass"]}
{"pregunta": "usar impacket para ejecutar comandos remotos", "espera": ["impacket", "psexec", "wmiexec", "smbexec"]}
{"pregunta": "crackear un hash con john the ripper", "espera": ["john", "--wordlist", "rockyou", "--format"]}
{"pregunta": "crackear hashes con hashcat", "espera": ["hashcat", "-m ", "rockyou", "-a "]}
{"pregunta": "ataque de fuerza bruta a un login ssh", "espera": ["hydra", "medusa", "-l ", "-p ", "ssh"]}
{"pregunta": "transferir un fichero a la maquina victima", "espera": ["scp", "http.server", "certutil", "wget", "curl", "impacket-smbserver"]}
{"pregunta": "montar un tunel para pivotar en la red interna", "espera": ["chisel", "ligolo", "ssh -l", "proxychains", "socks"]}
{"pregunta": "pivoting con ssh port forwarding", "espera": ["ssh -l", "ssh -r", "-d ", "port forward", "dynamic"]}
{"pregunta": "usar metasploit para explotar un servicio", "espera": ["metasploit", "msfconsole", "use exploit", "set rhost"]}
{"pregunta": "escanear vulnerabilidades web con nuclei", "espera": ["nuclei", "-t ", "template", "-u "]}
{"pregunta": "escanear un wordpress en busca de fallos", "espera": ["wpscan", "--enumerate", "--url", "plugin"]}
{"pregunta": "buscar exploits publicos de un servicio", "espera": ["searchsploit", "exploit-db", "exploitdb"]}
{"pregunta": "evadir un antivirus o edr al ejecutar payload", "espera": ["av evasion", "amsi", "bypass", "obfusc", "edr"]}
{"pregunta": "hacer un buffer overflow clasico", "espera": ["buffer overflow", "eip", "pattern_create", "badchars", "jmp esp"]}
{"pregunta": "filtrado de salida y como saltarmelo", "espera": ["egress", "filtering", "puerto", "443", "outbound"]}
{"pregunta": "enumerar usuarios y recursos de un active directory", "espera": ["enum", "ldap", "rpcclient", "net user", "adperti"]}
{"pregunta": "atacar autenticacion con credenciales por defecto", "espera": ["authentication", "default", "brute", "credential", "login"]}
{"pregunta": "descubrir contenido con fuzzing de parametros", "espera": ["ffuf", "fuzz", "content discovery", "wordlist"]}
{"pregunta": "usar burp repeater e intruder para probar peticiones", "espera": ["burp", "repeater", "intruder", "proxy"]}
{"pregunta": "escaneo de red para descubrir hosts vivos", "espera": ["nmap", "-sn", "ping sweep", "netdiscover", "arp"]}
{"pregunta": "conseguir persistencia tras comprometer una maquina", "espera": ["persistenc", "cron", "scheduled task", "backdoor", "registry"]}
{"pregunta": "como escaneo redes wifi y capturo handshakes", "espera": ["airodump", "aircrack", "handshake", "wpa", "airgeddon"]}
{"pregunta": "usar crackmapexec para barrer una red windows", "espera": ["crackmapexec", "cme ", "smb", "--shares", "spray"]}
{"pregunta": "leer un hash ntlm y reutilizarlo por la red", "espera": ["ntlm", "hash", "relay", "responder", "ntlmrelayx"]}

173
nucleo/saber/enriquece/evalua.py Executable file
View file

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Mide si el indice candidato recupera mejor que el vivo. Con numeros.
./evalua.py compara vivo contra saber.jsonl.nuevo
./evalua.py A.jsonl B.jsonl compara dos indices cualesquiera
## Que mide
Un conjunto de preguntas de pentesting (eval_set.jsonl), cada una con las
palabras que un fragmento CORRECTO tiene que contener (un comando, una
herramienta, un patron). Para cada indice se busca la pregunta y se mira si
alguno de los 5 primeros resultados contiene lo esperado.
hit@1 la pregunta se resuelve con el PRIMER resultado
hit@5 se resuelve con alguno de los cinco primeros
sim similitud media del mejor resultado
La busqueda replica la de busca.py (coseno + bono por palabras) para que la
comparacion sea justa: lo unico que cambia entre las dos columnas es el indice.
## Por que asi y no "le pregunte al modelo si estaba bien"
Un juez-LLM sobre una tarjeta de 4 GB es lento y ruidoso. Esto es determinista,
reproducible y honesto: la palabra esperada esta o no esta en el texto
recuperado. Es una cota inferior de la calidad un fragmento puede ser util sin
contener el literal pero sirve para COMPARAR dos indices, que es lo que decide
si se promociona el candidato.
"""
import json
import os
import re
import sys
import unicodedata
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI))
DATOS = os.path.join(RAIZ, "datos")
VIVO = os.path.join(DATOS, "saber.jsonl")
NUEVO = os.path.join(DATOS, "saber.jsonl.nuevo")
EVAL = os.path.join(AQUI, "eval_set.jsonl")
MODELO = "minishlab/potion-multilingual-128M"
_modelo = None
def _norm(t):
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return re.sub(r"[^a-z0-9 ]", " ", t)
def _carga_modelo():
global _modelo
if _modelo is None:
from model2vec import StaticModel
_modelo = StaticModel.from_pretrained(MODELO)
return _modelo
def _carga_indice(ruta):
import numpy as np
entradas, vs = [], []
with open(ruta, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
v = d.pop("v", None)
if v is None:
continue
entradas.append(d)
vs.append(v)
M = np.array(vs, dtype="float32")
M /= np.clip(np.linalg.norm(M, axis=1, keepdims=True), 1e-9, None)
txt = [set(_norm(e["texto"]).split()) for e in entradas]
nom = [_norm(e.get("herramienta", "")) for e in entradas]
return entradas, M, txt, nom
def _busca(indice, pregunta, cuantos=5):
import numpy as np
entradas, M, txt, nom = indice
q = _carga_modelo().encode([pregunta])[0]
q = q / max(float(np.linalg.norm(q)), 1e-9)
sim = M @ q
palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
if palabras:
bono = np.zeros(len(entradas), dtype="float32")
for i in range(len(entradas)):
b = 0.08 * len(palabras & txt[i])
if nom[i] and nom[i] in palabras:
b += 0.5
bono[i] = b
sim = sim + bono
orden = np.argsort(-sim)[: cuantos * 4]
salida, vistos = [], set()
for i in orden:
firma = entradas[i]["texto"][:120]
if firma in vistos:
continue
vistos.add(firma)
salida.append((float(sim[i]), entradas[i]["texto"]))
if len(salida) >= cuantos:
break
return salida
def evalua(ruta):
indice = _carga_indice(ruta)
casos = [json.loads(l) for l in open(EVAL, encoding="utf-8") if l.strip()]
hit1 = hit5 = 0
simtop = 0.0
fallos = []
for c in casos:
esperado = [_norm(k).strip() for k in c["espera"]]
res = _busca(indice, c["pregunta"])
simtop += res[0][0] if res else 0.0
textos = [_norm(t) for _, t in res]
acierta = lambda t: any(k and k in t for k in esperado)
if res and acierta(textos[0]):
hit1 += 1
if any(acierta(t) for t in textos):
hit5 += 1
else:
fallos.append(c["pregunta"])
n = len(casos)
return {"n": n, "hit1": hit1, "hit5": hit5,
"sim": simtop / n if n else 0.0, "fallos": fallos}
def _pinta(nombre, r):
print(f" {nombre}")
print(f" hit@1 {r['hit1']:3d}/{r['n']} ({100*r['hit1']//r['n']}%)")
print(f" hit@5 {r['hit5']:3d}/{r['n']} ({100*r['hit5']//r['n']}%)")
print(f" sim {r['sim']:.3f}")
def main():
a = sys.argv[1] if len(sys.argv) > 1 else VIVO
b = sys.argv[2] if len(sys.argv) > 2 else NUEVO
if not os.path.exists(b):
print(f" no existe el candidato: {b}")
return 1
print("Evaluando el indice VIVO...", flush=True)
rv = evalua(a)
print("Evaluando el indice CANDIDATO...", flush=True)
rn = evalua(b)
print("\n" + "=" * 44)
_pinta("VIVO " + os.path.basename(a), rv)
print()
_pinta("CANDIDATO " + os.path.basename(b), rn)
print("=" * 44)
mejora5 = rn["hit5"] - rv["hit5"]
mejora1 = rn["hit1"] - rv["hit1"]
print(f"\n hit@5: {mejora5:+d} hit@1: {mejora1:+d}")
# el veredicto que lee correr_noche.sh
promociona = rn["hit5"] >= rv["hit5"] and rn["hit1"] >= rv["hit1"] - 1
print(f" VEREDICTO {'PROMOCIONAR' if promociona else 'MANTENER'}")
if rn["fallos"]:
print(f"\n el candidato aun no resuelve {len(rn['fallos'])}:")
for q in rn["fallos"][:12]:
print(f" · {q}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Experimento: mide si un embedder transformer supera al model2vec estatico.
./experimento_embedder.py prueba intfloat/multilingual-e5-base
./experimento_embedder.py BAAI/bge-m3 prueba otro modelo
## Por que este experimento
model2vec (potion-multilingual-128M) es un embedder ESTATICO: rapidisimo (un
vector por token, precalculado, sin red neuronal en la consulta) pero flojo en
lo semantico. Su punto ciego se ve en las dos preguntas que el RAG no resuelve:
"detectar sql injection a ciegas basada en tiempo" -> no encuentra SLEEP
"descubrir directorios ocultos en una web" -> no encuentra gobuster
Las dos son cross-lingual: la pregunta va en castellano coloquial y el comando
en ingles tecnico ("time-based", "content discovery"). Un transformer de verdad
e5, bge entiende eso; el estatico no.
Este experimento NO toca nada: coge una MUESTRA del indice vivo (para que quepa
en memoria y sea rapido), la re-embebe con el transformer, y corre la misma
evaluacion. Si gana claro, merece la pena cambiar busca.py; si no, no.
## El coste que habria que pagar si se adopta
El estatico encodea la consulta en microsegundos. Un transformer en CPU tarda
~50-150 ms por consulta. Para un asistente de voz local es asumible, pero no es
gratis, y por eso se mide antes de decidir.
"""
import json
import os
import re
import sys
import unicodedata
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI))
VIVO = os.path.join(RAIZ, "datos", "saber.jsonl")
EVAL = os.path.join(AQUI, "eval_set.jsonl")
MODELO_TF = sys.argv[1] if len(sys.argv) > 1 else "intfloat/multilingual-e5-base"
# Muestra: todo lo que un caso de eval podria querer + relleno, para no cargar
# 14.000 vectores de transformer en RAM. Se cogen todas las entradas de
# metodologia y pregunta (el oro) mas una parte del resto.
MAX_ENTRADAS = 6000
def _norm(t):
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return re.sub(r"[^a-z0-9 ]", " ", t)
def _muestra():
oro, resto = [], []
with open(VIVO, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
(oro if d.get("tipo") in ("metodologia", "pregunta") else resto).append(d)
ent = oro + resto[: max(0, MAX_ENTRADAS - len(oro))]
return ent
def _eval(nombre, encode_corpus, encode_query, entradas):
import numpy as np
textos = [(e.get("indexar") or e["texto"]) for e in entradas]
M = encode_corpus(textos)
M = M / np.clip(np.linalg.norm(M, axis=1, keepdims=True), 1e-9, None)
palabras_e = [set(_norm(e["texto"]).split()) for e in entradas]
nom = [_norm(e.get("herramienta", "")) for e in entradas]
casos = [json.loads(l) for l in open(EVAL, encoding="utf-8") if l.strip()]
hit1 = hit5 = 0
fallos = []
for c in casos:
q = encode_query([c["pregunta"]])[0]
q = q / max(float(np.linalg.norm(q)), 1e-9)
sim = M @ q
pal = {w for w in _norm(c["pregunta"]).split() if len(w) > 3}
if pal:
bono = np.array([0.08 * len(pal & palabras_e[i]) +
(0.5 if nom[i] and nom[i] in pal else 0.0)
for i in range(len(entradas))], dtype="float32")
sim = sim + bono
orden = np.argsort(-sim)[:5]
esperado = [_norm(k).strip() for k in c["espera"]]
tops = [_norm(entradas[i]["texto"]) for i in orden]
ok = lambda t: any(k and k in t for k in esperado)
if tops and ok(tops[0]):
hit1 += 1
if any(ok(t) for t in tops):
hit5 += 1
else:
fallos.append(c["pregunta"])
n = len(casos)
print(f"\n {nombre}")
print(f" hit@1 {hit1:3d}/{n} ({100*hit1//n}%)")
print(f" hit@5 {hit5:3d}/{n} ({100*hit5//n}%)")
if fallos:
print(f" no resuelve {len(fallos)}: " + "; ".join(fallos[:6]))
return hit1, hit5
def main():
entradas = _muestra()
print(f"Muestra: {len(entradas)} entradas del indice vivo")
# 1) model2vec estatico, la referencia
from model2vec import StaticModel
est = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
enc_est = lambda xs: est.encode(xs)
h1e, h5e = _eval("model2vec (estatico, el actual)", enc_est, enc_est, entradas)
# 2) el transformer a prueba
print(f"\n cargando {MODELO_TF} (puede descargar ~1-2 GB la 1a vez)...", flush=True)
from sentence_transformers import SentenceTransformer
tf = SentenceTransformer(MODELO_TF)
# los modelos e5 piden prefijos "query:" y "passage:"
es_e5 = "e5" in MODELO_TF.lower()
enc_doc = lambda xs: tf.encode([("passage: " + x) if es_e5 else x for x in xs],
show_progress_bar=False, batch_size=32)
enc_q = lambda xs: tf.encode([("query: " + x) if es_e5 else x for x in xs],
show_progress_bar=False, batch_size=32)
h1t, h5t = _eval(f"{MODELO_TF} (transformer)", enc_doc, enc_q, entradas)
print("\n" + "=" * 44)
print(f" hit@1 estatico {h1e} -> transformer {h1t} ({h1t-h1e:+d})")
print(f" hit@5 estatico {h5e} -> transformer {h5t} ({h5t-h5e:+d})")
print("=" * 44)
if h5t > h5e or (h5t == h5e and h1t > h1e):
print(" El transformer gana. Merece plantear el cambio en busca.py")
print(" (coste: ~50-150 ms por consulta en CPU, hoy es instantaneo).")
else:
print(" El transformer NO compensa aqui. El estatico se queda.")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Construye el indice candidato uniendo lo viejo, lo cosechado y lo sintetizado.
./reindexa.py escribe datos/saber.jsonl.nuevo (NO toca el vivo)
## Que une
1. El indice vivo (datos/saber.jsonl): comandos de Linux, glosario, fichas,
servidores... todo lo que cosecha no produce. Se conserva entero.
2. cosecha.jsonl: la metodologia de pentesting que faltaba.
3. sintesis.jsonl: preguntas en castellano que apuntan a esos fragmentos.
## Indexacion multi-representacion
Cada entrada se EMBEBE por su campo `indexar` si lo tiene (las preguntas
sintetizadas), y si no por `texto` (todo lo demas). Asi una entrada de pregunta
se compara con la consulta del usuario pregunta-contra-pregunta, pero lo que se
le muestra al cerebro sigue siendo el fragmento literal de `texto`. busca.py no
cambia: solo lee `texto` y `v`.
## No destruye nada
Escribe a `saber.jsonl.nuevo`. Quien decide si sustituye al vivo es evalua.py +
correr_noche.sh, y solo si mide que es mejor, guardando antes una copia.
## Deduplicado
Por los primeros 120 caracteres del texto normalizado (para las entradas
normales) o de la pregunta (para las sintetizadas). OSCP_APUNTES duplica apuntes
de primer nivel; esto los colapsa.
"""
import json
import os
import re
import sys
import unicodedata
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI))
DATOS = os.path.join(RAIZ, "datos")
# La base son las entradas que cosecha NO produce (comandos Linux, glosario,
# fichas). Por defecto el indice vivo, pero se puede fijar con SABER_BASE al
# indice ORIGINAL para una reconstruccion limpia que no apile sintesis sobre
# sintesis y no infle el indice a cada pasada.
VIVO = os.environ.get("SABER_BASE") or os.path.join(DATOS, "saber.jsonl")
COSECHA = os.path.join(DATOS, "cosecha.jsonl")
SINTESIS = os.path.join(DATOS, "sintesis.jsonl")
NUEVO = os.path.join(DATOS, "saber.jsonl.nuevo")
MODELO = "minishlab/potion-multilingual-128M"
def _norm(t):
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", t)).strip()
def _lee(ruta, quita_v=False):
if not os.path.exists(ruta):
return
with open(ruta, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
if quita_v:
d.pop("v", None)
yield d
def main():
entradas, vistos = [], set()
fuentes = [("vivo", VIVO, True), ("cosecha", COSECHA, False),
("sintesis", SINTESIS, False)]
cuenta = {}
for nombre, ruta, quita in fuentes:
n = 0
for d in _lee(ruta, quita_v=quita):
clave_txt = d.get("indexar") or d.get("texto", "")
firma = _norm(clave_txt)[:120]
if not firma or firma in vistos:
continue
vistos.add(firma)
entradas.append(d)
n += 1
cuenta[nombre] = n
print(f" {nombre:9s} {n:6d} entradas nuevas", flush=True)
print(f"\n total tras deduplicar: {len(entradas)}", flush=True)
print(" cargando el modelo de embeddings...", flush=True)
from model2vec import StaticModel
modelo = StaticModel.from_pretrained(MODELO)
# se embebe `indexar` si existe (la pregunta), si no `texto`
a_embeber = [(e.get("indexar") or e["texto"]) for e in entradas]
print(f" calculando {len(a_embeber)} vectores...", flush=True)
vectores = modelo.encode(a_embeber, show_progress_bar=False)
with open(NUEVO, "w", encoding="utf-8") as f:
for e, v in zip(entradas, vectores):
e["v"] = [round(float(x), 5) for x in v]
f.write(json.dumps(e, ensure_ascii=False) + "\n")
print(f" escrito: {NUEVO} ({os.path.getsize(NUEVO)//1024} KB)", flush=True)
print(f" RESUMEN vivo={cuenta['vivo']} cosecha={cuenta['cosecha']} "
f"sintesis={cuenta['sintesis']} total={len(entradas)}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Genera preguntas en castellano para cada fragmento cosechado.
./sintetiza.py procesa datos/cosecha.jsonl (reanudable, horas)
./sintetiza.py --muestra 5 ensena 5 y para
## Que problema resuelve, y por que no corrompe comandos
El indice usa embeddings estaticos. Su punto flaco conocido: una pregunta
coloquial en castellano "como saco una shell reversa" no se parece
vectorialmente a un fragmento tecnico en ingles con `bash -i >& /dev/tcp/...`.
La busqueda hibrida por palabras ayuda, pero solo si coinciden literales.
La solucion es **indexacion multi-representacion**: por cada fragmento se generan
las preguntas que ese fragmento responde, y se EMBEBE LA PREGUNTA apuntando al
fragmento. Asi la consulta del usuario se compara contra otras preguntas, que es
comparar peras con peras.
Clave para no meter la pata: la respuesta que se le muestra al cerebro es el
**fragmento original, literal** (campo `texto`). El modelo local solo escribe la
PREGUNTA (campo `indexar`). Si inventa una pregunta rara, lo peor que pasa es que
esa entrada no recupere bien; **nunca corrompe un comando**, porque no reescribe
la respuesta. Y el fragmento crudo sigue en el indice por su lado (lo pone
cosecha), asi que la cobertura solo puede subir.
## El modelo
Habla con el ollama local. Usa el modelo que ya este CARGADO (mira /api/ps) para
no forzar a la tarjeta de 4 GB a cambiar de modelo con el carril verde abierto;
si no hay ninguno, carga qwen3.5:4b-jarvis. think:false qwen3.5 razona por
defecto y tardaria 20x.
## Reanudable
Un trabajo de horas no puede perder todo si se corta. Se apunta en un .hecho por
que linea de cosecha.jsonl iba, y al arrancar salta hasta ahi. La metodologia
(el oro) se procesa PRIMERO, para que si no termina la noche, lo valioso este.
"""
import argparse
import json
import os
import re
import sys
import urllib.request
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI))
COSECHA = os.path.join(RAIZ, "datos", "cosecha.jsonl")
SALIDA = os.path.join(RAIZ, "datos", "sintesis.jsonl")
MARCA = SALIDA + ".hecho"
ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434")
INSTRUCCION = """Eres un generador de preguntas para un buscador de apuntes de
pentesting. Te doy un fragmento de apuntes tecnicos. Devuelve de UNA a TRES
preguntas, en castellano, que una persona haria por voz y que ese fragmento
responde.
Reglas:
- Preguntas naturales y variadas, como las diria alguien: "como...", "que
comando...", "cual es la forma de...".
- NO respondas, NO copies comandos: solo las preguntas.
- Si el fragmento es ruido (un titulo suelto, una lista de enlaces), devuelve
una lista vacia.
Responde SOLO con JSON: {"preguntas": ["...", "..."]}"""
FIJO = os.environ.get("JARVIS_MODELO") # si se fija a mano, manda
RESPALDO = "qwen3.5:4b-jarvis"
def _modelo_cargado():
"""El modelo que ollama ya tiene en memoria, para no forzar un cambio."""
try:
with urllib.request.urlopen(ENDPOINT + "/api/ps", timeout=5) as r:
m = json.load(r).get("models", [])
if m:
return m[0]["name"]
except Exception:
pass
return None
def _modelo():
"""Que modelo usar AHORA. Se re-mira cada tanto en vez de fijarlo al
arrancar: en una tarjeta de 4 GB solo cabe uno, asi que si el carril verde
tiene el suyo cargado, generamos con ESE (una pregunta no necesita el ajuste
anti-alucinacion, solo la respuesta lo necesitaria y la respuesta es el
fragmento literal). Asi no se fuerza a ollama a cambiar de modelo en cada
peticion, que en esta maquina cuesta 2-3 s de recarga.
"""
return FIJO or _modelo_cargado() or RESPALDO
MODELO = _modelo()
def _pide(fragmento, modelo):
cuerpo = json.dumps({
"model": modelo,
"messages": [{"role": "system", "content": INSTRUCCION},
{"role": "user", "content": fragmento[:1400]}],
"stream": False, "think": False,
"options": {"temperature": 0.7, "num_predict": 200},
"format": "json",
}).encode()
req = urllib.request.Request(ENDPOINT + "/api/chat", data=cuerpo,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=120) as r:
contenido = json.loads(r.read())["message"]["content"]
except Exception:
return []
try:
d = json.loads(contenido)
except json.JSONDecodeError:
m = re.search(r"\[.*\]", contenido, re.S)
if not m:
return []
try:
d = {"preguntas": json.loads(m.group(0))}
except json.JSONDecodeError:
return []
preguntas = d.get("preguntas") if isinstance(d, dict) else d
if not isinstance(preguntas, list):
return []
fuera = []
for p in preguntas:
if isinstance(p, str) and 8 <= len(p.strip()) <= 200:
fuera.append(p.strip())
return fuera[:3]
def fragmentos():
"""Los fragmentos de cosecha, el ORO (metodologia) primero."""
todos = []
with open(COSECHA, encoding="utf-8") as f:
for l in f:
try:
todos.append(json.loads(l))
except json.JSONDecodeError:
continue
todos.sort(key=lambda d: 0 if d.get("tipo") == "metodologia" else 1)
return todos
def main():
p = argparse.ArgumentParser()
p.add_argument("--muestra", type=int, default=0)
a = p.parse_args()
if not os.path.exists(COSECHA):
print(" no hay cosecha.jsonl: corre cosecha.py primero")
return 1
frags = fragmentos()
print(f" {len(frags)} fragmentos · modelo {MODELO}", flush=True)
if a.muestra:
for fr in frags[:a.muestra]:
print(f"\n ── {fr.get('fichero')} ──")
for q in _pide(fr["texto"], MODELO):
print(f" P: {q}")
return 0
desde = 0
if os.path.exists(MARCA) and os.path.exists(SALIDA):
try:
desde = int(open(MARCA).read().strip()) + 1
except (ValueError, OSError):
desde = 0
modo = "a" if desde else "w"
hechas = sum(1 for _ in open(SALIDA)) if desde and os.path.exists(SALIDA) else 0
if desde:
print(f" reanudando desde el fragmento {desde} ({hechas} preguntas)", flush=True)
total = len(frags)
modelo = MODELO
with open(SALIDA, modo, encoding="utf-8", buffering=1) as out:
for i in range(desde, total):
# re-mirar que modelo esta cargado cada 25: si el verde se abre o se
# cierra durante la noche, seguimos al que este en la tarjeta.
if i % 25 == 0:
modelo = _modelo()
fr = frags[i]
for q in _pide(fr["texto"], modelo):
out.write(json.dumps({
"tipo": "pregunta",
"herramienta": fr.get("herramienta"),
"perfil": fr.get("perfil"),
"fichero": fr.get("fichero"),
"tema": fr.get("tema", ""),
"texto": fr["texto"], # la respuesta: el fragmento LITERAL
"indexar": q, # lo que se embebe: la pregunta
}, ensure_ascii=False) + "\n")
hechas += 1
with open(MARCA, "w") as m:
m.write(str(i))
if (i + 1) % 25 == 0:
print(f" PROGRESO sintetiza hecho={i+1} total={total} "
f"preguntas={hechas}", flush=True)
print(f"\n hecho: {hechas} preguntas en {SALIDA}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())

94
nucleo/saber/glosario.py Normal file
View file

@ -0,0 +1,94 @@
"""Tareas comunes de Linux en castellano, mapeadas al comando.
El puente que las man pages no dan: estan en ingles, y "cambiar permisos" no se
parece a "change file mode bits". Aqui viven las tareas del dia a dia dichas
como las diria el usuario, con el comando y una linea de como se usa. Es
curado a mano a proposito: son las 50 cosas que de verdad se piden, y una
traduccion buena vale mas que mil man pages mal recuperadas.
Se anaden al indice como entradas normales, asi que se buscan igual que todo lo
demas y ademas se llevan el impulso por palabras exactas.
"""
# (lo que se pide en castellano, el comando, como se usa)
TAREAS = [
("cambiar los permisos de un fichero o carpeta", "chmod",
"chmod 755 fichero (rwx dueño, rx resto). chmod +x da permiso de ejecucion."),
("cambiar el dueño de un fichero", "chown",
"chown usuario:grupo fichero. Con -R para una carpeta entera."),
("comprimir una carpeta en un archivo", "tar",
"tar czf archivo.tar.gz carpeta/ comprime. tar xzf archivo.tar.gz extrae."),
("descomprimir un zip", "unzip", "unzip archivo.zip. Con -d carpeta para donde."),
("buscar texto dentro de ficheros", "grep",
"grep -r 'texto' carpeta/ busca en todo. grep -i ignora mayusculas."),
("buscar ficheros por nombre", "find",
"find /ruta -name '*.txt' busca por nombre. -type f solo ficheros."),
("ver los puertos abiertos en escucha", "ss",
"ss -tlnp lista los puertos TCP en escucha con su proceso."),
("ver que procesos consumen mas", "top",
"top o htop en tiempo real. ps aux --sort=-%cpu para una foto."),
("matar un proceso", "kill",
"kill PID lo termina. kill -9 PID a la fuerza. pkill nombre por nombre."),
("ver el espacio libre en disco", "df", "df -h muestra el espacio por particion."),
("ver cuanto ocupa una carpeta", "du", "du -sh carpeta/ el tamaño total."),
("ver la memoria libre", "free", "free -h muestra RAM y swap."),
("descargar un fichero de internet", "wget",
"wget URL descarga. curl -O URL tambien. curl -L sigue redirecciones."),
("copiar ficheros a un servidor", "scp",
"scp fichero usuario@servidor:/ruta lo sube. scp -r para carpetas."),
("sincronizar carpetas", "rsync",
"rsync -av origen/ destino/ copia solo lo cambiado. -z comprime en red."),
("editar un fichero de texto", "nano",
"nano fichero (facil) o vim fichero. Ctrl+O guarda y Ctrl+X sale en nano."),
("ver el contenido de un fichero", "cat",
"cat fichero. less fichero para leer largo. head/tail para principio/final."),
("ver los ultimos cambios de un fichero de log", "tail",
"tail -f log sigue en vivo. tail -n 50 las ultimas 50 lineas."),
("ver los servicios del sistema", "systemctl",
"systemctl status servicio. start/stop/restart para manejarlo."),
("ver los mensajes del sistema", "journalctl",
"journalctl -xe lo reciente. journalctl -u servicio de un servicio."),
("montar un disco o usb", "mount",
"mount /dev/sdX1 /mnt monta. lsblk lista los discos. umount desmonta."),
("ver la configuracion de red", "ip",
"ip a las interfaces y sus IP. ip r la tabla de rutas."),
("escanear una red o unos puertos", "nmap",
"nmap -sV objetivo detecta servicios. nmap -p- todos los puertos."),
("ver el uso de red en vivo", "iftop", "iftop o nload muestran el trafico en vivo."),
("cambiar de directorio y listar", "ls",
"ls -la lista con detalles y ocultos. cd carpeta entra. pwd dice donde estas."),
("crear una carpeta", "mkdir", "mkdir carpeta. mkdir -p a/b/c crea la ruta entera."),
("mover o renombrar", "mv", "mv origen destino mueve o renombra."),
("copiar ficheros", "cp", "cp origen destino. cp -r para carpetas."),
("borrar ficheros", "rm",
"rm fichero. rm -r carpeta. CUIDADO: no hay papelera, es definitivo."),
("dar permisos de administrador a un comando", "sudo",
"sudo comando lo ejecuta como root. Pide tu contraseña."),
("ver el historial de comandos", "history", "history lista lo tecleado. Ctrl+R busca."),
("programar una tarea periodica", "cron",
"crontab -e edita tus tareas. Formato: min hora dia mes diasemana comando."),
("ver la temperatura y el hardware", "sensors",
"sensors muestra temperaturas. lscpu la CPU, lspci los dispositivos."),
("gestionar paquetes en Debian", "apt",
"apt install paquete, apt update actualiza la lista, apt upgrade el sistema."),
("ver o cambiar variables de entorno", "export",
"export VAR=valor la pone. env las lista. echo $VAR la muestra."),
("comprobar si un comando esta instalado", "which",
"which comando dice donde esta, o nada si no esta. type comando tambien."),
("ver diferencias entre dos ficheros", "diff",
"diff a b muestra las diferencias. diff -u formato de parche."),
("contar lineas palabras o bytes", "wc",
"wc -l lineas, wc -w palabras. cat fichero | wc -l cuenta lineas."),
("conectarse a una base de datos", "psql",
"psql -U usuario base para Postgres. mysql -u usuario -p para MySQL."),
("ver quien esta conectado", "who", "who los usuarios. w con lo que hacen."),
]
def entradas():
for pide, cmd, como in TAREAS:
yield {
"tipo": "tarea", "herramienta": cmd, "perfil": "LINUX",
"fichero": f"tarea comun: {cmd}",
"texto": f"Para {pide}: usa {cmd}. {como}",
}

232
nucleo/saber/indexa.py Executable file
View file

@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Convierte COFRE en algo que JARVIS puede consultar antes de contestar.
./indexa.py reconstruye el indice
./indexa.py --cuenta solo dice que encontraria, sin indexar
## El problema que resuelve
COFRE tiene 35.000 documentos y 30 GB. Indexarlo entero seria a la vez inutil y
lentisimo: el 95 % es codigo fuente de terceros node_modules, .git, dist que
no dice nada de COMO USAR una herramienta. Una busqueda sobre eso devuelve
ruido con total confianza, que es peor que no tener nada.
Lo que de verdad vale es el SABER, y son tres cosas:
1. Los apuntes propios del usuario: 26.000 palabras de flujos de trabajo
escritos probandolos. Es lo mas valioso porque es correcto y es suyo.
2. Los READMEs de primer nivel de cada herramienta: el "para que sirve y como
se llama", una vez por herramienta.
3. Una ficha por herramienta: nombre, perfil, ruta, una linea. Asi JARVIS
sabe QUE tiene aunque no haya un README.
## Como se trocea
En fragmentos por seccion (los encabezados markdown) y no en trozos ciegos de N
caracteres: un apunte partido por la mitad de una frase recupera peor que uno
partido por donde el autor ya separo las ideas. Cada fragmento recuerda de que
fichero y que herramienta viene, para poder citar la fuente.
"""
import argparse
import json
import os
import re
import sys
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
COFRE = os.path.expanduser("~/COFRE")
INDICE = os.path.join(AQUI, "datos", "saber.jsonl")
# Los perfiles que se indexan. Cada uno es una carpeta de ~/COFRE.
PERFILES = ["PENTESTERS", "PHISHERS", "REVERSERS", "PROTECTORS", "HARD-WARERS",
"AUTOMATAS", "DEFACERS", "SNEAKERS", "ZAPPERS"]
# Lo que NO se mira: dependencias, control de versiones, builds. Aqui esta la
# diferencia entre un indice util y 30 GB de ruido.
IGNORA = re.compile(
r"(^|/)(node_modules|\.git|vendor|dist|build|__pycache__|\.venv|venv|"
r"site-packages|target|\.cache|test|tests|fixtures|examples?|locale|"
r"i18n|translations|\.github)(/|$)", re.I)
# Documentos que son saber, no plantillas de proyecto ni codigo de conducta.
DOC_UTIL = re.compile(r"(readme|install|usage|apuntes|notas|trucos|howto|"
r"documentation|guide|guia|cheat|manual)", re.I)
DOC_RUIDO = re.compile(r"(code_of_conduct|contributing|changelog|license|"
r"copying|pull_request|issue_template|security\.md|"
r"hall_of_fame|redistributed|historical)", re.I)
MIN_FRAGMENTO = 80 # menos de esto no es un parrafo, es un titulo suelto
MAX_FRAGMENTO = 1200 # mas de esto recupera con demasiado ruido alrededor
def _profundidad(ruta):
return ruta.rstrip("/").count("/")
def herramientas(perfil):
"""Cada subcarpeta de primer nivel es una herramienta. Una ficha por cada."""
base = os.path.join(COFRE, perfil)
if not os.path.isdir(base):
return
for nombre in sorted(os.listdir(base)):
ruta = os.path.join(base, nombre)
if not os.path.isdir(ruta) or nombre.startswith("."):
continue
yield {"herramienta": nombre, "perfil": perfil, "ruta": ruta}
def _resumen_readme(ruta_herramienta):
"""El primer parrafo con sustancia de su README, si tiene."""
for nombre in ("README.md", "README", "README.txt", "readme.md"):
p = os.path.join(ruta_herramienta, nombre)
if not os.path.exists(p):
continue
try:
with open(p, encoding="utf-8", errors="ignore") as f:
texto = f.read(4000)
except OSError:
continue
# saltar titulos, insignias y lineas vacias hasta el primer parrafo real
for parrafo in re.split(r"\n\s*\n", texto):
limpio = re.sub(r"[#>*`\[\]!]|\(https?://[^)]+\)|<[^>]+>", "", parrafo).strip()
limpio = re.sub(r"\s+", " ", limpio)
if len(limpio) >= 60 and not limpio.lower().startswith(("http", "===")):
return limpio[:400]
return ""
def documentos_utiles(perfil):
"""Los .md/.txt que son saber, no codigo ni plantillas."""
base = os.path.join(COFRE, perfil)
for raiz, dirs, ficheros in os.walk(base):
if IGNORA.search(raiz):
dirs[:] = []
continue
# no bajar mas de 3 niveles: el saber vive arriba, el codigo abajo
if _profundidad(raiz) - _profundidad(base) > 3:
dirs[:] = []
continue
for fich in ficheros:
if not fich.lower().endswith((".md", ".txt")):
continue
if DOC_RUIDO.search(fich):
continue
# los apuntes propios entran siempre; de terceros, solo los utiles
propio = re.search(r"apuntes|trucos|notas|cheat|guia", fich, re.I)
if not propio and not DOC_UTIL.search(fich):
continue
yield os.path.join(raiz, fich)
def trocea(ruta):
"""Un fichero markdown en fragmentos por seccion."""
try:
with open(ruta, encoding="utf-8", errors="ignore") as f:
texto = f.read()
except OSError:
return
# partir por encabezados, conservando el titulo con su seccion
partes = re.split(r"\n(?=#{1,3}\s)", texto)
for parte in partes:
limpio = parte.strip()
if len(limpio) < MIN_FRAGMENTO:
continue
# si una seccion es enorme, partirla por parrafos sin pasar del tope
if len(limpio) <= MAX_FRAGMENTO:
yield limpio
else:
buffer = ""
for parrafo in re.split(r"\n\s*\n", limpio):
if len(buffer) + len(parrafo) > MAX_FRAGMENTO and buffer:
yield buffer.strip()
buffer = ""
buffer += parrafo + "\n\n"
if len(buffer.strip()) >= MIN_FRAGMENTO:
yield buffer.strip()
def construye(solo_cuenta=False, solo_cofre=False):
fichas, fragmentos = [], []
for perfil in PERFILES:
n_herr = 0
for h in herramientas(perfil):
resumen = _resumen_readme(h["ruta"])
texto = f"{h['herramienta']} ({perfil}). {resumen}".strip()
fichas.append({
"tipo": "ficha", "herramienta": h["herramienta"],
"perfil": perfil, "ruta": h["ruta"], "texto": texto,
})
n_herr += 1
n_frag = 0
for doc in documentos_utiles(perfil):
herr = _herramienta_de(doc, perfil)
for trozo in trocea(doc):
fragmentos.append({
"tipo": "apunte", "herramienta": herr, "perfil": perfil,
"fichero": os.path.relpath(doc, COFRE), "texto": trozo,
})
n_frag += 1
print(f" {perfil:14s} {n_herr:4d} herramientas {n_frag:5d} fragmentos de apuntes")
# El conocimiento de la propia maquina: comandos de Linux, servidores SSH,
# extensiones. Es lo que convierte "sabe de mis herramientas" en "sabe hacer
# cualquier cosa en Linux". Va salvo que se pida solo COFRE.
sistema = []
if not solo_cofre:
try:
from saber import sistema as sis
except ImportError:
import sistema as sis
print(" extrayendo la documentacion del sistema (man pages, ssh, firefox)...",
flush=True)
n_cmd = n_srv = n_nav = 0
for e in sis.todo():
sistema.append(e)
n_cmd += e["tipo"] == "comando"
n_srv += e["tipo"] == "servidor"
n_nav += e["tipo"] == "navegador"
if len(sistema) % 500 == 0:
print(f" {len(sistema)} entradas de sistema...", flush=True)
print(f" LINUX {n_cmd:4d} comandos {n_srv} servidores {n_nav} navegador")
todo = fichas + fragmentos + sistema
print(f"\n total: {len(fichas)} fichas + {len(fragmentos)} apuntes + "
f"{len(sistema)} sistema = {len(todo)} entradas")
if solo_cuenta:
return 0
print(" cargando el modelo de embeddings...", flush=True)
from model2vec import StaticModel
modelo = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
print(" calculando vectores...", flush=True)
vectores = modelo.encode([e["texto"] for e in todo], show_progress_bar=False)
os.makedirs(os.path.dirname(INDICE), exist_ok=True)
with open(INDICE, "w", encoding="utf-8") as f:
for entrada, vec in zip(todo, vectores):
entrada["v"] = [round(float(x), 5) for x in vec]
f.write(json.dumps(entrada, ensure_ascii=False) + "\n")
print(f" indice guardado: {INDICE} ({os.path.getsize(INDICE)//1024} KB)")
return 0
def _herramienta_de(ruta, perfil):
partes = os.path.relpath(ruta, os.path.join(COFRE, perfil)).split(os.sep)
return partes[0] if len(partes) > 1 else perfil
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--cuenta", action="store_true",
help="di que encontrarias, sin indexar")
p.add_argument("--solo-cofre", action="store_true",
help="indexar solo COFRE, sin la documentacion del sistema")
a = p.parse_args()
return construye(solo_cuenta=a.cuenta, solo_cofre=a.solo_cofre)
if __name__ == "__main__":
sys.exit(main())

183
nucleo/saber/sistema.py Normal file
View file

@ -0,0 +1,183 @@
"""Indexar el conocimiento de la propia maquina: comandos, servidores, extensiones.
El RAG de COFRE (indexa.py) sabe de las herramientas del usuario. Esto añade lo
otro que pidio: que sepa hacer CUALQUIER cosa en GNU/Linux. La fuente ya esta en
el disco 9.541 man pages, 3.000 comandos y es la mejor que hay: la
documentacion oficial de cada herramienta, escrita por quien la hizo.
## Que se extrae de cada man page, y por que no entera
Una man page entera son cientos de lineas: todas las opciones, los ejemplos, las
notas, los bugs. Meterla entera hace el indice enorme y recupera peor hay tanto
texto que la parte relevante se diluye. Se coge lo que contesta "¿que hace y
como se llama?":
NAME una linea: que es
SYNOPSIS como se invoca, con sus opciones
DESCRIPTION los primeros parrafos, donde esta lo esencial
Con eso, preguntar "como listo los puertos abiertos" recupera la man de ss o
netstat con su sintaxis, y el cerebro construye el comando desde ahi.
## Solo los comandos que EXISTEN en esta maquina
No las 9.541 man pages: muchas son de librerias de C, formatos de fichero y cosas
que no se invocan desde la terminal. Se indexan las de los comandos que estan de
verdad en el PATH (secciones 1 y 8), que es lo que el usuario puede pedir que se
ejecute.
"""
import os
import re
import subprocess
def _limpio(texto: str) -> str:
# las man pages traen backspaces para negrita (c\bco\bom\bmo); se quitan
texto = re.sub(r".\x08", "", texto)
texto = re.sub(r"\x1b\[[0-9;]*m", "", texto) # colores ANSI
return re.sub(r"[ \t]+", " ", texto)
def _comandos_del_path() -> list:
vistos = set()
for carpeta in os.environ.get("PATH", "").split(":"):
try:
for nombre in os.listdir(carpeta):
ruta = os.path.join(carpeta, nombre)
if nombre not in vistos and os.access(ruta, os.X_OK) \
and not os.path.isdir(ruta):
vistos.add(nombre)
except OSError:
continue
return sorted(vistos)
def _man(comando: str) -> str:
"""NAME + SYNOPSIS + arranque de DESCRIPTION de un comando, o ''."""
try:
r = subprocess.run(["man", comando], capture_output=True, text=True,
timeout=8, env={**os.environ, "MANWIDTH": "80",
"MAN_KEEP_FORMATTING": ""})
except (OSError, subprocess.TimeoutExpired):
return ""
if r.returncode != 0 or not r.stdout:
return ""
texto = _limpio(r.stdout)
trozos = []
seccion = None
buffer = []
# se cortan las secciones por sus titulos en mayuscula al margen izquierdo
for linea in texto.splitlines():
cabecera = re.match(r"^([A-Z][A-Z ]{2,20})$", linea.strip())
if cabecera:
if seccion in ("NAME", "SYNOPSIS", "DESCRIPTION") and buffer:
trozos.append((seccion, "\n".join(buffer).strip()))
seccion = cabecera.group(1).strip()
buffer = []
if seccion in ("OPTIONS", "SEE ALSO", "AUTHOR", "COPYRIGHT",
"REPORTING BUGS", "EXAMPLES"):
seccion = None # dejar de acumular
elif seccion:
buffer.append(linea)
if seccion in ("NAME", "SYNOPSIS", "DESCRIPTION") and buffer:
trozos.append((seccion, "\n".join(buffer).strip()))
partes = []
for sec, cont in trozos:
if sec == "DESCRIPTION":
cont = cont[:600] # solo el arranque de la descripcion
partes.append(cont)
return "\n".join(partes).strip()
def comandos():
"""Una entrada por comando del PATH que tenga man page."""
for cmd in _comandos_del_path():
texto = _man(cmd)
if len(texto) < 40:
continue
yield {
"tipo": "comando", "herramienta": cmd, "perfil": "LINUX",
"fichero": f"man {cmd}", "texto": texto[:1400],
}
def servidores():
"""Una ficha por host del ssh config, para que sepa a que puede conectarse."""
ruta = os.path.expanduser("~/.ssh/config")
if not os.path.exists(ruta):
return
host = None
datos = {}
def suelta():
if host and "*" not in host:
hn = datos.get("hostname", host)
usuario = datos.get("user", "")
desc = (f"Servidor SSH «{host}»: se conecta con «ssh {host}». "
f"Direccion {hn}." + (f" Usuario {usuario}." if usuario else "")
+ " Para ejecutar algo alli: ssh " + host + " '<comando>'.")
return {"tipo": "servidor", "herramienta": host, "perfil": "SSH",
"fichero": "~/.ssh/config", "texto": desc}
return None
for linea in open(ruta, encoding="utf-8", errors="ignore"):
m = re.match(r"^\s*(\w+)\s+(.+?)\s*$", linea)
if not m:
continue
clave, valor = m.group(1).lower(), m.group(2)
if clave == "host":
hecho = suelta()
if hecho:
yield hecho
host, datos = valor.split()[0], {}
elif host:
datos[clave] = valor
hecho = suelta()
if hecho:
yield hecho
def extensiones_firefox():
"""Que extensiones tiene puestas, para que sepa con que cuenta en el navegador.
Aviso honesto de alcance: JARVIS puede ABRIR firefox y llevarlo a una URL,
pero NO puede pulsar los botones de una extension eso vive dentro del
navegador, fuera del alcance de un comando de shell. Lo que si puede es
saber que tienes puestas y abrir lo que cada una gestiona por URL.
"""
import glob
import json as _json
base = os.path.expanduser("~/.mozilla/firefox")
perfiles = glob.glob(os.path.join(base, "*", "extensions.json"))
nombres = []
for p in perfiles:
try:
datos = _json.load(open(p, encoding="utf-8"))
except (OSError, ValueError):
continue
for addon in datos.get("addons", []):
if not addon.get("active") or addon.get("type") != "extension":
continue
nombre = ((addon.get("defaultLocale") or {}).get("name")
or addon.get("id", ""))
if nombre and nombre not in nombres:
nombres.append(nombre)
if nombres:
yield {
"tipo": "navegador", "herramienta": "firefox", "perfil": "NAVEGADOR",
"fichero": "extensiones de firefox",
"texto": ("Extensiones instaladas en Firefox: " + ", ".join(nombres)
+ ". JARVIS puede abrir Firefox y llevarlo a una URL; los "
"botones de cada extension se pulsan dentro del navegador."),
}
def todo():
"""Todas las entradas de sistema, para que indexa.py las sume a las de COFRE."""
from saber import glosario
yield from glosario.entradas() # el glosario en castellano, primero
yield from servidores()
yield from extensiones_firefox()
yield from comandos()

54
nucleo/ventana.sh Executable file
View file

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Abre el panel en SU PROPIA VENTANA, no en una pestaña del navegador.
#
# ventana.sh [url]
#
# Existe porque habia dos sitios abriendo el panel de dos maneras distintas:
# jarvis.py lo abria con `chromium --app=`, que sale como una aplicacion, y
# arranca.sh —cuando JARVIS ya estaba en marcha— tiraba de `xdg-open`, que abre
# el navegador POR DEFECTO en una pestaña mas. Segun si ya estaba abierto o no,
# el mismo icono daba una cosa u otra.
#
# ## Que hace que parezca una aplicacion y no una web
#
# --app=URL sin barra de direcciones, sin pestañas, sin marcadores
# --user-data-dir perfil propio: no se mezcla con la navegacion del
# usuario, no hereda sus extensiones y no arrastra su
# sesion. Ademas asi la ventana es un proceso aparte y
# cerrarla no toca al navegador de verdad.
# --class para que el gestor de ventanas la trate como una
# aplicacion propia: entrada suya en la barra de tareas y
# su icono, no el del navegador. Va acompañado del
# StartupWMClass del .desktop.
#
# Firefox queda como ultimo recurso: su `--kiosk` no deja ni cerrar la ventana
# comodamente y no tiene un equivalente limpio a --app. Si es lo unico que hay,
# se abre en ventana nueva y se avisa.
set -uo pipefail
RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
URL=${1:-http://127.0.0.1:${JARVIS_PUERTO:-8790}/}
PERFIL=${XDG_CACHE_HOME:-$HOME/.cache}/jarvis-panel
CLASE=jarvis-nucleo
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 || command -v firefox-esr >/dev/null; then
FF=$(command -v firefox || command -v firefox-esr)
echo "sin chromium: el panel se abre en una ventana de Firefox, con su barra" >&2
exec "$FF" --new-window "$URL" >/dev/null 2>&1
fi
echo "no hay ningun navegador con el que abrir el panel: $URL" >&2
exit 1