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]

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