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:
commit
8e4bc8ad94
125 changed files with 25033 additions and 0 deletions
183
nucleo/saber/sistema.py
Normal file
183
nucleo/saber/sistema.py
Normal 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue