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

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]