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.
183 lines
6.9 KiB
Python
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()
|