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.
346 lines
12 KiB
Python
346 lines
12 KiB
Python
"""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
|