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:
sito 2026-08-16 15:28:31 +02:00
commit 8e4bc8ad94
125 changed files with 25033 additions and 0 deletions

0
nucleo/saber/__init__.py Normal file
View file

159
nucleo/saber/busca.py Normal file
View file

@ -0,0 +1,159 @@
"""Buscar en el saber de COFRE lo que hace falta para contestar una pregunta.
Esto es la mitad "recuperar" de RAG: dada una pregunta, devuelve los fragmentos
de los apuntes que mas se le parecen, para pasarselos al cerebro. El cerebro
construye la respuesta desde ESE texto, no desde lo que recuerde, que es lo que
evita que se invente flags.
## Por que se carga una vez y se guarda
El indice son 2.866 vectores. Cargarlo y encodear la pregunta cuesta, pero solo
la primera vez: el modelo y los vectores se quedan en memoria. Una consulta
despues es un producto escalar contra 2.866 filas, milisegundos.
## Por que no una base de datos vectorial
FAISS, Chroma y compañia son para millones de vectores. Con 2.866, numpy y un
producto escalar es mas rapido de arrancar, no añade una dependencia pesada, y
el indice es un jsonl que se puede abrir y leer con los ojos.
"""
import json
import os
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
INDICE = os.path.join(AQUI, "datos", "saber.jsonl")
_modelo = None
_entradas = None
_matriz = None
def _carga():
global _modelo, _entradas, _matriz
if _entradas is not None:
return _entradas is not False
if not os.path.exists(INDICE):
_entradas = False
return False
import numpy as np
from model2vec import StaticModel
entradas, vectores = [], []
with open(INDICE, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
v = d.pop("v", None)
if v is None:
continue
entradas.append(d)
vectores.append(v)
_entradas = entradas
_matriz = np.array(vectores, dtype="float32")
# normalizar una vez: asi la similitud es un simple producto escalar
normas = np.linalg.norm(_matriz, axis=1, keepdims=True)
_matriz = _matriz / np.clip(normas, 1e-9, None)
_modelo = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
return True
def _norm(t: str) -> str:
import re
import unicodedata
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return re.sub(r"[^a-z0-9 ]", " ", t)
# El texto normalizado de cada entrada y su nombre, cacheados para el bono.
_texto_norm = None
_nombre_norm = None
def _bono_palabras(palabras):
"""Un vector de bonos, uno por entrada, segun cuantas palabras compartan."""
import numpy as np
global _texto_norm, _nombre_norm
if _texto_norm is None:
_texto_norm = [set(_norm(e["texto"]).split()) for e in _entradas]
_nombre_norm = [_norm(e.get("herramienta", "")) for e in _entradas]
bono = np.zeros(len(_entradas), dtype="float32")
for i, (palabras_e, nombre) in enumerate(zip(_texto_norm, _nombre_norm)):
comunes = len(palabras & palabras_e)
b = 0.08 * comunes # cada palabra compartida suma
if nombre and nombre in palabras: # y nombrar la herramienta, mucho
b += 0.5
bono[i] = b
return bono
def busca(pregunta: str, cuantos: int = 5, perfil: str = None) -> list:
"""Los fragmentos mas parecidos a la pregunta, mejor primero.
Cada uno lleva su similitud (0-1), de que herramienta y fichero viene, y el
texto. perfil filtra por carpeta de COFRE si se quiere acotar.
"""
if not _carga() or not pregunta.strip():
return []
import numpy as np
q = _modelo.encode([pregunta])[0]
q = q / max(float(np.linalg.norm(q)), 1e-9)
sim = _matriz @ q
# Impulso por palabras exactas (busqueda hibrida).
#
# Los embeddings estaticos fallan cuando la pregunta va en castellano y el
# texto en ingles —"cambiar permisos" no se parece a "change file mode
# bits"—. Pero si la pregunta NOMBRA la herramienta ("como uso chmod") o
# comparte palabras con el texto, eso es una señal fuerte que la similitud
# semantica sola no aprovecha. Se suma un bono por cada palabra de la
# pregunta que aparezca, y uno grande si coincide el nombre de la
# herramienta: asi "usa nmap para..." lleva nmap al primer puesto.
palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
if palabras:
bono = _bono_palabras(palabras)
sim = sim + bono
orden = np.argsort(-sim)[: cuantos * 6]
salida, vistos = [], set()
for i in orden:
e = _entradas[i]
if perfil and e.get("perfil") != perfil:
continue
# sin duplicados: OSCP_APUNTES es copia de los apuntes de primer nivel.
# Se comparan los primeros 120 caracteres, que basta para reconocerlos.
firma = e["texto"][:120]
if firma in vistos:
continue
vistos.add(firma)
salida.append({**e, "sim": round(float(sim[i]), 3)})
if len(salida) >= cuantos:
break
return salida
def contexto(pregunta: str, cuantos: int = 5, minimo: float = 0.35) -> str:
"""Lo mismo, pero ya montado como texto para meterle al cerebro.
Se corta por debajo de `minimo` de similitud: pasarle al modelo fragmentos
que no vienen a cuento es lo que le hace mezclar herramientas que no tienen
nada que ver. Mejor darle poco y bueno que mucho y ruidoso.
"""
trozos = [t for t in busca(pregunta, cuantos) if t["sim"] >= minimo]
if not trozos:
return ""
lineas = ["De los apuntes de COFRE del usuario:\n"]
for t in trozos:
fuente = t.get("fichero") or f"{t['perfil']}/{t['herramienta']}"
lineas.append(f"--- {fuente} ---\n{t['texto']}\n")
return "\n".join(lineas)
def disponible() -> bool:
return os.path.exists(INDICE)

View file

@ -0,0 +1,110 @@
# Enriquecer el RAG a nivel de pentesting profesional
El RAG del naranja ya funcionaba, pero se dejaba fuera lo mejor que hay en el
disco. Medido antes de empezar: **de las 72 notas del `OSCP_Vault` —la
metodologia OSCP escrita a mano, con comandos reales— había 0 en el índice.**
`indexa.py` las descartaba por dos motivos a la vez: el nombre (`SQL
injection.md` no lleva "notas" ni "guia") y la profundidad (viven a 5 niveles).
Este pipeline las rescata, multiplica su recuperabilidad con preguntas
generadas, y **mide** que el resultado es mejor antes de tocar nada.
## Correrlo
```bash
./correr_noche.sh # las cuatro fases, y decide si promociona
./correr_noche.sh --sin-promo # igual, pero deja el vivo intacto
```
Es reanudable: cada fase se salta si ya está hecha, así que si se corta,
relanzarlo continúa. La fase larga (síntesis) es reanudable fragmento a
fragmento.
## Las cuatro fases
| | Script | Qué hace | Coste |
|---|---|---|---|
| 1 | `cosecha.py` | rescata la metodología de COFRE que faltaba | segundos, CPU |
| 2 | `sintetiza.py` | preguntas en castellano por cada fragmento | **horas**, modelo local |
| 3 | `reindexa.py` | une todo y calcula vectores → `saber.jsonl.nuevo` | minutos, CPU |
| 4 | `evalua.py` | mide recuperación vivo vs candidato | segundos |
### 1 · Cosecha
Donde `indexa.py` es prudente (solo ficheros que *parecen* documentación, 3
niveles), esto es agresivo con el oro: las carpetas de metodología (OSCP vaults,
apuntes, cheatsheets) enteras, sin límite de profundidad. Fuera de ahí pone un
**tope por herramienta**: si un repo tiene más de 25 documentos, es una base de
datos (exploitdb son 29.925 ficheros), no unos apuntes, y solo se coge su
README. Así el oro no se ahoga en ruido.
### 2 · Síntesis — indexación multi-representación
El punto flaco de los embeddings estáticos: "cómo saco una shell reversa" no se
parece vectorialmente a `bash -i >& /dev/tcp/...`. La solución es **embeber la
pregunta y devolver el fragmento**. Por cada fragmento, el modelo local escribe
las preguntas que responde; se indexa la pregunta, pero lo que se le muestra al
cerebro es el fragmento **literal**.
Clave: el modelo **solo escribe preguntas, nunca reescribe comandos**. Si
inventa una pregunta rara, esa entrada recupera peor y ya está; no corrompe una
respuesta. Y el fragmento crudo sigue en el índice por su lado.
### 3 · Reindexado
Une el índice vivo (comandos Linux, glosario, fichas), la cosecha y la síntesis;
deduplica por prefijo; calcula los vectores con el mismo `model2vec` de siempre.
Escribe a `saber.jsonl.nuevo`. **No toca el vivo.** `busca.py` no cambia.
### 4 · Evaluación
`eval_set.jsonl` son ~45 preguntas de pentesting con las palabras que un
fragmento correcto debe contener. Mide `hit@1`, `hit@5` y similitud media, del
índice vivo contra el candidato. Determinista y reproducible, sin juez-LLM.
## La decisión, y por qué es segura
El índice vivo **solo se sustituye si la evaluación dice que el candidato es
mejor**, y antes se guarda `saber.jsonl.antes-<fecha>`. Si empeora, se queda el
vivo y el candidato espera revisión. Siempre reversible con un `cp`.
## Ficheros que genera (en `../../datos/`)
cosecha.jsonl la metodología rescatada, sin vectores
sintesis.jsonl las preguntas generadas (+ .hecho, el marcador)
saber.jsonl.nuevo el índice candidato, con vectores
informe_rag.txt los números de la evaluación
noche_rag.log el registro de la noche
saber.jsonl.antes-* copia del índice anterior, si se promocionó
## Iteraciones posteriores a la primera noche
- **Troceado que no tira comandos cortos** (`cosecha.py`). El troceado por
encabezado descartaba toda sección de menos de 60 caracteres, y eso perdía
el oro: `## Detección time-based` + `' AND SLEEP(5)-- -` son 50. Ahora una
sección corta se pega a la siguiente y cada fragmento lleva delante el título
de la nota. `SLEEP` pasó de 0 a 13 apariciones en el índice; hit@1 80→82 %.
El salto en la métrica es pequeño porque las preguntas que quedan son
cross-lingual, y de eso no salva el troceado.
- **`experimento_embedder.py`** — mide, sin tocar nada, si un transformer (e5,
bge) supera al model2vec estático. **Resultado (16 ago): NO compensa.**
`intfloat/multilingual-e5-base` empató con el estático (hit@1 86 %, hit@5
95 % los dos): arregla las 2 preguntas que el estático fallaba, pero rompe
otras 2 distintas. A cambio pediría ~50-150 ms por consulta en CPU y una
dependencia de torch en cada búsqueda. Medido antes de cambiar → **el
estático se queda.** El trabajo de recuperación lo hace la búsqueda híbrida
(coseno + bono por palabras), no el embedder, y por eso subir el embedder no
mueve la aguja. Reproducible: `./experimento_embedder.py`.
- **Re-síntesis limpia** sobre el troceado nuevo (`SABER_BASE` en `reindexa.py`
reconstruye desde el índice original en vez de apilar, para no inflar). Da a
los comandos rescatados (SLEEP y demás) su gancho-pregunta en castellano, que
es lo que de verdad ataca las consultas cross-lingual —no el embedder—.
## Nota sobre la tarjeta
La síntesis usa el modelo local por `127.0.0.1:11434`. Fija `qwen3.5:4b-jarvis`
(el naranja, que no inventa comandos). Si dejas el TUI verde abierto, ollama
tiene que cambiar de modelo en cada petición y la noche va más lenta: para la
síntesis más rápida, cierra el verde.

View file

@ -0,0 +1,92 @@
#!/usr/bin/env bash
# El pipeline de la noche: mejora el RAG hasta nivel pentesting profesional.
#
# ./correr_noche.sh corre las cuatro fases y decide
# ./correr_noche.sh --sin-promo igual, pero NO sustituye el indice vivo
#
# ── Que hace, en orden ─────────────────────────────────────────────────────
#
# 1. COSECHA rescata la metodologia de COFRE que el indexador se dejaba
# (el OSCP_Vault entero, 0 de 72 notas estaban en el indice).
# 2. SINTETIZA por cada fragmento, preguntas en castellano que lo encuentran.
# Es la fase larga: horas, con el modelo local. Reanudable.
# 3. REINDEXA une vivo + cosecha + sintesis y calcula los vectores. Escribe
# a saber.jsonl.nuevo. NO toca el indice vivo.
# 4. EVALUA mide recuperacion del vivo contra el candidato, con numeros.
#
# ── No rompe nada ──────────────────────────────────────────────────────────
#
# El indice vivo solo se sustituye si evalua DICE que el candidato es mejor, y
# antes se guarda una copia con fecha. Si el candidato empeora, se queda el
# vivo y el candidato espera revision. Reversible siempre.
#
# Cada fase se salta si ya esta hecha, asi que relanzarlo continua donde iba.
set -uo pipefail
AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)
RAIZ=$(cd -P "$AQUI/../.." && pwd) # .../nucleo
DATOS="$RAIZ/datos"
PY="$RAIZ/venv/bin/python"
[ -x "$PY" ] || PY=python3
LOG="$DATOS/noche_rag.log"
# El modelo NO se fija aqui a proposito: sintetiza.py mira que tiene ollama
# cargado y usa ESE, para no forzar cambios en la tarjeta de 4 GB. Si el verde
# esta abierto, generara con el verde; si no, cae al naranja. Solo se fuerza si
# exportas JARVIS_MODELO tu. Aun asi, para la noche mas rapida y limpia, lo
# suyo es cerrar el TUI verde: con un solo modelo ollama no recarga nada.
PROMO=si
[ "${1:-}" = "--sin-promo" ] && PROMO=no
fecha() { date '+%Y-%m-%d %H:%M:%S'; }
fase() { echo "" | tee -a "$LOG"; echo "[$(fecha)] === FASE $* ===" | tee -a "$LOG"; }
echo "[$(fecha)] arranca el pipeline de la noche (modelo $JARVIS_MODELO)" | tee -a "$LOG"
# ── 1. COSECHA ─────────────────────────────────────────────────────────────
fase "1/4 COSECHA"
if [ -s "$DATOS/cosecha.jsonl" ]; then
echo " ya hecha ($(wc -l <"$DATOS/cosecha.jsonl") fragmentos), se salta" | tee -a "$LOG"
else
"$PY" "$AQUI/cosecha.py" 2>&1 | tee -a "$LOG"
fi
# ── 2. SINTETIZA (la larga) ────────────────────────────────────────────────
fase "2/4 SINTETIZA (horas; reanudable)"
# hecha del todo = el .hecho llego al ultimo fragmento
TOTAL=$(wc -l <"$DATOS/cosecha.jsonl")
HECHO=$(cat "$DATOS/sintesis.jsonl.hecho" 2>/dev/null || echo -1)
if [ "$HECHO" -ge "$((TOTAL - 1))" ] 2>/dev/null; then
echo " ya completa ($(wc -l <"$DATOS/sintesis.jsonl" 2>/dev/null || echo 0) preguntas)" | tee -a "$LOG"
else
"$PY" "$AQUI/sintetiza.py" 2>&1 | tee -a "$LOG"
fi
# ── 3. REINDEXA ────────────────────────────────────────────────────────────
fase "3/4 REINDEXA"
"$PY" "$AQUI/reindexa.py" 2>&1 | tee -a "$LOG"
# ── 4. EVALUA ──────────────────────────────────────────────────────────────
fase "4/4 EVALUA"
INFORME="$DATOS/informe_rag.txt"
"$PY" "$AQUI/evalua.py" 2>&1 | tee "$INFORME" | tee -a "$LOG"
VEREDICTO=$(grep -oE 'VEREDICTO (PROMOCIONAR|MANTENER)' "$INFORME" | awk '{print $2}' | tail -1)
# ── Decision ───────────────────────────────────────────────────────────────
fase "DECISION"
if [ "$PROMO" = no ]; then
echo " --sin-promo: dejo el candidato en saber.jsonl.nuevo sin tocar el vivo" | tee -a "$LOG"
elif [ "$VEREDICTO" = PROMOCIONAR ]; then
COPIA="$DATOS/saber.jsonl.antes-$(date +%Y%m%d-%H%M)"
cp "$DATOS/saber.jsonl" "$COPIA"
mv "$DATOS/saber.jsonl.nuevo" "$DATOS/saber.jsonl"
echo " PROMOCIONADO. El candidato es mejor y ya es el indice vivo." | tee -a "$LOG"
echo " copia del anterior: $COPIA" | tee -a "$LOG"
echo " para deshacer: cp '$COPIA' '$DATOS/saber.jsonl'" | tee -a "$LOG"
else
echo " MANTENIDO el vivo: el candidato no mejora la evaluacion." | tee -a "$LOG"
echo " candidato en saber.jsonl.nuevo e informe en informe_rag.txt para revisar." | tee -a "$LOG"
fi
echo "[$(fecha)] pipeline terminado" | tee -a "$LOG"

282
nucleo/saber/enriquece/cosecha.py Executable file
View file

@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""Cosecha la metodologia de pentesting que el indexador normal se deja fuera.
./cosecha.py recorre COFRE y escribe datos/cosecha.jsonl
./cosecha.py --cuenta dice que cosecharia, sin escribir
## Por que existe, aparte de indexa.py
`indexa.py` es conservador a proposito: solo mira ficheros cuyo NOMBRE parece
documentacion (readme, notas, guia...) y no baja mas de 3 niveles. Eso funciona
para las herramientas, pero **tira la mejor metodologia que hay en el disco**:
- El `OSCP_Vault` son 72 notas OSCP escritas a mano `SQL injection.md`,
`Reverse shell.md`, `LFI a RCE.md`, `msfvenom.md`... con comandos reales y
enlaces cruzados. Ninguna entra: el nombre no lleva "notas/guia", y ademas
viven en `OSCP_APUNTES/OSCP_Vault/02 - Explotacion web/`, a 5 niveles.
- Igual con las cheatsheets y apuntes sueltos de AD, tunneling, privesc.
Medido antes de escribir esto: **0 de 72 notas del Vault estaban en el indice.**
Este cosechador es agresivo donde el otro es prudente: en los perfiles
ofensivos coge TODO .md/.txt que no sea ruido evidente, a la profundidad que
haga falta, y marca de que TEMA es por la carpeta que lo contiene (las del Vault
van numeradas: "01 - Enumeracion", "02 - Explotacion web"...).
No pisa a indexa.py: escribe a un fichero aparte. La union y el deduplicado los
hace reindexa.py.
"""
import argparse
import json
import os
import re
import sys
import unicodedata
def _norm(t):
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return re.sub(r"[^a-z0-9 ]", " ", t)
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI)) # .../nucleo
COFRE = os.path.expanduser("~/COFRE")
SALIDA = os.path.join(RAIZ, "datos", "cosecha.jsonl")
# Los perfiles ofensivos: aqui vive el saber de pentesting. En estos se cosecha
# a lo ancho. (LINUX se queda para indexa.py, que ya lo hace bien con man pages.)
PERFILES = ["PENTESTERS", "PHISHERS", "REVERSERS", "DEFACERS", "SNEAKERS",
"HARD-WARERS", "PROTECTORS", "ZAPPERS", "AUTOMATAS"]
# Carpetas de oro: se recorren ENTERAS, sin limite de profundidad, porque su
# valor esta justo en las notas hondas.
ORO = re.compile(r"(OSCP_APUNTES|OSCP_Vault|TELMO_OSCP|RedTeam-Tools|"
r"apuntes|cheat|vault|metodolog|notas)", re.I)
# Lo que no se mira nunca: dependencias, control de versiones, binarios.
IGNORA = re.compile(
r"(^|/)(node_modules|\.git|\.obsidian|vendor|dist|build|__pycache__|"
r"\.venv|venv|site-packages|target|\.cache|\.github)(/|$)", re.I)
# Ficheros que son plantilla o licencia, no saber.
RUIDO = re.compile(r"(code_of_conduct|contributing|changelog|license|copying|"
r"pull_request|issue_template|\.min\.|package-lock)", re.I)
# Fuera de las carpetas de ORO, solo se coge lo que PARECE documentacion por el
# nombre. Es la misma idea que indexa.py, un poco mas ancha (tutorial, writeup,
# walkthrough). Sin esto entra cada .md de cada exploit.
DOC = re.compile(r"(readme|install|usage|apuntes|notas|trucos|howto|tutorial|"
r"walkthrough|writeup|write-up|guide|guia|cheat|manual|tips|"
r"metodolog|documentation|docs?)", re.I)
# Tope de ficheros por herramienta fuera del ORO. exploitdb tiene 29.925 .md:
# es una base de datos, no unos apuntes, y meterla entera ahoga el oro. Si una
# herramienta pasa de esto, se la trata como repo a granel y solo se coge su
# documentacion de primer nivel (README).
CAP_FICHEROS = 25
MIN_FRAGMENTO = 60
MAX_FRAGMENTO = 1200
MAX_PROFUNDIDAD = 4 # niveles bajo el perfil, salvo en carpetas de ORO
# Tope de tamano por fichero. Una nota de metodologia son unos pocos KB; un .txt
# de 300 MB es una wordlist (SecLists y similares llevan .txt de gigabytes) y
# leerlo entero revienta la maquina —la swap de este equipo es de 976 MiB—. Por
# encima de esto no es saber, es un diccionario, y no se lee.
MAX_BYTES = 512 * 1024
def _profundidad(ruta):
return ruta.rstrip("/").count("/")
def _tema(ruta):
"""El tema, deducido de la carpeta. Las del Vault van numeradas."""
for parte in reversed(ruta.split(os.sep)):
# "02 - Explotacion web" -> "explotacion web"
m = re.match(r"^\d{2}\s*[-.]\s*(.+)$", parte)
if m:
return m.group(1).strip().lower()
return ""
def _herramienta(ruta, perfil):
rel = os.path.relpath(ruta, os.path.join(COFRE, perfil)).split(os.sep)
return rel[0] if len(rel) > 1 else perfil
def _prefija(titulo, seccion):
"""Lleva el titulo de la nota delante de la seccion, salvo que ya lo tenga.
Sin esto, un fragmento como "## Deteccion time-based\n' AND SLEEP(5)" no
sabe de que va ¿time-based de que?. Con "(SQL injection) ..." delante, el
vector y el cerebro tienen el contexto. Se evita duplicar si el titulo ya
aparece en la cabecera de la seccion."""
if titulo and _norm(titulo) not in _norm(seccion[:80]):
return f"({titulo}) {seccion}"
return seccion
def trocea(texto):
"""En fragmentos por encabezado, fusionando los cortos y con el titulo.
Antes se partia por encabezado y se TIRABA toda seccion de menos de 60
caracteres. Eso perdia justo lo mejor: una seccion "## Deteccion time-based"
con `' AND SLEEP(5)-- -` son 50 caracteres, y es oro. Ahora una seccion
corta se PEGA a la siguiente en vez de tirarse, y cada fragmento lleva
delante el titulo de la nota para no quedar sin contexto.
"""
texto = re.sub(r"\A---\n.*?\n---\n", "", texto, flags=re.S)
m = re.search(r"^#\s+(.+)", texto, re.M)
titulo = m.group(1).strip() if m else ""
crudas = [s.strip() for s in re.split(r"\n(?=#{1,3}\s)", texto) if s.strip()]
# fusionar hacia adelante mientras la seccion sea demasiado corta
fundidas, i = [], 0
while i < len(crudas):
sec = crudas[i]
while len(sec) < MIN_FRAGMENTO and i + 1 < len(crudas):
i += 1
sec += "\n\n" + crudas[i]
fundidas.append(sec)
i += 1
# si la ultima quedo corta, se dobla sobre la anterior. Se saca primero y
# se indexa despues: fundidas.pop() en el lado derecho ya habria acortado
# la lista y fundidas[-2] se saldria de rango.
if len(fundidas) >= 2 and len(fundidas[-1]) < MIN_FRAGMENTO:
cola = fundidas.pop()
fundidas[-1] += "\n\n" + cola
for sec in fundidas:
if len(sec) < MIN_FRAGMENTO:
continue # una nota entera diminuta
if len(sec) <= MAX_FRAGMENTO:
yield _prefija(titulo, sec)
else:
buf = ""
for parrafo in re.split(r"\n\s*\n", sec):
if len(buf) + len(parrafo) > MAX_FRAGMENTO and buf:
yield _prefija(titulo, buf.strip())
buf = ""
buf += parrafo + "\n\n"
if len(buf.strip()) >= MIN_FRAGMENTO:
yield _prefija(titulo, buf.strip())
def _candidato(ruta, fich, en_oro):
"""Un fichero vale si es texto, no es ruido, no es enorme, y —fuera del
oro su nombre parece documentacion."""
if not fich.lower().endswith((".md", ".txt")):
return False
if RUIDO.search(fich):
return False
if not en_oro and not DOC.search(fich):
return False
try:
return os.path.getsize(ruta) <= MAX_BYTES
except OSError:
return False
def ficheros(perfil):
"""Los ficheros a cosechar de un perfil, con el tope por herramienta.
Se recorre cada herramienta (subcarpeta de primer nivel) por separado para
poder contar sus ficheros: si pasa del cap y no es oro, es un repo a granel
(exploitdb) y solo se coge su README, no sus 30.000 entradas.
"""
base = os.path.join(COFRE, perfil)
if not os.path.isdir(base):
return
# sueltos en la raiz del perfil (apuntes-ad-pentest.md, trucos_trampas.md)
for fich in sorted(os.listdir(base)):
ruta = os.path.join(base, fich)
if os.path.isfile(ruta) and _candidato(ruta, fich, en_oro=True):
yield ruta, True
for herr in sorted(os.listdir(base)):
raiz_h = os.path.join(base, herr)
if not os.path.isdir(raiz_h) or herr.startswith("."):
continue
candidatos = []
for raiz, dirs, fichs in os.walk(raiz_h):
if IGNORA.search(raiz):
dirs[:] = []
continue
en_oro = bool(ORO.search(raiz))
if not en_oro and _profundidad(raiz) - _profundidad(raiz_h) > MAX_PROFUNDIDAD:
dirs[:] = []
continue
for fich in sorted(fichs):
ruta = os.path.join(raiz, fich)
if _candidato(ruta, fich, en_oro):
candidatos.append((ruta, en_oro, _profundidad(ruta)))
del_oro = [c for c in candidatos if c[1]]
resto = [c for c in candidatos if not c[1]]
# el oro entra siempre y entero
for ruta, en_oro, _ in del_oro:
yield ruta, True
# el resto, con tope: si es un repo a granel, solo lo mas alto
if len(resto) > CAP_FICHEROS:
resto.sort(key=lambda c: c[2]) # los menos hondos primero
resto = resto[:CAP_FICHEROS]
for ruta, en_oro, _ in resto:
yield ruta, False
def construye(solo_cuenta=False):
salida = []
for perfil in PERFILES:
n_doc = n_frag = 0
for ruta, en_oro in ficheros(perfil):
try:
with open(ruta, encoding="utf-8", errors="ignore") as f:
texto = f.read()
except OSError:
continue
if len(texto.strip()) < MIN_FRAGMENTO:
continue
tema = _tema(ruta)
herr = _herramienta(ruta, perfil)
rel = os.path.relpath(ruta, COFRE)
hubo = False
for trozo in trocea(texto):
salida.append({
"tipo": "metodologia" if en_oro else "apunte",
"herramienta": herr, "perfil": perfil,
"fichero": rel, "tema": tema, "texto": trozo,
})
n_frag += 1
hubo = True
n_doc += hubo
print(f" {perfil:14s} {n_doc:5d} docs {n_frag:6d} fragmentos", flush=True)
oro = sum(1 for e in salida if e["tipo"] == "metodologia")
print(f"\n total: {len(salida)} fragmentos ({oro} de metodologia)", flush=True)
if solo_cuenta:
return 0
os.makedirs(os.path.dirname(SALIDA), exist_ok=True)
with open(SALIDA, "w", encoding="utf-8") as f:
for e in salida:
f.write(json.dumps(e, ensure_ascii=False) + "\n")
print(f" escrito: {SALIDA}", flush=True)
return 0
def main():
p = argparse.ArgumentParser()
p.add_argument("--cuenta", action="store_true")
a = p.parse_args()
return construye(solo_cuenta=a.cuenta)
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,45 @@
{"pregunta": "como saco una shell reversa desde bash", "espera": ["bash -i", "/dev/tcp", "reverse", "nc "]}
{"pregunta": "generar un payload de reverse shell con msfvenom", "espera": ["msfvenom", "-p ", "lhost", "payload"]}
{"pregunta": "estabilizar una shell tty para que funcione bien", "espera": ["python", "pty", "stty", "script /dev/null"]}
{"pregunta": "bypass de login por inyeccion sql", "espera": ["or '1'='1", "or 1=1", "-- -", "union select"]}
{"pregunta": "detectar sql injection a ciegas basada en tiempo", "espera": ["sleep", "waitfor", "time-based", "benchmark"]}
{"pregunta": "de una sqli a ejecucion de comandos", "espera": ["xp_cmdshell", "load_file", "into outfile", "rce"]}
{"pregunta": "explotar un local file inclusion para conseguir rce", "espera": ["lfi", "php://", "log poisoning", "/proc/self/environ", "data://"]}
{"pregunta": "probar server side template injection", "espera": ["ssti", "{{7*7", "jinja", "${", "twig"]}
{"pregunta": "payload basico de xss para robar cookie", "espera": ["<script", "document.cookie", "onerror", "alert("]}
{"pregunta": "inyeccion de comandos en un parametro web", "espera": ["command injection", ";", "| ", "$(", "`", "&&"]}
{"pregunta": "saltarme un filtro de subida de ficheros", "espera": ["file upload", "magic bytes", ".phtml", "content-type", "double extension"]}
{"pregunta": "descubrir directorios y ficheros ocultos en una web", "espera": ["gobuster", "ffuf", "feroxbuster", "dirb", "wordlist"]}
{"pregunta": "path traversal para leer etc passwd", "espera": ["../", "directory traversal", "/etc/passwd", "%2e"]}
{"pregunta": "enumerar un servidor smb", "espera": ["smbclient", "enum4linux", "smbmap", "crackmapexec", "139", "445"]}
{"pregunta": "escaneo de puertos y servicios con nmap", "espera": ["nmap", "-sv", "-sc", "-p-", "-a "]}
{"pregunta": "como paso de usuario normal a root en linux", "espera": ["privesc", "sudo -l", "suid", "linpeas", "gtfobins"]}
{"pregunta": "escalada de privilegios en windows", "espera": ["winpeas", "privesc", "seimpersonate", "potato", "token"]}
{"pregunta": "ataque dcsync para sacar hashes del dominio", "espera": ["dcsync", "secretsdump", "mimikatz", "lsadump"]}
{"pregunta": "pass the hash para autenticarme sin la contrasena", "espera": ["pass-the-hash", "pass the hash", "-hashes", "ntlm", "pth"]}
{"pregunta": "kerberoasting para sacar tickets de servicio", "espera": ["kerberoast", "getuserspns", "spn", "ticket"]}
{"pregunta": "usar bloodhound para mapear el active directory", "espera": ["bloodhound", "sharphound", "neo4j", "collector"]}
{"pregunta": "dumpear credenciales de memoria con mimikatz", "espera": ["mimikatz", "sekurlsa", "logonpasswords", "lsass"]}
{"pregunta": "usar impacket para ejecutar comandos remotos", "espera": ["impacket", "psexec", "wmiexec", "smbexec"]}
{"pregunta": "crackear un hash con john the ripper", "espera": ["john", "--wordlist", "rockyou", "--format"]}
{"pregunta": "crackear hashes con hashcat", "espera": ["hashcat", "-m ", "rockyou", "-a "]}
{"pregunta": "ataque de fuerza bruta a un login ssh", "espera": ["hydra", "medusa", "-l ", "-p ", "ssh"]}
{"pregunta": "transferir un fichero a la maquina victima", "espera": ["scp", "http.server", "certutil", "wget", "curl", "impacket-smbserver"]}
{"pregunta": "montar un tunel para pivotar en la red interna", "espera": ["chisel", "ligolo", "ssh -l", "proxychains", "socks"]}
{"pregunta": "pivoting con ssh port forwarding", "espera": ["ssh -l", "ssh -r", "-d ", "port forward", "dynamic"]}
{"pregunta": "usar metasploit para explotar un servicio", "espera": ["metasploit", "msfconsole", "use exploit", "set rhost"]}
{"pregunta": "escanear vulnerabilidades web con nuclei", "espera": ["nuclei", "-t ", "template", "-u "]}
{"pregunta": "escanear un wordpress en busca de fallos", "espera": ["wpscan", "--enumerate", "--url", "plugin"]}
{"pregunta": "buscar exploits publicos de un servicio", "espera": ["searchsploit", "exploit-db", "exploitdb"]}
{"pregunta": "evadir un antivirus o edr al ejecutar payload", "espera": ["av evasion", "amsi", "bypass", "obfusc", "edr"]}
{"pregunta": "hacer un buffer overflow clasico", "espera": ["buffer overflow", "eip", "pattern_create", "badchars", "jmp esp"]}
{"pregunta": "filtrado de salida y como saltarmelo", "espera": ["egress", "filtering", "puerto", "443", "outbound"]}
{"pregunta": "enumerar usuarios y recursos de un active directory", "espera": ["enum", "ldap", "rpcclient", "net user", "adperti"]}
{"pregunta": "atacar autenticacion con credenciales por defecto", "espera": ["authentication", "default", "brute", "credential", "login"]}
{"pregunta": "descubrir contenido con fuzzing de parametros", "espera": ["ffuf", "fuzz", "content discovery", "wordlist"]}
{"pregunta": "usar burp repeater e intruder para probar peticiones", "espera": ["burp", "repeater", "intruder", "proxy"]}
{"pregunta": "escaneo de red para descubrir hosts vivos", "espera": ["nmap", "-sn", "ping sweep", "netdiscover", "arp"]}
{"pregunta": "conseguir persistencia tras comprometer una maquina", "espera": ["persistenc", "cron", "scheduled task", "backdoor", "registry"]}
{"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"]}

173
nucleo/saber/enriquece/evalua.py Executable file
View file

@ -0,0 +1,173 @@
#!/usr/bin/env python3
"""Mide si el indice candidato recupera mejor que el vivo. Con numeros.
./evalua.py compara vivo contra saber.jsonl.nuevo
./evalua.py A.jsonl B.jsonl compara dos indices cualesquiera
## Que mide
Un conjunto de preguntas de pentesting (eval_set.jsonl), cada una con las
palabras que un fragmento CORRECTO tiene que contener (un comando, una
herramienta, un patron). Para cada indice se busca la pregunta y se mira si
alguno de los 5 primeros resultados contiene lo esperado.
hit@1 la pregunta se resuelve con el PRIMER resultado
hit@5 se resuelve con alguno de los cinco primeros
sim similitud media del mejor resultado
La busqueda replica la de busca.py (coseno + bono por palabras) para que la
comparacion sea justa: lo unico que cambia entre las dos columnas es el indice.
## Por que asi y no "le pregunte al modelo si estaba bien"
Un juez-LLM sobre una tarjeta de 4 GB es lento y ruidoso. Esto es determinista,
reproducible y honesto: la palabra esperada esta o no esta en el texto
recuperado. Es una cota inferior de la calidad un fragmento puede ser util sin
contener el literal pero sirve para COMPARAR dos indices, que es lo que decide
si se promociona el candidato.
"""
import json
import os
import re
import sys
import unicodedata
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI))
DATOS = os.path.join(RAIZ, "datos")
VIVO = os.path.join(DATOS, "saber.jsonl")
NUEVO = os.path.join(DATOS, "saber.jsonl.nuevo")
EVAL = os.path.join(AQUI, "eval_set.jsonl")
MODELO = "minishlab/potion-multilingual-128M"
_modelo = None
def _norm(t):
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return re.sub(r"[^a-z0-9 ]", " ", t)
def _carga_modelo():
global _modelo
if _modelo is None:
from model2vec import StaticModel
_modelo = StaticModel.from_pretrained(MODELO)
return _modelo
def _carga_indice(ruta):
import numpy as np
entradas, vs = [], []
with open(ruta, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
v = d.pop("v", None)
if v is None:
continue
entradas.append(d)
vs.append(v)
M = np.array(vs, dtype="float32")
M /= np.clip(np.linalg.norm(M, axis=1, keepdims=True), 1e-9, None)
txt = [set(_norm(e["texto"]).split()) for e in entradas]
nom = [_norm(e.get("herramienta", "")) for e in entradas]
return entradas, M, txt, nom
def _busca(indice, pregunta, cuantos=5):
import numpy as np
entradas, M, txt, nom = indice
q = _carga_modelo().encode([pregunta])[0]
q = q / max(float(np.linalg.norm(q)), 1e-9)
sim = M @ q
palabras = {w for w in _norm(pregunta).split() if len(w) > 3}
if palabras:
bono = np.zeros(len(entradas), dtype="float32")
for i in range(len(entradas)):
b = 0.08 * len(palabras & txt[i])
if nom[i] and nom[i] in palabras:
b += 0.5
bono[i] = b
sim = sim + bono
orden = np.argsort(-sim)[: cuantos * 4]
salida, vistos = [], set()
for i in orden:
firma = entradas[i]["texto"][:120]
if firma in vistos:
continue
vistos.add(firma)
salida.append((float(sim[i]), entradas[i]["texto"]))
if len(salida) >= cuantos:
break
return salida
def evalua(ruta):
indice = _carga_indice(ruta)
casos = [json.loads(l) for l in open(EVAL, encoding="utf-8") if l.strip()]
hit1 = hit5 = 0
simtop = 0.0
fallos = []
for c in casos:
esperado = [_norm(k).strip() for k in c["espera"]]
res = _busca(indice, c["pregunta"])
simtop += res[0][0] if res else 0.0
textos = [_norm(t) for _, t in res]
acierta = lambda t: any(k and k in t for k in esperado)
if res and acierta(textos[0]):
hit1 += 1
if any(acierta(t) for t in textos):
hit5 += 1
else:
fallos.append(c["pregunta"])
n = len(casos)
return {"n": n, "hit1": hit1, "hit5": hit5,
"sim": simtop / n if n else 0.0, "fallos": fallos}
def _pinta(nombre, r):
print(f" {nombre}")
print(f" hit@1 {r['hit1']:3d}/{r['n']} ({100*r['hit1']//r['n']}%)")
print(f" hit@5 {r['hit5']:3d}/{r['n']} ({100*r['hit5']//r['n']}%)")
print(f" sim {r['sim']:.3f}")
def main():
a = sys.argv[1] if len(sys.argv) > 1 else VIVO
b = sys.argv[2] if len(sys.argv) > 2 else NUEVO
if not os.path.exists(b):
print(f" no existe el candidato: {b}")
return 1
print("Evaluando el indice VIVO...", flush=True)
rv = evalua(a)
print("Evaluando el indice CANDIDATO...", flush=True)
rn = evalua(b)
print("\n" + "=" * 44)
_pinta("VIVO " + os.path.basename(a), rv)
print()
_pinta("CANDIDATO " + os.path.basename(b), rn)
print("=" * 44)
mejora5 = rn["hit5"] - rv["hit5"]
mejora1 = rn["hit1"] - rv["hit1"]
print(f"\n hit@5: {mejora5:+d} hit@1: {mejora1:+d}")
# el veredicto que lee correr_noche.sh
promociona = rn["hit5"] >= rv["hit5"] and rn["hit1"] >= rv["hit1"] - 1
print(f" VEREDICTO {'PROMOCIONAR' if promociona else 'MANTENER'}")
if rn["fallos"]:
print(f"\n el candidato aun no resuelve {len(rn['fallos'])}:")
for q in rn["fallos"][:12]:
print(f" · {q}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,142 @@
#!/usr/bin/env python3
"""Experimento: mide si un embedder transformer supera al model2vec estatico.
./experimento_embedder.py prueba intfloat/multilingual-e5-base
./experimento_embedder.py BAAI/bge-m3 prueba otro modelo
## Por que este experimento
model2vec (potion-multilingual-128M) es un embedder ESTATICO: rapidisimo (un
vector por token, precalculado, sin red neuronal en la consulta) pero flojo en
lo semantico. Su punto ciego se ve en las dos preguntas que el RAG no resuelve:
"detectar sql injection a ciegas basada en tiempo" -> no encuentra SLEEP
"descubrir directorios ocultos en una web" -> no encuentra gobuster
Las dos son cross-lingual: la pregunta va en castellano coloquial y el comando
en ingles tecnico ("time-based", "content discovery"). Un transformer de verdad
e5, bge entiende eso; el estatico no.
Este experimento NO toca nada: coge una MUESTRA del indice vivo (para que quepa
en memoria y sea rapido), la re-embebe con el transformer, y corre la misma
evaluacion. Si gana claro, merece la pena cambiar busca.py; si no, no.
## El coste que habria que pagar si se adopta
El estatico encodea la consulta en microsegundos. Un transformer en CPU tarda
~50-150 ms por consulta. Para un asistente de voz local es asumible, pero no es
gratis, y por eso se mide antes de decidir.
"""
import json
import os
import re
import sys
import unicodedata
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI))
VIVO = os.path.join(RAIZ, "datos", "saber.jsonl")
EVAL = os.path.join(AQUI, "eval_set.jsonl")
MODELO_TF = sys.argv[1] if len(sys.argv) > 1 else "intfloat/multilingual-e5-base"
# Muestra: todo lo que un caso de eval podria querer + relleno, para no cargar
# 14.000 vectores de transformer en RAM. Se cogen todas las entradas de
# metodologia y pregunta (el oro) mas una parte del resto.
MAX_ENTRADAS = 6000
def _norm(t):
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return re.sub(r"[^a-z0-9 ]", " ", t)
def _muestra():
oro, resto = [], []
with open(VIVO, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
(oro if d.get("tipo") in ("metodologia", "pregunta") else resto).append(d)
ent = oro + resto[: max(0, MAX_ENTRADAS - len(oro))]
return ent
def _eval(nombre, encode_corpus, encode_query, entradas):
import numpy as np
textos = [(e.get("indexar") or e["texto"]) for e in entradas]
M = encode_corpus(textos)
M = M / np.clip(np.linalg.norm(M, axis=1, keepdims=True), 1e-9, None)
palabras_e = [set(_norm(e["texto"]).split()) for e in entradas]
nom = [_norm(e.get("herramienta", "")) for e in entradas]
casos = [json.loads(l) for l in open(EVAL, encoding="utf-8") if l.strip()]
hit1 = hit5 = 0
fallos = []
for c in casos:
q = encode_query([c["pregunta"]])[0]
q = q / max(float(np.linalg.norm(q)), 1e-9)
sim = M @ q
pal = {w for w in _norm(c["pregunta"]).split() if len(w) > 3}
if pal:
bono = np.array([0.08 * len(pal & palabras_e[i]) +
(0.5 if nom[i] and nom[i] in pal else 0.0)
for i in range(len(entradas))], dtype="float32")
sim = sim + bono
orden = np.argsort(-sim)[:5]
esperado = [_norm(k).strip() for k in c["espera"]]
tops = [_norm(entradas[i]["texto"]) for i in orden]
ok = lambda t: any(k and k in t for k in esperado)
if tops and ok(tops[0]):
hit1 += 1
if any(ok(t) for t in tops):
hit5 += 1
else:
fallos.append(c["pregunta"])
n = len(casos)
print(f"\n {nombre}")
print(f" hit@1 {hit1:3d}/{n} ({100*hit1//n}%)")
print(f" hit@5 {hit5:3d}/{n} ({100*hit5//n}%)")
if fallos:
print(f" no resuelve {len(fallos)}: " + "; ".join(fallos[:6]))
return hit1, hit5
def main():
entradas = _muestra()
print(f"Muestra: {len(entradas)} entradas del indice vivo")
# 1) model2vec estatico, la referencia
from model2vec import StaticModel
est = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
enc_est = lambda xs: est.encode(xs)
h1e, h5e = _eval("model2vec (estatico, el actual)", enc_est, enc_est, entradas)
# 2) el transformer a prueba
print(f"\n cargando {MODELO_TF} (puede descargar ~1-2 GB la 1a vez)...", flush=True)
from sentence_transformers import SentenceTransformer
tf = SentenceTransformer(MODELO_TF)
# los modelos e5 piden prefijos "query:" y "passage:"
es_e5 = "e5" in MODELO_TF.lower()
enc_doc = lambda xs: tf.encode([("passage: " + x) if es_e5 else x for x in xs],
show_progress_bar=False, batch_size=32)
enc_q = lambda xs: tf.encode([("query: " + x) if es_e5 else x for x in xs],
show_progress_bar=False, batch_size=32)
h1t, h5t = _eval(f"{MODELO_TF} (transformer)", enc_doc, enc_q, entradas)
print("\n" + "=" * 44)
print(f" hit@1 estatico {h1e} -> transformer {h1t} ({h1t-h1e:+d})")
print(f" hit@5 estatico {h5e} -> transformer {h5t} ({h5t-h5e:+d})")
print("=" * 44)
if h5t > h5e or (h5t == h5e and h1t > h1e):
print(" El transformer gana. Merece plantear el cambio en busca.py")
print(" (coste: ~50-150 ms por consulta en CPU, hoy es instantaneo).")
else:
print(" El transformer NO compensa aqui. El estatico se queda.")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Construye el indice candidato uniendo lo viejo, lo cosechado y lo sintetizado.
./reindexa.py escribe datos/saber.jsonl.nuevo (NO toca el vivo)
## Que une
1. El indice vivo (datos/saber.jsonl): comandos de Linux, glosario, fichas,
servidores... todo lo que cosecha no produce. Se conserva entero.
2. cosecha.jsonl: la metodologia de pentesting que faltaba.
3. sintesis.jsonl: preguntas en castellano que apuntan a esos fragmentos.
## Indexacion multi-representacion
Cada entrada se EMBEBE por su campo `indexar` si lo tiene (las preguntas
sintetizadas), y si no por `texto` (todo lo demas). Asi una entrada de pregunta
se compara con la consulta del usuario pregunta-contra-pregunta, pero lo que se
le muestra al cerebro sigue siendo el fragmento literal de `texto`. busca.py no
cambia: solo lee `texto` y `v`.
## No destruye nada
Escribe a `saber.jsonl.nuevo`. Quien decide si sustituye al vivo es evalua.py +
correr_noche.sh, y solo si mide que es mejor, guardando antes una copia.
## Deduplicado
Por los primeros 120 caracteres del texto normalizado (para las entradas
normales) o de la pregunta (para las sintetizadas). OSCP_APUNTES duplica apuntes
de primer nivel; esto los colapsa.
"""
import json
import os
import re
import sys
import unicodedata
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI))
DATOS = os.path.join(RAIZ, "datos")
# La base son las entradas que cosecha NO produce (comandos Linux, glosario,
# fichas). Por defecto el indice vivo, pero se puede fijar con SABER_BASE al
# indice ORIGINAL para una reconstruccion limpia que no apile sintesis sobre
# sintesis y no infle el indice a cada pasada.
VIVO = os.environ.get("SABER_BASE") or os.path.join(DATOS, "saber.jsonl")
COSECHA = os.path.join(DATOS, "cosecha.jsonl")
SINTESIS = os.path.join(DATOS, "sintesis.jsonl")
NUEVO = os.path.join(DATOS, "saber.jsonl.nuevo")
MODELO = "minishlab/potion-multilingual-128M"
def _norm(t):
t = unicodedata.normalize("NFD", (t or "").lower())
t = "".join(c for c in t if unicodedata.category(c) != "Mn")
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]", " ", t)).strip()
def _lee(ruta, quita_v=False):
if not os.path.exists(ruta):
return
with open(ruta, encoding="utf-8") as f:
for l in f:
try:
d = json.loads(l)
except json.JSONDecodeError:
continue
if quita_v:
d.pop("v", None)
yield d
def main():
entradas, vistos = [], set()
fuentes = [("vivo", VIVO, True), ("cosecha", COSECHA, False),
("sintesis", SINTESIS, False)]
cuenta = {}
for nombre, ruta, quita in fuentes:
n = 0
for d in _lee(ruta, quita_v=quita):
clave_txt = d.get("indexar") or d.get("texto", "")
firma = _norm(clave_txt)[:120]
if not firma or firma in vistos:
continue
vistos.add(firma)
entradas.append(d)
n += 1
cuenta[nombre] = n
print(f" {nombre:9s} {n:6d} entradas nuevas", flush=True)
print(f"\n total tras deduplicar: {len(entradas)}", flush=True)
print(" cargando el modelo de embeddings...", flush=True)
from model2vec import StaticModel
modelo = StaticModel.from_pretrained(MODELO)
# se embebe `indexar` si existe (la pregunta), si no `texto`
a_embeber = [(e.get("indexar") or e["texto"]) for e in entradas]
print(f" calculando {len(a_embeber)} vectores...", flush=True)
vectores = modelo.encode(a_embeber, show_progress_bar=False)
with open(NUEVO, "w", encoding="utf-8") as f:
for e, v in zip(entradas, vectores):
e["v"] = [round(float(x), 5) for x in v]
f.write(json.dumps(e, ensure_ascii=False) + "\n")
print(f" escrito: {NUEVO} ({os.path.getsize(NUEVO)//1024} KB)", flush=True)
print(f" RESUMEN vivo={cuenta['vivo']} cosecha={cuenta['cosecha']} "
f"sintesis={cuenta['sintesis']} total={len(entradas)}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Genera preguntas en castellano para cada fragmento cosechado.
./sintetiza.py procesa datos/cosecha.jsonl (reanudable, horas)
./sintetiza.py --muestra 5 ensena 5 y para
## Que problema resuelve, y por que no corrompe comandos
El indice usa embeddings estaticos. Su punto flaco conocido: una pregunta
coloquial en castellano "como saco una shell reversa" no se parece
vectorialmente a un fragmento tecnico en ingles con `bash -i >& /dev/tcp/...`.
La busqueda hibrida por palabras ayuda, pero solo si coinciden literales.
La solucion es **indexacion multi-representacion**: por cada fragmento se generan
las preguntas que ese fragmento responde, y se EMBEBE LA PREGUNTA apuntando al
fragmento. Asi la consulta del usuario se compara contra otras preguntas, que es
comparar peras con peras.
Clave para no meter la pata: la respuesta que se le muestra al cerebro es el
**fragmento original, literal** (campo `texto`). El modelo local solo escribe la
PREGUNTA (campo `indexar`). Si inventa una pregunta rara, lo peor que pasa es que
esa entrada no recupere bien; **nunca corrompe un comando**, porque no reescribe
la respuesta. Y el fragmento crudo sigue en el indice por su lado (lo pone
cosecha), asi que la cobertura solo puede subir.
## El modelo
Habla con el ollama local. Usa el modelo que ya este CARGADO (mira /api/ps) para
no forzar a la tarjeta de 4 GB a cambiar de modelo con el carril verde abierto;
si no hay ninguno, carga qwen3.5:4b-jarvis. think:false qwen3.5 razona por
defecto y tardaria 20x.
## Reanudable
Un trabajo de horas no puede perder todo si se corta. Se apunta en un .hecho por
que linea de cosecha.jsonl iba, y al arrancar salta hasta ahi. La metodologia
(el oro) se procesa PRIMERO, para que si no termina la noche, lo valioso este.
"""
import argparse
import json
import os
import re
import sys
import urllib.request
AQUI = os.path.dirname(os.path.abspath(__file__))
RAIZ = os.path.dirname(os.path.dirname(AQUI))
COSECHA = os.path.join(RAIZ, "datos", "cosecha.jsonl")
SALIDA = os.path.join(RAIZ, "datos", "sintesis.jsonl")
MARCA = SALIDA + ".hecho"
ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434")
INSTRUCCION = """Eres un generador de preguntas para un buscador de apuntes de
pentesting. Te doy un fragmento de apuntes tecnicos. Devuelve de UNA a TRES
preguntas, en castellano, que una persona haria por voz y que ese fragmento
responde.
Reglas:
- Preguntas naturales y variadas, como las diria alguien: "como...", "que
comando...", "cual es la forma de...".
- NO respondas, NO copies comandos: solo las preguntas.
- Si el fragmento es ruido (un titulo suelto, una lista de enlaces), devuelve
una lista vacia.
Responde SOLO con JSON: {"preguntas": ["...", "..."]}"""
FIJO = os.environ.get("JARVIS_MODELO") # si se fija a mano, manda
RESPALDO = "qwen3.5:4b-jarvis"
def _modelo_cargado():
"""El modelo que ollama ya tiene en memoria, para no forzar un cambio."""
try:
with urllib.request.urlopen(ENDPOINT + "/api/ps", timeout=5) as r:
m = json.load(r).get("models", [])
if m:
return m[0]["name"]
except Exception:
pass
return None
def _modelo():
"""Que modelo usar AHORA. Se re-mira cada tanto en vez de fijarlo al
arrancar: en una tarjeta de 4 GB solo cabe uno, asi que si el carril verde
tiene el suyo cargado, generamos con ESE (una pregunta no necesita el ajuste
anti-alucinacion, solo la respuesta lo necesitaria y la respuesta es el
fragmento literal). Asi no se fuerza a ollama a cambiar de modelo en cada
peticion, que en esta maquina cuesta 2-3 s de recarga.
"""
return FIJO or _modelo_cargado() or RESPALDO
MODELO = _modelo()
def _pide(fragmento, modelo):
cuerpo = json.dumps({
"model": modelo,
"messages": [{"role": "system", "content": INSTRUCCION},
{"role": "user", "content": fragmento[:1400]}],
"stream": False, "think": False,
"options": {"temperature": 0.7, "num_predict": 200},
"format": "json",
}).encode()
req = urllib.request.Request(ENDPOINT + "/api/chat", data=cuerpo,
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=120) as r:
contenido = json.loads(r.read())["message"]["content"]
except Exception:
return []
try:
d = json.loads(contenido)
except json.JSONDecodeError:
m = re.search(r"\[.*\]", contenido, re.S)
if not m:
return []
try:
d = {"preguntas": json.loads(m.group(0))}
except json.JSONDecodeError:
return []
preguntas = d.get("preguntas") if isinstance(d, dict) else d
if not isinstance(preguntas, list):
return []
fuera = []
for p in preguntas:
if isinstance(p, str) and 8 <= len(p.strip()) <= 200:
fuera.append(p.strip())
return fuera[:3]
def fragmentos():
"""Los fragmentos de cosecha, el ORO (metodologia) primero."""
todos = []
with open(COSECHA, encoding="utf-8") as f:
for l in f:
try:
todos.append(json.loads(l))
except json.JSONDecodeError:
continue
todos.sort(key=lambda d: 0 if d.get("tipo") == "metodologia" else 1)
return todos
def main():
p = argparse.ArgumentParser()
p.add_argument("--muestra", type=int, default=0)
a = p.parse_args()
if not os.path.exists(COSECHA):
print(" no hay cosecha.jsonl: corre cosecha.py primero")
return 1
frags = fragmentos()
print(f" {len(frags)} fragmentos · modelo {MODELO}", flush=True)
if a.muestra:
for fr in frags[:a.muestra]:
print(f"\n ── {fr.get('fichero')} ──")
for q in _pide(fr["texto"], MODELO):
print(f" P: {q}")
return 0
desde = 0
if os.path.exists(MARCA) and os.path.exists(SALIDA):
try:
desde = int(open(MARCA).read().strip()) + 1
except (ValueError, OSError):
desde = 0
modo = "a" if desde else "w"
hechas = sum(1 for _ in open(SALIDA)) if desde and os.path.exists(SALIDA) else 0
if desde:
print(f" reanudando desde el fragmento {desde} ({hechas} preguntas)", flush=True)
total = len(frags)
modelo = MODELO
with open(SALIDA, modo, encoding="utf-8", buffering=1) as out:
for i in range(desde, total):
# re-mirar que modelo esta cargado cada 25: si el verde se abre o se
# cierra durante la noche, seguimos al que este en la tarjeta.
if i % 25 == 0:
modelo = _modelo()
fr = frags[i]
for q in _pide(fr["texto"], modelo):
out.write(json.dumps({
"tipo": "pregunta",
"herramienta": fr.get("herramienta"),
"perfil": fr.get("perfil"),
"fichero": fr.get("fichero"),
"tema": fr.get("tema", ""),
"texto": fr["texto"], # la respuesta: el fragmento LITERAL
"indexar": q, # lo que se embebe: la pregunta
}, ensure_ascii=False) + "\n")
hechas += 1
with open(MARCA, "w") as m:
m.write(str(i))
if (i + 1) % 25 == 0:
print(f" PROGRESO sintetiza hecho={i+1} total={total} "
f"preguntas={hechas}", flush=True)
print(f"\n hecho: {hechas} preguntas en {SALIDA}", flush=True)
return 0
if __name__ == "__main__":
sys.exit(main())

94
nucleo/saber/glosario.py Normal file
View file

@ -0,0 +1,94 @@
"""Tareas comunes de Linux en castellano, mapeadas al comando.
El puente que las man pages no dan: estan en ingles, y "cambiar permisos" no se
parece a "change file mode bits". Aqui viven las tareas del dia a dia dichas
como las diria el usuario, con el comando y una linea de como se usa. Es
curado a mano a proposito: son las 50 cosas que de verdad se piden, y una
traduccion buena vale mas que mil man pages mal recuperadas.
Se anaden al indice como entradas normales, asi que se buscan igual que todo lo
demas y ademas se llevan el impulso por palabras exactas.
"""
# (lo que se pide en castellano, el comando, como se usa)
TAREAS = [
("cambiar los permisos de un fichero o carpeta", "chmod",
"chmod 755 fichero (rwx dueño, rx resto). chmod +x da permiso de ejecucion."),
("cambiar el dueño de un fichero", "chown",
"chown usuario:grupo fichero. Con -R para una carpeta entera."),
("comprimir una carpeta en un archivo", "tar",
"tar czf archivo.tar.gz carpeta/ comprime. tar xzf archivo.tar.gz extrae."),
("descomprimir un zip", "unzip", "unzip archivo.zip. Con -d carpeta para donde."),
("buscar texto dentro de ficheros", "grep",
"grep -r 'texto' carpeta/ busca en todo. grep -i ignora mayusculas."),
("buscar ficheros por nombre", "find",
"find /ruta -name '*.txt' busca por nombre. -type f solo ficheros."),
("ver los puertos abiertos en escucha", "ss",
"ss -tlnp lista los puertos TCP en escucha con su proceso."),
("ver que procesos consumen mas", "top",
"top o htop en tiempo real. ps aux --sort=-%cpu para una foto."),
("matar un proceso", "kill",
"kill PID lo termina. kill -9 PID a la fuerza. pkill nombre por nombre."),
("ver el espacio libre en disco", "df", "df -h muestra el espacio por particion."),
("ver cuanto ocupa una carpeta", "du", "du -sh carpeta/ el tamaño total."),
("ver la memoria libre", "free", "free -h muestra RAM y swap."),
("descargar un fichero de internet", "wget",
"wget URL descarga. curl -O URL tambien. curl -L sigue redirecciones."),
("copiar ficheros a un servidor", "scp",
"scp fichero usuario@servidor:/ruta lo sube. scp -r para carpetas."),
("sincronizar carpetas", "rsync",
"rsync -av origen/ destino/ copia solo lo cambiado. -z comprime en red."),
("editar un fichero de texto", "nano",
"nano fichero (facil) o vim fichero. Ctrl+O guarda y Ctrl+X sale en nano."),
("ver el contenido de un fichero", "cat",
"cat fichero. less fichero para leer largo. head/tail para principio/final."),
("ver los ultimos cambios de un fichero de log", "tail",
"tail -f log sigue en vivo. tail -n 50 las ultimas 50 lineas."),
("ver los servicios del sistema", "systemctl",
"systemctl status servicio. start/stop/restart para manejarlo."),
("ver los mensajes del sistema", "journalctl",
"journalctl -xe lo reciente. journalctl -u servicio de un servicio."),
("montar un disco o usb", "mount",
"mount /dev/sdX1 /mnt monta. lsblk lista los discos. umount desmonta."),
("ver la configuracion de red", "ip",
"ip a las interfaces y sus IP. ip r la tabla de rutas."),
("escanear una red o unos puertos", "nmap",
"nmap -sV objetivo detecta servicios. nmap -p- todos los puertos."),
("ver el uso de red en vivo", "iftop", "iftop o nload muestran el trafico en vivo."),
("cambiar de directorio y listar", "ls",
"ls -la lista con detalles y ocultos. cd carpeta entra. pwd dice donde estas."),
("crear una carpeta", "mkdir", "mkdir carpeta. mkdir -p a/b/c crea la ruta entera."),
("mover o renombrar", "mv", "mv origen destino mueve o renombra."),
("copiar ficheros", "cp", "cp origen destino. cp -r para carpetas."),
("borrar ficheros", "rm",
"rm fichero. rm -r carpeta. CUIDADO: no hay papelera, es definitivo."),
("dar permisos de administrador a un comando", "sudo",
"sudo comando lo ejecuta como root. Pide tu contraseña."),
("ver el historial de comandos", "history", "history lista lo tecleado. Ctrl+R busca."),
("programar una tarea periodica", "cron",
"crontab -e edita tus tareas. Formato: min hora dia mes diasemana comando."),
("ver la temperatura y el hardware", "sensors",
"sensors muestra temperaturas. lscpu la CPU, lspci los dispositivos."),
("gestionar paquetes en Debian", "apt",
"apt install paquete, apt update actualiza la lista, apt upgrade el sistema."),
("ver o cambiar variables de entorno", "export",
"export VAR=valor la pone. env las lista. echo $VAR la muestra."),
("comprobar si un comando esta instalado", "which",
"which comando dice donde esta, o nada si no esta. type comando tambien."),
("ver diferencias entre dos ficheros", "diff",
"diff a b muestra las diferencias. diff -u formato de parche."),
("contar lineas palabras o bytes", "wc",
"wc -l lineas, wc -w palabras. cat fichero | wc -l cuenta lineas."),
("conectarse a una base de datos", "psql",
"psql -U usuario base para Postgres. mysql -u usuario -p para MySQL."),
("ver quien esta conectado", "who", "who los usuarios. w con lo que hacen."),
]
def entradas():
for pide, cmd, como in TAREAS:
yield {
"tipo": "tarea", "herramienta": cmd, "perfil": "LINUX",
"fichero": f"tarea comun: {cmd}",
"texto": f"Para {pide}: usa {cmd}. {como}",
}

232
nucleo/saber/indexa.py Executable file
View file

@ -0,0 +1,232 @@
#!/usr/bin/env python3
"""Convierte COFRE en algo que JARVIS puede consultar antes de contestar.
./indexa.py reconstruye el indice
./indexa.py --cuenta solo dice que encontraria, sin indexar
## El problema que resuelve
COFRE tiene 35.000 documentos y 30 GB. Indexarlo entero seria a la vez inutil y
lentisimo: el 95 % es codigo fuente de terceros node_modules, .git, dist que
no dice nada de COMO USAR una herramienta. Una busqueda sobre eso devuelve
ruido con total confianza, que es peor que no tener nada.
Lo que de verdad vale es el SABER, y son tres cosas:
1. Los apuntes propios del usuario: 26.000 palabras de flujos de trabajo
escritos probandolos. Es lo mas valioso porque es correcto y es suyo.
2. Los READMEs de primer nivel de cada herramienta: el "para que sirve y como
se llama", una vez por herramienta.
3. Una ficha por herramienta: nombre, perfil, ruta, una linea. Asi JARVIS
sabe QUE tiene aunque no haya un README.
## Como se trocea
En fragmentos por seccion (los encabezados markdown) y no en trozos ciegos de N
caracteres: un apunte partido por la mitad de una frase recupera peor que uno
partido por donde el autor ya separo las ideas. Cada fragmento recuerda de que
fichero y que herramienta viene, para poder citar la fuente.
"""
import argparse
import json
import os
import re
import sys
AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
COFRE = os.path.expanduser("~/COFRE")
INDICE = os.path.join(AQUI, "datos", "saber.jsonl")
# Los perfiles que se indexan. Cada uno es una carpeta de ~/COFRE.
PERFILES = ["PENTESTERS", "PHISHERS", "REVERSERS", "PROTECTORS", "HARD-WARERS",
"AUTOMATAS", "DEFACERS", "SNEAKERS", "ZAPPERS"]
# Lo que NO se mira: dependencias, control de versiones, builds. Aqui esta la
# diferencia entre un indice util y 30 GB de ruido.
IGNORA = re.compile(
r"(^|/)(node_modules|\.git|vendor|dist|build|__pycache__|\.venv|venv|"
r"site-packages|target|\.cache|test|tests|fixtures|examples?|locale|"
r"i18n|translations|\.github)(/|$)", re.I)
# Documentos que son saber, no plantillas de proyecto ni codigo de conducta.
DOC_UTIL = re.compile(r"(readme|install|usage|apuntes|notas|trucos|howto|"
r"documentation|guide|guia|cheat|manual)", re.I)
DOC_RUIDO = re.compile(r"(code_of_conduct|contributing|changelog|license|"
r"copying|pull_request|issue_template|security\.md|"
r"hall_of_fame|redistributed|historical)", re.I)
MIN_FRAGMENTO = 80 # menos de esto no es un parrafo, es un titulo suelto
MAX_FRAGMENTO = 1200 # mas de esto recupera con demasiado ruido alrededor
def _profundidad(ruta):
return ruta.rstrip("/").count("/")
def herramientas(perfil):
"""Cada subcarpeta de primer nivel es una herramienta. Una ficha por cada."""
base = os.path.join(COFRE, perfil)
if not os.path.isdir(base):
return
for nombre in sorted(os.listdir(base)):
ruta = os.path.join(base, nombre)
if not os.path.isdir(ruta) or nombre.startswith("."):
continue
yield {"herramienta": nombre, "perfil": perfil, "ruta": ruta}
def _resumen_readme(ruta_herramienta):
"""El primer parrafo con sustancia de su README, si tiene."""
for nombre in ("README.md", "README", "README.txt", "readme.md"):
p = os.path.join(ruta_herramienta, nombre)
if not os.path.exists(p):
continue
try:
with open(p, encoding="utf-8", errors="ignore") as f:
texto = f.read(4000)
except OSError:
continue
# saltar titulos, insignias y lineas vacias hasta el primer parrafo real
for parrafo in re.split(r"\n\s*\n", texto):
limpio = re.sub(r"[#>*`\[\]!]|\(https?://[^)]+\)|<[^>]+>", "", parrafo).strip()
limpio = re.sub(r"\s+", " ", limpio)
if len(limpio) >= 60 and not limpio.lower().startswith(("http", "===")):
return limpio[:400]
return ""
def documentos_utiles(perfil):
"""Los .md/.txt que son saber, no codigo ni plantillas."""
base = os.path.join(COFRE, perfil)
for raiz, dirs, ficheros in os.walk(base):
if IGNORA.search(raiz):
dirs[:] = []
continue
# no bajar mas de 3 niveles: el saber vive arriba, el codigo abajo
if _profundidad(raiz) - _profundidad(base) > 3:
dirs[:] = []
continue
for fich in ficheros:
if not fich.lower().endswith((".md", ".txt")):
continue
if DOC_RUIDO.search(fich):
continue
# los apuntes propios entran siempre; de terceros, solo los utiles
propio = re.search(r"apuntes|trucos|notas|cheat|guia", fich, re.I)
if not propio and not DOC_UTIL.search(fich):
continue
yield os.path.join(raiz, fich)
def trocea(ruta):
"""Un fichero markdown en fragmentos por seccion."""
try:
with open(ruta, encoding="utf-8", errors="ignore") as f:
texto = f.read()
except OSError:
return
# partir por encabezados, conservando el titulo con su seccion
partes = re.split(r"\n(?=#{1,3}\s)", texto)
for parte in partes:
limpio = parte.strip()
if len(limpio) < MIN_FRAGMENTO:
continue
# si una seccion es enorme, partirla por parrafos sin pasar del tope
if len(limpio) <= MAX_FRAGMENTO:
yield limpio
else:
buffer = ""
for parrafo in re.split(r"\n\s*\n", limpio):
if len(buffer) + len(parrafo) > MAX_FRAGMENTO and buffer:
yield buffer.strip()
buffer = ""
buffer += parrafo + "\n\n"
if len(buffer.strip()) >= MIN_FRAGMENTO:
yield buffer.strip()
def construye(solo_cuenta=False, solo_cofre=False):
fichas, fragmentos = [], []
for perfil in PERFILES:
n_herr = 0
for h in herramientas(perfil):
resumen = _resumen_readme(h["ruta"])
texto = f"{h['herramienta']} ({perfil}). {resumen}".strip()
fichas.append({
"tipo": "ficha", "herramienta": h["herramienta"],
"perfil": perfil, "ruta": h["ruta"], "texto": texto,
})
n_herr += 1
n_frag = 0
for doc in documentos_utiles(perfil):
herr = _herramienta_de(doc, perfil)
for trozo in trocea(doc):
fragmentos.append({
"tipo": "apunte", "herramienta": herr, "perfil": perfil,
"fichero": os.path.relpath(doc, COFRE), "texto": trozo,
})
n_frag += 1
print(f" {perfil:14s} {n_herr:4d} herramientas {n_frag:5d} fragmentos de apuntes")
# El conocimiento de la propia maquina: comandos de Linux, servidores SSH,
# extensiones. Es lo que convierte "sabe de mis herramientas" en "sabe hacer
# cualquier cosa en Linux". Va salvo que se pida solo COFRE.
sistema = []
if not solo_cofre:
try:
from saber import sistema as sis
except ImportError:
import sistema as sis
print(" extrayendo la documentacion del sistema (man pages, ssh, firefox)...",
flush=True)
n_cmd = n_srv = n_nav = 0
for e in sis.todo():
sistema.append(e)
n_cmd += e["tipo"] == "comando"
n_srv += e["tipo"] == "servidor"
n_nav += e["tipo"] == "navegador"
if len(sistema) % 500 == 0:
print(f" {len(sistema)} entradas de sistema...", flush=True)
print(f" LINUX {n_cmd:4d} comandos {n_srv} servidores {n_nav} navegador")
todo = fichas + fragmentos + sistema
print(f"\n total: {len(fichas)} fichas + {len(fragmentos)} apuntes + "
f"{len(sistema)} sistema = {len(todo)} entradas")
if solo_cuenta:
return 0
print(" cargando el modelo de embeddings...", flush=True)
from model2vec import StaticModel
modelo = StaticModel.from_pretrained("minishlab/potion-multilingual-128M")
print(" calculando vectores...", flush=True)
vectores = modelo.encode([e["texto"] for e in todo], show_progress_bar=False)
os.makedirs(os.path.dirname(INDICE), exist_ok=True)
with open(INDICE, "w", encoding="utf-8") as f:
for entrada, vec in zip(todo, vectores):
entrada["v"] = [round(float(x), 5) for x in vec]
f.write(json.dumps(entrada, ensure_ascii=False) + "\n")
print(f" indice guardado: {INDICE} ({os.path.getsize(INDICE)//1024} KB)")
return 0
def _herramienta_de(ruta, perfil):
partes = os.path.relpath(ruta, os.path.join(COFRE, perfil)).split(os.sep)
return partes[0] if len(partes) > 1 else perfil
def main() -> int:
p = argparse.ArgumentParser()
p.add_argument("--cuenta", action="store_true",
help="di que encontrarias, sin indexar")
p.add_argument("--solo-cofre", action="store_true",
help="indexar solo COFRE, sin la documentacion del sistema")
a = p.parse_args()
return construye(solo_cuenta=a.cuenta, solo_cofre=a.solo_cofre)
if __name__ == "__main__":
sys.exit(main())

183
nucleo/saber/sistema.py Normal file
View 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()