JARVIS/nucleo/saber/sistema.py
sito 8e4bc8ad94 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.
2026-08-16 15:34:37 +02:00

183 lines
6.9 KiB
Python

"""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()