From 7c5381eea7fc7733887fa9ff57f72fdbc3d97ce1 Mon Sep 17 00:00:00 2001 From: sito Date: Tue, 18 Aug 2026 12:14:57 +0200 Subject: [PATCH] saber: lo instalado en la maquina y los docs de repos clonados entran al indice instalado.py lee los .desktop y los paquetes para saber que hay en el equipo y para que sirve cada cosa. indexa.py lo engancha, y con el los docs markdown de las herramientas clonadas en CODERS. Las dos fuentes son personales: se quedan en el indice de la maquina y no entran en el publico. busca_cli.py expone el mismo motor que usa el cerebro (saber.busca) para consultar el indice desde la terminal, con --n y --json. reentrena_noche.sh encadena las fases del reentrenamiento largo y las deja corriendo horas; cada fase es reanudable por su cuenta. eval del indice vivo: hit@1 43/57 (75%), hit@5 55/57 (96%), sim 0.909. --- nucleo/saber/awesome.py | 14 ++ nucleo/saber/busca_cli.py | 61 ++++++++ nucleo/saber/enriquece/eval_set.jsonl | 12 ++ nucleo/saber/enriquece/reentrena_noche.sh | 71 ++++++++++ nucleo/saber/enriquece/sintetiza_catalogo.py | 8 ++ nucleo/saber/indexa.py | 65 ++++++++- nucleo/saber/instalado.py | 140 +++++++++++++++++++ 7 files changed, 369 insertions(+), 2 deletions(-) create mode 100755 nucleo/saber/busca_cli.py create mode 100755 nucleo/saber/enriquece/reentrena_noche.sh create mode 100755 nucleo/saber/instalado.py diff --git a/nucleo/saber/awesome.py b/nucleo/saber/awesome.py index 47ffdf9..282eef8 100644 --- a/nucleo/saber/awesome.py +++ b/nucleo/saber/awesome.py @@ -36,6 +36,20 @@ CATALOGOS = [ "LISTAS", "CC0-1.0", True), ("awesome-hacking", os.path.join("_pages", "index.rst"), "rst", "awesome-hacking (jekil)", "PENTESTERS", "", False), + # Segunda tanda (16-17 ago). publico=False hasta verificar licencia: no salen + # al indice publico, solo al personal que usa JARVIS en esta maquina. + ("awesome-sysadmin", "README.md", "md", "awesome-sysadmin", "SISTEMA", "", False), + ("awesome-cli-apps", "readme.md", "md", "awesome-cli-apps", "SISTEMA", "", False), + ("awesome-security", "README.md", "md", "awesome-security", "PENTESTERS", "", False), + ("awesome-pentest", "README.md", "md", "awesome-pentest", "PENTESTERS", "", False), + ("awesome-shell", "README.md", "md", "awesome-shell", "SISTEMA", "", False), + ("awesome-linux", "README.md", "md", "awesome-linux", "SISTEMA", "", False), + # Tercera tanda (madrugada 17 ago). Todas publico=False hasta revisar licencia. + ("awesome-osint", "README.md", "md", "awesome-osint", "PENTESTERS", "", False), + ("awesome-devops", "README.md", "md", "awesome-devops", "SISTEMA", "", False), + ("awesome-docker", "README.md", "md", "awesome-docker", "SISTEMA", "", False), + ("awesome-go", "README.md", "md", "awesome-go", "SISTEMA", "", False), + ("awesome-python", "README.md", "md", "awesome-python", "SISTEMA", "", False), ] # markdown: - [Nombre](http...) - Descripcion ... `Lic` `Lenguaje` diff --git a/nucleo/saber/busca_cli.py b/nucleo/saber/busca_cli.py new file mode 100755 index 0000000..5c82123 --- /dev/null +++ b/nucleo/saber/busca_cli.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +"""Busca en el indice del RAG desde la linea de comandos. + + busca_cli.py "como saco una shell reversa" + busca_cli.py --n 3 "gestor de contraseñas autoalojado" + busca_cli.py --json "kerberoasting" + +Es el mismo motor que usa el cerebro del naranja (saber.busca), expuesto para que +lo llame cualquiera: un script, o un agente por su herramienta de terminal. Asi el +carril verde (Hermes) puede consultar tus apuntes sin duplicar el RAG. +""" +import argparse +import json +import os +import sys + +AQUI = os.path.dirname(os.path.abspath(__file__)) +NUCLEO = os.path.dirname(AQUI) +if NUCLEO not in sys.path: + sys.path.insert(0, NUCLEO) + + +def main(): + p = argparse.ArgumentParser(description="Busca en los apuntes indexados (RAG)") + p.add_argument("consulta", nargs="+", help="lo que quieres buscar") + p.add_argument("--n", type=int, default=5, help="cuantos resultados (def. 5)") + p.add_argument("--json", action="store_true", help="salida JSON en vez de texto") + a = p.parse_args() + + from saber import busca + if not busca.disponible(): + print("no hay indice: corre nucleo/saber/indexa.py", file=sys.stderr) + return 1 + + consulta = " ".join(a.consulta) + res = busca.busca(consulta, cuantos=a.n) + + if a.json: + salida = [{"herramienta": r.get("herramienta"), + "fuente": r.get("fuente") or r.get("perfil"), + "fichero": r.get("fichero"), + "texto": r.get("texto", "")[:600]} for r in res] + print(json.dumps(salida, ensure_ascii=False, indent=2)) + return 0 + + if not res: + print("sin resultados") + return 0 + for i, r in enumerate(res, 1): + fuente = r.get("fuente") or r.get("perfil") or "" + cabecera = f"{i}. {r.get('herramienta', '?')}" + if fuente: + cabecera += f" ({fuente})" + print(cabecera) + print(" " + r.get("texto", "").strip().replace("\n", "\n ")[:500]) + print() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/nucleo/saber/enriquece/eval_set.jsonl b/nucleo/saber/enriquece/eval_set.jsonl index ac9d453..937a58e 100644 --- a/nucleo/saber/enriquece/eval_set.jsonl +++ b/nucleo/saber/enriquece/eval_set.jsonl @@ -43,3 +43,15 @@ {"pregunta": "como escaneo redes wifi y capturo handshakes", "espera": ["airodump", "aircrack", "handshake", "wpa", "airgeddon"]} {"pregunta": "usar crackmapexec para barrer una red windows", "espera": ["crackmapexec", "cme ", "smb", "--shares", "spray"]} {"pregunta": "leer un hash ntlm y reutilizarlo por la red", "espera": ["ntlm", "hash", "relay", "responder", "ntlmrelayx"]} +{"pregunta": "alternativa self-hosted a google analytics", "espera": ["analytic", "plausible", "matomo", "goatcounter"]} +{"pregunta": "servidor multimedia para ver peliculas en casa", "espera": ["jellyfin", "plex", "streaming", "media"]} +{"pregunta": "gestor de contraseñas autoalojado", "espera": ["password", "vaultwarden", "bitwarden", "contrasen"]} +{"pregunta": "herramienta de linea de comandos para procesar json", "espera": ["jq", "jp", "fx", "json"]} +{"pregunta": "monitorizar los recursos del sistema en linux", "espera": ["htop", "netdata", "monitor", "glances"]} +{"pregunta": "framework de plugins para la shell zsh", "espera": ["zsh", "oh-my-zsh", "prezto", "shell"]} +{"pregunta": "agente de inteligencia artificial para pentesting automatico", "espera": ["strix", "pentest", "vulnerab"]} +{"pregunta": "programa para modelado 3d y renderizado", "espera": ["blender", "3d"]} +{"pregunta": "software para grabar y mezclar audio multipista", "espera": ["ardour", "audio"]} +{"pregunta": "herramienta para automatizar el hogar domotica", "espera": ["home", "iot", "domot", "assistant"]} +{"pregunta": "distribucion de linux para privacidad", "espera": ["tails", "privac", "linux"]} +{"pregunta": "escaner de vulnerabilidades web", "espera": ["nuclei", "nikto", "sqlmap", "scanner", "vulnerab"]} diff --git a/nucleo/saber/enriquece/reentrena_noche.sh b/nucleo/saber/enriquece/reentrena_noche.sh new file mode 100755 index 0000000..1658a97 --- /dev/null +++ b/nucleo/saber/enriquece/reentrena_noche.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Reentrenamiento nocturno AMPLIADO del RAG. Encadena las fases y las deja +# corriendo horas, sin tocar gitea. Cada fase es reanudable por su cuenta. +# +# ./reentrena_noche.sh +# +# Fases: +# 1. Espera a que termine cualquier reindex en curso. +# 2. Sintesis FRESCA de todo el contenido nuevo (conocimiento + catalogo + +# apps instaladas + docs de strix) -> preguntas-gancho. +# 3. Reindex personal con los ganchos horneados. +# 4. Evaluacion hit@k (numeros). +# 5. Refresco de COFRE: cosecha + sintesis de los apuntes. +# 6. Reindex final + evaluacion. +# +# Todo el ruido va al log; a la salida estandar solo hitos con hora. +set -uo pipefail + +AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd) +NUCLEO=$(cd "$AQUI/../.." && pwd) +PY="$NUCLEO/venv/bin/python" +[ -x "$PY" ] || PY=python3 +DATOS="$NUCLEO/datos" +LOG="${1:-$DATOS/reentrena_noche.log}" + +hora() { date +%H:%M:%S; } +hito() { echo "[$(hora)] $*" | tee -a "$LOG"; } + +cd "$NUCLEO" +hito "=== ARRANCA reentrenamiento nocturno ampliado ===" + +# --- Fase 1: no competir con un reindex en curso --- +while pgrep -f "saber.indexa" | grep -qv $$ 2>/dev/null; do + sleep 20 +done +hito "fase 1 ok: no hay reindex compitiendo" + +# --- Fase 2: sintesis fresca del contenido nuevo --- +hito "fase 2: sintesis del catalogo+nuevo (fresca, puede tardar 1-2 h)" +rm -f "$DATOS/sintesis-catalogo.jsonl" "$DATOS/sintesis-catalogo.jsonl.hecho" +"$PY" -m saber.enriquece.sintetiza_catalogo >>"$LOG" 2>&1 +hito "fase 2 ok: $(wc -l < "$DATOS/sintesis-catalogo.jsonl" 2>/dev/null) preguntas-gancho nuevas" + +# --- Fase 3: reindex con los ganchos --- +hito "fase 3: reindex personal con ganchos" +"$PY" -m saber.indexa >>"$LOG" 2>&1 +hito "fase 3 ok: indice reconstruido" + +# --- Fase 4: evaluacion --- +hito "fase 4: evaluacion hit@k" +"$PY" -m saber.enriquece.evalua "$DATOS/saber.jsonl" "$DATOS/saber.jsonl" 2>>"$LOG" \ + | grep -iE "hit@|sim" | head -3 | tee -a "$LOG" + +# --- Fase 5: refresco de COFRE (cosecha + sintesis) --- +if [ "${SIN_COFRE:-0}" = "1" ]; then + hito "fase 5: OMITIDA (SIN_COFRE=1, el refresco de COFRE ya se hizo)" +else + hito "fase 5: refresco de COFRE (cosecha + sintesis, largo)" + "$PY" -m saber.enriquece.cosecha >>"$LOG" 2>&1 && hito " cosecha ok" || hito " cosecha fallo (sigo)" + rm -f "$DATOS/sintesis.jsonl.hecho" + "$PY" -m saber.enriquece.sintetiza >>"$LOG" 2>&1 && hito " sintesis COFRE ok" || hito " sintesis COFRE fallo (sigo)" +fi + +# --- Fase 6: reindex final + evaluacion --- +hito "fase 6: reindex final" +"$PY" -m saber.indexa >>"$LOG" 2>&1 +hito "fase 6: evaluacion final" +"$PY" -m saber.enriquece.evalua "$DATOS/saber.jsonl" "$DATOS/saber.jsonl" 2>>"$LOG" \ + | grep -iE "hit@|sim" | head -3 | tee -a "$LOG" + +hito "=== FIN del reentrenamiento nocturno ===" diff --git a/nucleo/saber/enriquece/sintetiza_catalogo.py b/nucleo/saber/enriquece/sintetiza_catalogo.py index 8f2f537..9e7fa39 100644 --- a/nucleo/saber/enriquece/sintetiza_catalogo.py +++ b/nucleo/saber/enriquece/sintetiza_catalogo.py @@ -61,6 +61,14 @@ def _fragmentos(): frags.append(e) for e in awesome.entradas(): # catalogo: ya trae `publico` frags.append(e) + for e in indexa._instalado(): # apps de esta maquina: personal + e = dict(e) + e["publico"] = False + frags.append(e) + for e in indexa._docs_extra(): # docs de strix, etc.: personal + e = dict(e) + e["publico"] = False + frags.append(e) frags.sort(key=lambda d: (0 if d.get("publico") else 1, 0 if d.get("tipo") == "metodologia" else 1)) return frags diff --git a/nucleo/saber/indexa.py b/nucleo/saber/indexa.py index 2b059b4..bd9da6e 100755 --- a/nucleo/saber/indexa.py +++ b/nucleo/saber/indexa.py @@ -200,6 +200,57 @@ def _ganchos(solo_publico=False): return ganchos +def _instalado(): + """Las aplicaciones instaladas en esta maquina (instalado.py). Personal.""" + try: + try: + from saber import instalado + except ImportError: + import instalado + except ImportError: + return [] + fragmentos = [] + for e in instalado.entradas(): + e.pop("publico", None) + fragmentos.append(e) + return fragmentos + + +# Repos de ~/COFRE/CODERS cuyos docs (markdown) vale la pena indexar aunque no +# sean un perfil de COFRE. Personal: son herramientas de terceros clonadas aqui. +DOCS_REPOS = [ + ("strix", "PENTESTERS", ["README.md", "AGENTS.md", "docs", "benchmarks"]), +] + + +def _docs_extra(): + """Docs markdown de herramientas clonadas en CODERS (strix, etc.). Personal.""" + coders = os.path.join(COFRE, "CODERS") + fragmentos = [] + for nombre, perfil, rutas in DOCS_REPOS: + base = os.path.join(coders, nombre) + if not os.path.isdir(base): + continue + ficheros = [] + for r in rutas: + p = os.path.join(base, r) + if os.path.isfile(p) and p.endswith(".md"): + ficheros.append(p) + elif os.path.isdir(p): + for raiz, dirs, fs in os.walk(p): + if IGNORA.search(raiz): + dirs[:] = [] + continue + ficheros += [os.path.join(raiz, f) for f in fs if f.endswith(".md")] + for doc in ficheros: + for trozo in trocea(doc): + fragmentos.append({ + "tipo": "metodologia", "herramienta": nombre, "perfil": perfil, + "tema": nombre, "fichero": os.path.relpath(doc, coders), + "texto": trozo}) + return fragmentos + + def _conocimiento(): """El pack de metodologia y manuales que viene con el repo (conocimiento/).""" fragmentos = [] @@ -325,14 +376,24 @@ def construye(solo_cuenta=False, solo_cofre=False): if catalogo: print(f" CATALOGO {len(catalogo):5d} fragmentos (awesome lists)") + instalado = _instalado() + if instalado: + print(f" INSTALADO {len(instalado):5d} apps/paquetes de esta maquina") + + docs_extra = _docs_extra() + if docs_extra: + print(f" DOCS-CODERS {len(docs_extra):5d} fragmentos (strix, etc.)") + ganchos = _ganchos(solo_publico=False) if ganchos: print(f" GANCHOS {len(ganchos):5d} preguntas sintetizadas") - todo = fichas + fragmentos + sistema + conocimiento + catalogo + ganchos + todo = (fichas + fragmentos + sistema + conocimiento + catalogo + + instalado + docs_extra + ganchos) print(f"\n total: {len(fichas)} fichas + {len(fragmentos)} apuntes + " f"{len(sistema)} sistema + {len(conocimiento)} conocimiento + " - f"{len(catalogo)} catalogo + {len(ganchos)} ganchos = {len(todo)} entradas") + f"{len(catalogo)} catalogo + {len(instalado)} instalado + " + f"{len(docs_extra)} docs + {len(ganchos)} ganchos = {len(todo)} entradas") if solo_cuenta: return 0 diff --git a/nucleo/saber/instalado.py b/nucleo/saber/instalado.py new file mode 100755 index 0000000..3d4aa06 --- /dev/null +++ b/nucleo/saber/instalado.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Indexa las aplicaciones instaladas en ESTA maquina: que hay y para que sirve. + + ./instalado.py di cuantas entradas saldrian + ./instalado.py --volcado imprime unas cuantas de muestra + +Dos fuentes, ambas locales: + 1. Los .desktop (/usr/share/applications, ~/.local, flatpak): las apps que el + usuario ve y lanza, con su nombre, para que sirven (Comment) y el comando. + 2. Los paquetes que el usuario instalo a mano (apt-mark showmanual), con su + descripcion de una linea. No las dependencias: eso es ruido. + +Es privado por definicion —dice que software tiene esta maquina—, asi que +indexa.py lo mete SOLO en el indice personal, nunca en el publico ni en gitea. +""" +import glob +import os +import re +import subprocess +import sys + +DIRS_DESKTOP = [ + "/usr/share/applications", + os.path.expanduser("~/.local/share/applications"), + "/var/lib/flatpak/exports/share/applications", + os.path.expanduser("~/.local/share/flatpak/exports/share/applications"), + "/var/lib/snapd/desktop/applications", +] + + +def _campo(texto, clave): + m = re.search(r"^%s=(.+)$" % re.escape(clave), texto, re.M) + return m.group(1).strip() if m else "" + + +def _apps_desktop(): + vistos = set() + for d in DIRS_DESKTOP: + for ruta in sorted(glob.glob(os.path.join(d, "*.desktop"))): + base = os.path.basename(ruta) + if base in vistos: + continue + vistos.add(base) + try: + with open(ruta, encoding="utf-8", errors="ignore") as f: + txt = f.read() + except OSError: + continue + if _campo(txt, "NoDisplay").lower() == "true": + continue + nombre = _campo(txt, "Name") + if not nombre: + continue + comentario = _campo(txt, "Comment") or _campo(txt, "GenericName") + categorias = _campo(txt, "Categories").replace(";", ", ").strip(", ") + exe = re.sub(r"%[a-zA-Z]", "", _campo(txt, "Exec")).strip() + cmd = exe.split()[0] if exe else "" + partes = [f"{nombre}"] + if comentario: + partes.append(f"— {comentario}") + if cmd: + partes.append(f"(se lanza con: {cmd})") + if categorias: + partes.append(f"[{categorias}]") + yield { + "tipo": "instalado", "herramienta": nombre, "perfil": "SISTEMA", + "publico": False, "tema": categorias.lower(), + "fichero": f"app instalada: {base}", + "texto": "Aplicacion instalada: " + " ".join(partes), + } + + +def _paquetes_manuales(): + """Los paquetes apt que instalo el usuario (no dependencias), con su resumen.""" + try: + man = subprocess.run(["apt-mark", "showmanual"], capture_output=True, + text=True, timeout=30).stdout.split() + except (OSError, subprocess.SubprocessError): + return + if not man: + return + # resumen de cada uno en un solo dpkg-query (rapido) + try: + salida = subprocess.run( + ["dpkg-query", "-W", "-f=${Package}\t${binary:Summary}\n", *man], + capture_output=True, text=True, timeout=60).stdout + except (OSError, subprocess.SubprocessError): + return + lineas = [] + for l in salida.splitlines(): + if "\t" not in l: + continue + pkg, resumen = l.split("\t", 1) + if resumen.strip(): + lineas.append(f"{pkg}: {resumen.strip()}") + # se agrupan en fragmentos (no una entrada por paquete: son cientos) + buffer = "Paquetes que el usuario instalo a mano en esta maquina:\n" + for ln in lineas: + if len(buffer) + len(ln) > 1100 and buffer: + yield _pkg_entry(buffer) + buffer = "Paquetes instalados (cont.):\n" + buffer += "- " + ln + "\n" + if buffer.strip(): + yield _pkg_entry(buffer) + + +def _pkg_entry(texto): + return {"tipo": "instalado", "herramienta": "paquetes apt", "perfil": "SISTEMA", + "publico": False, "tema": "paquetes", "fichero": "apt-mark showmanual", + "texto": texto.strip()} + + +def entradas(): + yield from _apps_desktop() + yield from _paquetes_manuales() + + +def main(): + import argparse + p = argparse.ArgumentParser() + p.add_argument("--volcado", action="store_true") + a = p.parse_args() + apps = pkgs = 0 + muestras = [] + for e in entradas(): + if e["herramienta"] == "paquetes apt": + pkgs += 1 + else: + apps += 1 + if len(muestras) < 6: + muestras.append(e) + print(f" {apps} apps (.desktop) + {pkgs} fragmentos de paquetes apt") + if a.volcado: + for e in muestras: + print(" -", e["texto"][:160].replace("\n", " ")) + return 0 + + +if __name__ == "__main__": + sys.exit(main())