commit 8e4bc8ad9499215d7c3f7bad5651746a5d79a028 Author: sito Date: Sun Aug 16 15:28:31 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e8c02a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Entornos y modelos: se reconstruyen, no se versionan +venv/ +*venv*/ +__pycache__/ +*.pyc +models/ +*.onnx +*.gguf +*.bin +*.pt +*.ckpt + +# Datos PRIVADOS del usuario: nunca al repositorio +# - saber.jsonl y derivados llevan el texto de los apuntes de COFRE +# - diario.jsonl es lo que se le dice al asistente por voz +# - la voz de referencia y las cachés de audio son personales +nucleo/datos/*.jsonl +nucleo/datos/saber.jsonl.antes-* +nucleo/datos/cache_voz/ +nucleo/datos/banco.json +nucleo/datos/*.log +nucleo/datos/informe_rag.txt +voz/ref/ +voz/cache_voz*/ +voz/dataset/ +voz/originales/ +voz/modelos/ +voz/comparativa/ +*.wav + +# Excepcion: el catalogo de acciones y su generador SI van (definen que sabe +# hacer el asistente, no son datos personales) +!nucleo/datos/acciones.json +!nucleo/datos/_generar.py + +# Registros y temporales +*.log +scan.txt +PARAR +.flatpak-builder/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b0ee9b5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,45 @@ +# JARVIS — mapa del repositorio + +Asistente de voz 100 % local para Linux. Núcleo propio. Este fichero orienta a +quien (persona o agente) trabaje en el código. + +**Las rutas relativas van desde la raíz del repositorio.** El núcleo usa rutas +relativas a su propia ubicación (`AQUI = dirname(__file__)`); algunos scripts de +`config/` y `voz/` todavía asumen rutas absolutas del entorno del autor y habría +que hacerlas configurables. + +## El flujo + + oír → pensar → hacer → hablar + +`nucleo/jarvis.py` es el bucle. Oye con whisper (`oido/`), decide con el modelo +(`cerebro/`), ejecuta con las manos (`manos/`) o contesta, y habla (`boca/`). El +panel (`cara/`) muestra todo en vivo. + +## Carpetas + + nucleo/ + jarvis.py la entrada; arranca.sh lo lanza + oido/ whisper.cpp: captura, palabra de activación, transcripción + cerebro/ ollama, tool-calling, y el RAG (pasivo + agéntico) + boca/ Piper. voz.py fija la voz por defecto + manos/ las acciones, el diario, el sudo, la fonética + cara/ el panel web y su servidor + saber/ el RAG: indexa, busca, glosario; enriquece/ lo mejora + datos/ acciones.json (el catálogo). El resto es privado (.gitignore) + pruebas/ la suite del núcleo + + config/ configuración: modelo (Modelfile), voz, whisper CUDA + voz/ clonado de voz con XTTS y caché de frases + entrena/ fine-tuning con QLoRA + +## Dos cosas que ahorran errores + +- **La gráfica es pequeña (4 GB) y el cerebro y whisper compiten por ella.** + Antes de tocar el dimensionado, mirar el presupuesto de VRAM del README. +- **Los datos del usuario no se versionan.** `saber.jsonl`, `diario.jsonl` y las + cachés de voz son privados: el `.gitignore` los bloquea. Ver PRIVACIDAD.md. + +## Estilo + +Registro técnico y conciso. Los commits describen el porqué, no solo el qué. diff --git a/PRIVACIDAD.md b/PRIVACIDAD.md new file mode 100644 index 0000000..af01832 --- /dev/null +++ b/PRIVACIDAD.md @@ -0,0 +1,51 @@ +# Privacidad + +La premisa del proyecto es que nada sale de la máquina. No es un eslogan: se +verifica, y este documento dice qué es local, qué podría salir y qué se ha +dejado deliberadamente fuera de este repositorio. + +## Qué es local + +| Pieza | Dónde corre | +|---|---| +| Cerebro (modelo) | Ollama en `127.0.0.1:11434` | +| Escucha (STT) | whisper.cpp, en la propia máquina | +| Voz (TTS) | Piper / XTTS, en la propia máquina | +| Memoria de conversación | ficheros locales | +| RAG | índice local sobre apuntes locales | + +Ninguna de estas piezas necesita una cuenta ni una clave. El asistente funciona +con la máquina desconectada de internet. + +## Qué puede salir, y no es un fallo + +- **La búsqueda web**, solo si el modelo decide usarla para una pregunta que lo + pide. Es una herramienta que se invoca, no una fuga de fondo, y se avisa. + +Fuera de eso, no hay tráfico saliente durante el uso normal. + +## Qué NO está en este repositorio, a propósito + +Este código es público; los datos del usuario no. El `.gitignore` bloquea, y +nunca se deben añadir: + +- **`nucleo/datos/saber.jsonl`** y sus derivados (`cosecha.jsonl`, + `sintesis.jsonl`): contienen el TEXTO de los apuntes indexados. Publicarlos + publicaría los apuntes. +- **`nucleo/datos/diario.jsonl`**: es lo que el usuario le ha dicho al asistente + por voz. +- **`voz/ref/`** y las cachés de audio: la voz de referencia y las frases + sintetizadas son personales. +- **Entornos, modelos y datasets**: se reconstruyen; no son código. + +Lo único de `nucleo/datos/` que sí va es `acciones.json` (el catálogo de lo que +el asistente sabe hacer) y su generador: definen capacidades, no datos. + +## Cómo comprobarlo + +- Que nada nuestro escuche fuera de loopback: + `ss -ltnp | grep -vE '127\.0\.0\.1|\[::1\]'` +- Que el cerebro apunta a local: revisar el endpoint en `nucleo/cerebro/`. +- Que no hay claves en el repo: no hay ningún fichero de credenciales; el + manejador de sudo (`nucleo/manos/sudo_root.py`) guarda la contraseña solo en + memoria durante la sesión y la borra al terminar. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f2e06d --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# JARVIS + +Asistente de voz **100 % local** para Linux. Ni el micrófono, ni las +conversaciones, ni el modelo salen de la máquina. Sin cuentas, sin claves, sin +nube. Núcleo propio, escrito para caber en una tarjeta pequeña (NVIDIA T1200 de +4 GB) y hacer lo que se le pide por voz, en castellano. + +![Linux](https://img.shields.io/badge/Linux-Debian%2012-A81D33?logo=debian&logoColor=white) +![GNOME](https://img.shields.io/badge/GNOME-X11-4A86CF?logo=gnome&logoColor=white) +![Python](https://img.shields.io/badge/Python-3.11-3776AB?logo=python&logoColor=white) +![Ollama](https://img.shields.io/badge/Ollama-qwen3.5%3A4b-000000?logo=ollama&logoColor=white) +![NVIDIA](https://img.shields.io/badge/CUDA-sm__75-76B900?logo=nvidia&logoColor=white) +![whisper.cpp](https://img.shields.io/badge/whisper.cpp-STT-555555) +![Piper](https://img.shields.io/badge/Piper-TTS-555555) + +``` + ┌───────────────────────────┐ + micrófono ─────>│ OÍDO whisper.cpp CUDA │ STT castellano + │ sm_75 · small/GPU │ ~930 ms régimen + └─────────────┬─────────────┘ + v + apuntes propios ──> ┌───────────┐ ┌─────────────────────────┐ + (RAG) │ SABER │─>│ CEREBRO ollama │ + │ model2vec │ │ qwen3.5:4b · tool-call │ + │ +híbrido │ │ + RAG pasivo Y agéntico │ + └───────────┘ └─────────────┬───────────┘ + v + ┌───────────────┐ ┌──────────────────────┐ + altavoz <───────────│ BOCA Piper │<─│ MANOS │ + │ / voz clonada│ │ acciones + shell │ + └───────────────┘ └──────────────────────┘ + │ + ┌───────────┴───────────────────────────┐ + │ CARA panel web · WebSocket · reactor │ + └────────────────────────────────────────┘ + todo en 127.0.0.1 · 0 llamadas a la nube +``` + + + +## El stack + +| Capa | Pieza | Papel | +|---|---|---| +| Oír | whisper.cpp (CUDA, sm_75) | STT en castellano, en la GPU | +| Pensar | Ollama · `qwen3.5:4b` | el cerebro, con tool-calling | +| Saber | model2vec + búsqueda híbrida | RAG sobre los apuntes del usuario | +| Hablar | Piper (o voz clonada con XTTS) | TTS local | +| Cara | panel web · WebSocket · Canvas | el HUD, con el reactor y las métricas | +| Manos | catálogo de acciones + shell | lo que ejecuta en la máquina | + +Todo corre en `127.0.0.1`. No hay ninguna llamada a la nube en el uso normal; lo +único que puede salir es una búsqueda web si el modelo decide usarla, y se avisa. + +## Requisitos de hardware + +Referencia (el equipo del autor): + +| | | +|---|---| +| GPU | NVIDIA T1200, **4 GB VRAM**, Turing (sm_75) | +| RAM | 62 GB | +| SO | Debian 12, GNOME sobre X11, PipeWire | + +La restricción que manda es la VRAM. El presupuesto, medido: + +``` +┌──────────────────────────────────┬──────────┬───────────────────────┐ +│ COMPONENTE │ VRAM │ NOTA │ +├──────────────────────────────────┼──────────┼───────────────────────┤ +│ qwen3.5:4b (24/32 capas en GPU) │ 2177 MiB │ el cerebro │ +│ whisper small (PICO de la trans- │ 839 MiB │ el pico, no el reposo:│ +│ cripción) │ │ ahí estuvo el OOM │ +│ ── suma ── │ 3016 MiB │ cabe, con margen justo│ +└──────────────────────────────────┴──────────┴───────────────────────┘ +``` + +Corre en menos con un modelo más pequeño; con más VRAM, sobra sitio. + +## Instalación + +Necesita, en la máquina: [Ollama](https://ollama.com), whisper.cpp compilado con +CUDA (ver [`config/README_whisper_gpu.md`](config/README_whisper_gpu.md)) y +[Piper](https://github.com/rhasspy/piper). + +```bash +git clone https://gitea.laenre.net/hacklab/JARVIS.git +cd JARVIS +./install.sh # comprueba servicios, crea el venv y el modelo de Ollama +``` + +`install.sh` no descarga modelos pesados ni toca el sistema sin avisar: solo +prepara el entorno de Python y crea `qwen3.5:4b-jarvis` a partir del `Modelfile`. + +Después: + +```bash +./nucleo/saber/indexa.py # (opcional) indexa tus apuntes para el RAG +./nucleo/arranca.sh # el asistente y el panel, en 127.0.0.1 +``` + +## Qué le puedes pedir + +Habla en castellano, natural. **150 acciones** en el catálogo, agrupadas: + +| Grupo | Nº | Ejemplos | +|---|---|---| +| sistema | 59 | "cuánto espacio queda", "la memoria", "la carga de CPU", "la temperatura" | +| firefox | 54 | "abre el Gmail", "abre YouTube", "abre el GitHub", "abre Maps" | +| ventanas | 12 | "manda la ventana a la izquierda", "maximiza", "centra la ventana" | +| ficheros | 11 | "busca un fichero llamado…", "lee el fichero…", "cuánto ocupa la carpeta…" | +| pentest | 8 | "escanea la red", "los puertos del host…", "resuelve el dominio…", "el whois de…" | +| oasis | 6 | "está corriendo Oasis", "cuántas conexiones tiene" | + +Si lo que pides no está en el catálogo, el modelo lo resuelve con un **comando de +shell** (de solo lectura por defecto). Y si pregunta *cómo* se hace algo técnico, +puede **buscar en tus apuntes** (RAG) antes de responder, para dar el comando que +ya has probado en vez de inventarlo. + +## Órdenes con root + +Algunas acciones necesitan `sudo`. El manejo es explícito y con límites, no un +"root para siempre": + +- La contraseña **nunca se escribe en disco**. Vive en memoria del proceso solo + si activas el permiso de sesión, y se pasa a `sudo -S` por la entrada estándar. +- El permiso de sesión **caduca a los 15 minutos** de la última orden, y también + al cerrar el panel. +- **Cada** orden con root muestra su comando exacto en un diálogo antes de + ejecutarlo. El permiso de sesión solo ahorra re-teclear la contraseña; nunca + salta el ver y aprobar. **La voz no puede autoaprobar root.** + +Con el candado echado, el asistente no obedece órdenes que toquen el sistema: +las pide desbloquear primero. + +## Privacidad + +Es la premisa, y se verifica. Ver [PRIVACIDAD.md](PRIVACIDAD.md). Este +repositorio **no incluye** los apuntes indexados ni el diario de conversación: +son del usuario, y el `.gitignore` los bloquea. + +## Estructura + +``` +nucleo/ el asistente (oido, cerebro, boca, manos, cara, saber) +config/ configuración: modelo, voz, whisper CUDA +voz/ clonado de voz con XTTS y caché de frases +entrena/ fine-tuning con QLoRA +install.sh el instalador +``` + +## Estado y limitaciones + +Proyecto en desarrollo. Rugosidades conocidas: varios scripts de `config/` y +`voz/` asumen rutas absolutas del entorno del autor (`~/COFRE/...`) y habría que +hacerlas configurables; el fine-tuning de `entrena/` está pendiente de un ajuste +para modelos multimodales. + +## Licencia + +Por decidir por el autor. El núcleo es código propio, no derivado. diff --git a/config/README_whisper_gpu.md b/config/README_whisper_gpu.md new file mode 100644 index 0000000..f930f4c --- /dev/null +++ b/config/README_whisper_gpu.md @@ -0,0 +1,30 @@ +# Whisper small en GPU + +El reconocimiento pasa del modelo `base` en CPU al `small` en GPU. Medido: + +| | acierto | latencia (régimen) | +|---|---|---| +| base · CPU | 80 % | 928 ms | +| small · GPU | **93 %** | **~930 ms** | + +Misma velocidad, +13 puntos de acierto. `small` ocupa 757 MiB en la T1200 y +coexiste con el modelo grande de Ollama (2901/3717 MiB con los dos cargados). + +## Cómo está montado + +- whisper.cpp compilado con CUDA (sm_75) en `voz/whisper-cuda/` (no versionado; + se reconstruye con `cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=75`). +- Envoltorio `~/.local/bin/whisper-server` (copia de referencia en + `config/whisper-server-cuda.sh`): fija `LD_LIBRARY_PATH` a las libs de CUDA y + llama al binario. Está en el PATH del host, que es donde Newelle lo busca. +- Ajustes de Newelle: `stt-settings.whispercpp = {model: small, + use_system_server: true}`. El handler arranca el servidor por + `flatpak-spawn --host whisper-server` y le pega las peticiones. + +## Lo único a saber + +La PRIMERA transcripción de cada sesión tarda ~4 s: es el arranque del servidor +(cargar el modelo a la GPU). A partir de ahí, ~930 ms. El servidor se queda +vivo mientras la app corre. + +Para volver a CPU: `use_system_server: false` y `model: base` en los ajustes. diff --git a/config/ampliar_swap.sh b/config/ampliar_swap.sh new file mode 100644 index 0000000..c9a345e --- /dev/null +++ b/config/ampliar_swap.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Amplia la swap en caliente, sin reiniciar y sin tocar particiones. +# +# Por que: el portatil tiene 62 GB de RAM y 976 MiB de swap en la particion +# nvme0n1p3. Ese reparto es de una maquina con 2 GB de RAM, no con 62. La swap +# se llena (96% medido) y aunque hoy no cause nada —comprobado: si=4 so=0 KB/s, +# 43 GB de RAM disponible, ninguna fuga— no queda colchon para cuando algo pida +# memoria de golpe. +# +# Lo que NO arregla: nada de lo que se notaba. La pantalla que pedia contraseña +# era el candado de JARVIS, no el sistema. Esto es higiene, no una reparacion. +# +# Se ejecuta con sudo y no hace falta reiniciar: `swapon` activa el fichero al +# momento y la linea del fstab solo sirve para el proximo arranque. +# +# sudo bash ampliar_swap.sh [tamaño] # por defecto 16G +set -euo pipefail + +TAM=${1:-16G} +FICHERO=/swapfile + +[ "$(id -u)" -eq 0 ] || { echo "hay que ejecutarlo con sudo" >&2; exit 1; } + +if swapon --show=NAME --noheadings 2>/dev/null | grep -qx "$FICHERO"; then + echo "$FICHERO ya esta activo; nada que hacer." + swapon --show + exit 0 +fi + +echo "1. creando $FICHERO de $TAM" +# fallocate va bien en ext4; en btrfs haria falta otra receta (chattr +C y +# sin compresion), pero la raiz de esta maquina es ext4. +fallocate -l "$TAM" "$FICHERO" 2>/dev/null || dd if=/dev/zero of="$FICHERO" bs=1M count=$((${TAM%G} * 1024)) status=none +chmod 600 "$FICHERO" # solo root: si no, mkswap se niega y con razon + +echo "2. formateando" +mkswap "$FICHERO" >/dev/null + +echo "3. activando (aqui ya esta funcionando, sin reiniciar)" +swapon "$FICHERO" + +echo "4. dejandolo puesto para el proximo arranque" +grep -q "^$FICHERO " /etc/fstab || echo "$FICHERO none swap sw 0 0" >> /etc/fstab + +# Con 62 GB de RAM no tiene sentido apartar paginas al disco con la alegria de +# swappiness=60: se baja a 10 para que la swap sea un seguro y no una costumbre. +echo "5. bajando swappiness de $(cat /proc/sys/vm/swappiness) a 10" +sysctl -q vm.swappiness=10 +echo "vm.swappiness=10" > /etc/sysctl.d/99-jarvis-swappiness.conf + +echo +swapon --show +free -h | head -3 diff --git a/config/aprender.py b/config/aprender.py new file mode 100644 index 0000000..da4d7b1 --- /dev/null +++ b/config/aprender.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Enseña acciones nuevas a partir de lo que no entendió. + + python3 ~/COFRE/CODERS/JARVIS/config/aprender.py + +Cada vez que dices algo que no encaja con ninguna accion, el panel lo anota en +config/jarvis-sin-accion.jsonl. Esto lo lee, agrupa lo repetido y pregunta a +que accion pertenece cada frase; lo que decidas se escribe como alias en +config/jarvis-acciones.json, que el catalogo carga ANTES que el del paquete. + +Por que asi y no entrenando un modelo: los fallos del reconocedor son +sistematicos, no aleatorios. "abre el correo" se oye "a ver el correo" siempre, +no una de cada diez veces. Un alias lo arregla del todo, se lee, se borra si +molesta y no cuesta un milisegundo. Reentrenar costaria horas para acertar +"casi siempre". +""" +import json +import os +import sys +from collections import Counter + +CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config") +REGISTRO = os.path.join(CONFIG, "jarvis-sin-accion.jsonl") +PROPIO = os.path.join(CONFIG, "jarvis-acciones.json") +PAQUETE = os.path.expanduser( + "~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/" + "active/files/share/newelle/acciones/acciones.json") + + +def carga_catalogo(): + acciones = {} + for ruta in (PAQUETE, PROPIO): + try: + with open(ruta) as f: + for a in json.load(f).get("acciones", []): + acciones.setdefault(a["id"], a) + except (OSError, json.JSONDecodeError): + pass + return acciones + + +def carga_fallos(): + """Las frases sin accion, agrupadas por veces que se han dicho.""" + cuenta = Counter() + try: + with open(REGISTRO) as f: + for linea in f: + try: + texto = json.loads(linea).get("texto", "").strip() + except json.JSONDecodeError: + continue + if texto: + cuenta[texto] += 1 + except OSError: + pass + return cuenta + + +def guarda_alias(nuevos): + """Añade los alias al catalogo propio, respetando lo que ya hubiera.""" + try: + with open(PROPIO) as f: + propio = json.load(f) + except (OSError, json.JSONDecodeError): + propio = {"version": 1, "acciones": []} + + porid = {a["id"]: a for a in propio.get("acciones", [])} + catalogo = carga_catalogo() + + for ident, frases in nuevos.items(): + if ident in porid: + entrada = porid[ident] + else: + # se copia la del paquete entera: el catalogo propio se carga antes + # y sustituye a la original, asi que tiene que traer su comando + entrada = dict(catalogo[ident]) + porid[ident] = entrada + propio.setdefault("acciones", []).append(entrada) + for f in frases: + if f not in entrada["frases"]: + entrada["frases"].append(f) + + with open(PROPIO, "w") as f: + json.dump(propio, f, indent=2, ensure_ascii=False) + return PROPIO + + +def main(): + fallos = carga_fallos() + if not fallos: + print("No hay nada sin entender todavia.") + print(f"Se anota solo en {REGISTRO}") + return 0 + + catalogo = carga_catalogo() + ids = sorted(catalogo) + print(f"{len(fallos)} frases distintas no encajaron con ninguna accion.\n") + + nuevos = {} + for texto, veces in fallos.most_common(): + print(f' "{texto}" ({veces} {"vez" if veces == 1 else "veces"})') + print(" escribe el id de la accion, 'l' para listarlos, " + "Enter para saltar, 'q' para terminar") + try: + resp = input(" > ").strip() + except (EOFError, KeyboardInterrupt): + print() + break + + if resp == "q": + break + if resp == "l": + for i, ident in enumerate(ids): + print(f" {ident:<24}", end="\n" if i % 3 == 2 else "") + print() + try: + resp = input(" > ").strip() + except (EOFError, KeyboardInterrupt): + break + if not resp: + continue + if resp not in catalogo: + print(f" '{resp}' no existe, saltada") + continue + + nuevos.setdefault(resp, []).append(texto) + print(f" anotada como alias de {resp}") + + if not nuevos: + print("\nNada que guardar.") + return 0 + + ruta = guarda_alias(nuevos) + total = sum(len(v) for v in nuevos.values()) + print(f"\n{total} alias guardados en {ruta}") + print("Se aplican al abrir la pestaña JARVIS otra vez.") + + if input("¿Vaciar el registro de frases no entendidas? [s/N] ").strip().lower() == "s": + try: + os.remove(REGISTRO) + print("registro vaciado") + except OSError as e: + print(f"no se pudo vaciar: {e}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/apuntar_modelo_jarvis.py b/config/apuntar_modelo_jarvis.py new file mode 100644 index 0000000..edb5775 --- /dev/null +++ b/config/apuntar_modelo_jarvis.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Apunta el cerebro de Newelle al modelo qwen3.5:4b-jarvis. + +Es la variante con num_gpu 24 (ver qwen3.5-4b-jarvis.Modelfile): deja 1540 MiB +libres en la tarjeta para que whisper pueda transcribir sin morir de OOM. + +Toca solo la clave llm-settings, y dentro solo el bloque "ollama": el modelo +elegido, la lista de modelos y la biblioteca que se ve en la interfaz. +""" +import json +import subprocess +import sys + +APP = "io.github.qwersyk.Newelle" +RAMA = "master" +MODELO = "qwen3.5:4b-jarvis" + +GSET = ["flatpak", "run", f"--command=gsettings", f"{APP}//{RAMA}"] + + +def leer(clave): + out = subprocess.check_output(GSET + ["get", APP, clave], text=True).strip() + # gsettings devuelve el string entre comillas simples, con \\ escapados + return json.loads(out[1:-1].replace("\\\\", "\\").replace("\\'", "'")) + + +def escribir(clave, valor): + subprocess.check_call(GSET + ["set", APP, clave, json.dumps(valor)]) + + +def main(): + ajustes = leer("llm-settings") + ollama = ajustes.setdefault("ollama", {}) + + anterior = ollama.get("model") + if anterior == MODELO: + print(f"ya apuntaba a {MODELO}; nada que hacer.") + return 0 + + ollama["model"] = MODELO + + # la lista de modelos del desplegable: pares [clave, titulo] + modelos = json.loads(ollama.get("models", "[]")) + if not any(m[0] == MODELO for m in modelos): + modelos.insert(0, [MODELO, MODELO]) + ollama["models"] = json.dumps(modelos) + + # la biblioteca que pinta la interfaz, para que salga con descripcion + biblioteca = ollama.get("model-library", []) + if not any(m.get("key") == MODELO for m in biblioteca): + biblioteca.insert(0, { + "key": MODELO, + "title": "qwen3.5 4b (JARVIS)", + "description": "qwen3.5:4b con 24 de 32 capas en GPU, para dejar " + "sitio a whisper en la tarjeta de 4 GB.", + }) + ollama["model-library"] = biblioteca + + escribir("llm-settings", ajustes) + print(f"cerebro: {anterior} -> {MODELO}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/autoescuela.py b/config/autoescuela.py new file mode 100755 index 0000000..163ee3c --- /dev/null +++ b/config/autoescuela.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +"""Dos JARVIS hablando: uno examina, el otro ejecuta, el primero corrige. + + python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py # una tanda + python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --horas 6 # toda la noche + python3 ~/COFRE/CODERS/JARVIS/config/autoescuela.py --aplicar # guardar mejoras + +La idea. Los bucles que teniamos probaban trozos: mejora_nocturna comprobaba +solo si la frase llega a la accion; probar_acciones, solo si la accion corre. +Esto los junta en una conversacion de punta a punta y por AUDIO: + + Examinador -> dice una tarea en voz alta (Piper), con ruido y sala + Jarvis -> la oye (Whisper), decide que accion es, la ejecuta, + y contesta en voz alta + Examinador -> comprueba tres cosas: + 1. ¿oyo la tarea correcta? (reconocimiento) + 2. ¿hizo la accion que tocaba? (emparejamiento) + 3. ¿el resultado tiene sentido? (ejecucion y respuesta) + y pone nota + +Lo que se ejecuta de verdad: solo lo de solo lectura. Lo que abre cosas, apaga +o necesita root se comprueba hasta el emparejamiento, sin lanzarlo —una +autoescuela no deberia apagarte la pantalla ni pedir tu contraseña de noche. + +Lo que aprende: los fallos se separan por tipo. Un fallo de reconocimiento +propone un alias (con el mismo filtro seguro de mejora_nocturna: sistematico, +sin ambiguedad, conserva palabra clave). Un fallo de ejecucion o de respuesta +se ANOTA para arreglarlo a mano —eso no se toca solo, que es codigo—. + +Escenarios: casi todos son de un turno (una tarea, una respuesta), sacados del +catalogo. Unos pocos son de varios turnos para probar lo que tiene estado: +buscar -> "abre el segundo" -> "busca dentro X". Y hay charla que NO debe +disparar ninguna accion, como control negativo. +""" +import argparse +import importlib.util +import json +import os +import random +import re +import shlex +import shutil +import subprocess +import sys +import tempfile +import time +from collections import defaultdict + +CASA = os.path.expanduser("~") +JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS") +CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config") +APP = "io.github.qwersyk.Newelle//master" + +PIPER = os.path.join(JARVIS, "voz/piper/piper") +MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos") +WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli") +MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models") + +CATALOGO = os.path.expanduser( + "~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/" + "active/files/share/newelle/acciones/acciones.json") +CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json") +PROPIO = os.path.join(CONFIG, "jarvis-acciones.json") +MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py") +SELECCION = os.path.join(JARVIS, "newelle/src/utility/seleccion.py") + +SALA = os.path.join(JARVIS, "muestras/autoescuela") +DIALOGO = os.path.join(SALA, "dialogo.log") +INFORME = os.path.join(SALA, "informe.json") +WAVS = os.path.join(CONFIG, "autoescuela_wavs") + +VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"] +VELOCIDADES = [0.9, 1.0, 1.12] +RUIDOS = ["", "aecho=0.8:0.7:22:0.12", "volume=0.72,highpass=f=120", + "highpass=f=150,lowpass=f=6500"] + +# Lo que tiene efecto o pide permiso: se comprueba el emparejamiento pero no se +# ejecuta. Igual que en probar_acciones. +EFECTOS = ["firefox", "xdg-open", "loginctl", "xset", "pactl set", "pkill", + "brightnessctl", "systemctl start", "systemctl stop", "systemctl restart", + "import -window", "gnome-screenshot", "sudo", "rm ", "kill", "dpms"] + +RELLENOS = ["notas", "el informe", "documentos", "reactor"] + + +def carga(ruta, nombre): + spec = importlib.util.spec_from_file_location(nombre, ruta) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +def log(msg, a_dialogo=False): + linea = f"[{time.strftime('%H:%M:%S')}] {msg}" + print(linea, flush=True) + fichero = DIALOGO if a_dialogo else DIALOGO + try: + with open(fichero, "a") as f: + f.write((msg if a_dialogo else linea) + "\n") + except OSError: + pass + + +def dice(texto, voz, vel, ruido, destino): + """El examinador dice una tarea: la sintetiza a un wav de 16 kHz.""" + crudo = tempfile.mktemp(suffix=".wav") + try: + r = subprocess.run( + [PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"), + "--length_scale", str(1 / vel), "--output_file", crudo], + input=texto, capture_output=True, text=True, timeout=60) + if r.returncode != 0 or not os.path.exists(crudo): + return False + filtros = "aresample=16000" + ("," + ruido if ruido else "") + subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", crudo, + "-ac", "1", "-af", filtros, "-y", destino], check=True, timeout=60) + return True + except (OSError, subprocess.SubprocessError): + return False + finally: + if os.path.exists(crudo): + os.remove(crudo) + + +def oye(wavs, hilos=8, por_tanda=40): + """Jarvis oye: transcribe los wav. {fichero: texto}.""" + salida = {} + ficheros = sorted(wavs) + for i in range(0, len(ficheros), por_tanda): + tanda = ficheros[i:i + por_tanda] + lista = " ".join(f'"{WAVS}/{n}"' for n in tanda) + orden = (f'for f in {lista}; do echo "### $(basename "$f")"; ' + f'{WHISPER} -m {MODELOS_STT}/ggml-base.bin -l es -nt -t {hilos} ' + f'-f "$f" 2>/dev/null | tr "\\n" " "; echo; done') + try: + r = subprocess.run(["flatpak", "run", "--command=bash", APP, "-c", orden], + capture_output=True, text=True, errors="replace", timeout=1200) + except subprocess.SubprocessError: + continue + actual = None + for linea in r.stdout.splitlines(): + if linea.startswith("### "): + actual = linea[4:].strip() + elif actual: + salida[actual] = linea.strip() + actual = None + return salida + + +def tiene_efecto(comando): + return any(m in comando for m in EFECTOS) + + +def ejecuta_lectura(comando, argumento=""): + """Ejecuta un comando de solo lectura y devuelve su salida.""" + c = comando.replace("{argumento}", shlex.quote(argumento)) + c = c.replace("{argumento_url}", shlex.quote(argumento)) + try: + r = subprocess.run(["bash", "-lc", c], capture_output=True, text=True, + errors="replace", timeout=15) + return (r.stdout or r.stderr or "").strip(), r.returncode + except subprocess.SubprocessError: + return "", 1 + + +class Ejecutor: + """La tuberia de Jarvis: oye una frase, decide y hace. Replica el enrutado + del panel (seleccion antes que catalogo) para poder probarlo sin GUI.""" + + def __init__(self, motor, seleccion): + self.motor = motor + self.sel = seleccion + self.cat = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)] + or [CATALOGO_FUENTE]) + self.lista = [] + self.fichero = None + + def recarga(self): + self.cat = self.motor.Catalogo( + [p for p in (PROPIO, CATALOGO) if os.path.exists(p)] or [CATALOGO_FUENTE]) + + def procesa(self, oido): + """Devuelve (tipo, id_o_dato, salida). tipo: + 'seleccion' | 'dentro' | 'accion' | 'modelo'.""" + norm = self.motor.normaliza(oido) + + s = self.sel.orden_de_seleccion(norm) + if s is not None and self.lista: + que, pos = s + idx = len(self.lista) - 1 if pos == -1 else pos - 1 + if 0 <= idx < len(self.lista): + self.fichero = self.lista[idx] + return "seleccion", f"{que}:{pos}", self.fichero + return "seleccion", "fuera_de_rango", "" + + termino = self.sel.orden_buscar_dentro(norm) + if termino and self.fichero: + r, _ = ejecuta_lectura(f"grep -in {shlex.quote(termino)} " + f"{shlex.quote(self.fichero)} | head", "") + return "dentro", termino, r + + accion, arg = self.cat.busca(oido) + if accion is None: + return "modelo", "", "" + + # las de lista alimentan self.lista + if accion.id in ("buscar_fichero", "buscar_carpeta", "grep_recursivo"): + salida, _ = ejecuta_lectura(accion.comando, arg) + rutas = [l.split(":", 1)[0] if accion.id == "grep_recursivo" else l + for l in salida.splitlines() if l.strip()] + self.lista = rutas + if rutas: + self.fichero = rutas[0] + return "accion", accion.id, salida + + if accion.confirmar or tiene_efecto(accion.comando): + return "accion", accion.id, "(no se ejecuta; solo emparejamiento)" + + salida, _ = ejecuta_lectura(accion.comando, arg) + return "accion", accion.id, salida + + +def escenarios(acciones): + """Construye las tareas de examen. Cada una: (texto, comprobacion).""" + tareas = [] + + # un turno por accion: la tarea es su frase, se espera su id + for a in acciones: + if not a["frases"]: + continue + texto = a["frases"][0] + if a.get("captura"): + texto += " " + random.choice(RELLENOS) + tareas.append({ + "tipo": "accion", "texto": texto, "espera_id": a["id"], + "turnos": [texto], + }) + + # multi-turno: probar el estado (lista -> elegir -> buscar dentro) + tareas.append({ + "tipo": "multi", "texto": "buscar y elegir", + "turnos": ["busca el fichero reactor", "abre el primero", "busca dentro def"], + "espera": ["accion:grep_recursivo|accion:buscar_fichero", "seleccion", "dentro"], + }) + + # control negativo: charla que NO debe disparar accion + for frase in ["cuentame un chiste sobre gatos", "que opinas de todo esto", + "estoy cansado hoy la verdad", "no se muy bien que hacer ahora"]: + tareas.append({"tipo": "charla", "texto": frase, "turnos": [frase]}) + + return tareas + + +def una_tanda(ejecutor, motor, ronda, muestras): + acciones = ejecutor.cat.acciones + base_acciones = json.load(open(CATALOGO if os.path.exists(CATALOGO) + else CATALOGO_FUENTE))["acciones"] + tareas = escenarios(base_acciones) + + # sintetizar todos los turnos + shutil.rmtree(WAVS, ignore_errors=True) + os.makedirs(WAVS, exist_ok=True) + plan = [] # (fichero, tarea_idx, turno_idx, texto) + n = 0 + for ti, tarea in enumerate(tareas): + for tj, texto in enumerate(tarea["turnos"]): + n += 1 + nombre = f"{n:05d}.wav" + if dice(texto, random.choice(VOCES), random.choice(VELOCIDADES), + random.choice(RUIDOS), os.path.join(WAVS, nombre)): + plan.append((nombre, ti, tj, texto)) + + log(f"ronda {ronda}: examinador dice {len(plan)} tareas, jarvis las oye...") + oidos = oye([p[0] for p in plan]) + + # correr los escenarios en orden (el estado importa en los multi-turno) + resultados = {"bien": 0, "reconoce_mal": 0, "accion_mal": 0, + "ejecuta_mal": 0, "charla_colada": 0} + candidatos = defaultdict(lambda: defaultdict(int)) + por_tarea = defaultdict(list) + for nombre, ti, tj, texto in plan: + por_tarea[ti].append((tj, nombre, texto)) + + for ti in sorted(por_tarea): + tarea = tareas[ti] + ejecutor.lista = [] # cada escenario empieza limpio + ejecutor.fichero = None + for tj, nombre, texto in sorted(por_tarea[ti]): + oido = oidos.get(nombre, "").strip() + if not oido: + continue + tipo, dato, salida = ejecutor.procesa(oido) + + # dejar la conversacion en el log, legible + log(f" examinador: «{texto}»", a_dialogo=True) + log(f" jarvis oye: «{oido}» -> {tipo}:{dato}", a_dialogo=True) + + veredicto = verifica(tarea, tj, texto, oido, tipo, dato, salida, + motor, candidatos) + log(f" veredicto: {veredicto}\n", a_dialogo=True) + resultados[veredicto] = resultados.get(veredicto, 0) + 1 + + total = sum(resultados.values()) + bien = resultados["bien"] + log(f"ronda {ronda}: {bien}/{total} bien · " + + " · ".join(f"{k} {v}" for k, v in resultados.items() if k != "bien" and v)) + return resultados, candidatos + + +def verifica(tarea, tj, texto, oido, tipo, dato, salida, motor, candidatos): + """Pone nota a un turno y, si es fallo de reconocimiento, anota el candidato.""" + if tarea["tipo"] == "charla": + return "bien" if tipo == "modelo" else "charla_colada" + + if tarea["tipo"] == "multi": + esperado = tarea["espera"][tj] + actual = f"{tipo}:{dato}" + if any(actual.startswith(e.split("|")[0].split(":")[0] + ":") and + (e == actual or e.split(":")[0] == tipo) + for e in esperado.split("|")): + # para multi basta con que el TIPO sea el correcto + if tipo in [e.split(":")[0] for e in esperado.split("|")]: + return "bien" + return "accion_mal" + + # tarea de un turno: se espera un id concreto + espera_id = tarea["espera_id"] + if tipo != "accion": + # no reconocio la orden: candidato a alias si conserva sentido + candidatos[espera_id][motor.normaliza(oido)] += 1 + return "reconoce_mal" + if dato != espera_id: + return "accion_mal" + # emparejo bien; ¿la ejecucion dio algo con sentido? + if salida and ("command not found" in salida or salida == "TIMEOUT"): + return "ejecuta_mal" + return "bien" + + +# --- aprender: mismos filtros seguros que mejora_nocturna -------------------- +VACIAS = {"que", "de", "la", "el", "me", "se", "es", "esta", "estan", "hay", + "tengo", "un", "una", "por", "con", "como", "cuanto", "cuanta"} + + +def contenido(frase): + return {p for p in frase.split() if len(p) > 3 and p not in VACIAS} + + +def alias_seguros(candidatos, acciones, motor, cat): + porfrase = defaultdict(set) + for ident, frases in candidatos.items(): + for f in frases: + porfrase[f].add(ident) + porid = {a["id"]: a for a in acciones} + propuestos = defaultdict(list) + for ident, frases in candidatos.items(): + a = porid.get(ident) + if a is None or a.get("captura"): + continue + orig = motor.normaliza(a["frases"][0] if a["frases"] else "") + claves = contenido(orig) + for f, veces in frases.items(): + if veces < 2 or len(porfrase[f]) > 1 or len(f.split()) < 2: + continue + if cat.busca(f)[0] is not None or motor.parecido(f, orig) < 0.6: + continue + pal = set(f.split()) + if claves and not any( + c in pal or any(motor.parecido(c, w) > 0.8 for w in pal) + for c in claves): + continue + propuestos[ident].append((f, veces)) + return propuestos + + +def guarda_alias(propuestos, acciones): + try: + propio = json.load(open(PROPIO)) + except (OSError, json.JSONDecodeError): + propio = {"version": 1, "acciones": []} + porid = {a["id"]: a for a in propio.get("acciones", [])} + orig = {a["id"]: a for a in acciones} + n = 0 + for ident, lista in propuestos.items(): + e = porid.get(ident) + if e is None: + e = dict(orig[ident]); porid[ident] = e + propio.setdefault("acciones", []).append(e) + for f, _ in lista: + if f not in e["frases"]: + e["frases"].append(f); n += 1 + json.dump(propio, open(PROPIO, "w"), indent=2, ensure_ascii=False) + return n + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--horas", type=float, default=0) + ap.add_argument("--aplicar", action="store_true", help="guardar los alias que encuentre") + ap.add_argument("--muestras", type=int, default=1) + args = ap.parse_args() + + for ruta, q in ((PIPER, "piper"), (WHISPER, "whisper")): + if not os.path.exists(ruta): + print(f"falta {q}"); return 1 + + os.makedirs(SALA, exist_ok=True) + motor = carga(MOTOR, "acciones") + sel = carga(SELECCION, "seleccion") + ejecutor = Ejecutor(motor, sel) + + limite = time.time() + args.horas * 3600 if args.horas else None + log("=" * 56) + log("arranca la autoescuela (examinador + jarvis)") + + historial = [] + ronda = 0 + total_alias = 0 + while True: + ronda += 1 + ini = time.time() + try: + resultados, candidatos = una_tanda(ejecutor, motor, ronda, args.muestras) + except Exception as e: + log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}") + time.sleep(5); continue + + # aprender de los fallos de reconocimiento + base = json.load(open(CATALOGO if os.path.exists(CATALOGO) + else CATALOGO_FUENTE))["acciones"] + propuestos = alias_seguros(candidatos, base, motor, ejecutor.cat) + nuevos = sum(len(v) for v in propuestos.values()) + if nuevos and args.aplicar: + total_alias += guarda_alias(propuestos, base) + ejecutor.recarga() + log(f"ronda {ronda}: +{nuevos} alias aplicados") + elif nuevos: + log(f"ronda {ronda}: {nuevos} alias propuestos (usa --aplicar para guardarlos)") + + total = sum(resultados.values()) + historial.append({ + "ronda": ronda, + "acierto": round(100 * resultados["bien"] / max(1, total), 1), + "fallos": {k: v for k, v in resultados.items() if k != "bien" and v}, + "minutos": round((time.time() - ini) / 60, 1), + }) + json.dump({"actualizado": time.strftime("%Y-%m-%d %H:%M"), + "rondas": ronda, "alias_aplicados": total_alias, + "historial": historial[-30:]}, + open(INFORME, "w"), indent=2, ensure_ascii=False) + + if not args.horas: + break + if time.time() > limite: + log("se acabo el tiempo. Fin."); break + + shutil.rmtree(WAVS, ignore_errors=True) + log(f"terminado tras {ronda} rondas · ultimo acierto " + f"{historial[-1]['acierto'] if historial else '?'} %") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/banco.py b/config/banco.py new file mode 100755 index 0000000..203a1e8 --- /dev/null +++ b/config/banco.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""Un banco de pruebas FIJO para medir si el reconocimiento mejora. + +## El problema que resuelve + +La mejora nocturna medía así: decía las 150 frases con voces, velocidades y +ruido ALEATORIOS, las transcribía, y comparaba el porcentaje con el de la ronda +anterior. Nueve horas y 39 rondas después: + + ruido de medida (desviacion): ±1,9 puntos + mejora acumulada en la noche: +1,45 puntos + +El ruido era MAYOR que la mejora. Con eso, la pregunta que el bucle necesita +contestar —"¿este alias mejora o empeora?"— se decidía a cara o cruz, y por eso +en nueve horas aplicó cuatro alias y no dejó ni un commit. + +## Cómo lo arregla + +Se graba el audio UNA vez, con semillas fijas, y se guardan las +TRANSCRIPCIONES. A partir de ahí, medir el catálogo es volver a pasar el +emparejador sobre esas mismas transcripciones guardadas: + + - determinista: la misma entrada siempre, así que dos medidas son comparables + - instantáneo: sin TTS ni Whisper, milisegundos en vez de cinco minutos + - sensible: se nota una frase de diferencia, no hacen falta cuatro puntos + +Lo que se pierde: deja de explorar ruidos nuevos. Por eso el banco se REHACE de +vez en cuando (--crear), y entre medias se usa fijo para decidir. + + banco.py --crear [--muestras 2] graba y transcribe (lento, una vez) + banco.py --medir puntúa el catálogo actual (instantáneo) + banco.py --fallos enseña qué se sigue fallando +""" +import argparse +import collections +import importlib.util +import json +import os +import random +import subprocess +import sys +import tempfile + +RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json") +MOTOR = os.path.join(RAIZ, "newelle", "src", "utility", "acciones.py") +BANCO = os.path.join(RAIZ, "muestras", "banco.json") +PIPER = os.path.join(RAIZ, "config", "jarvis-piper.sh") +MODELO_WHISPER = os.path.expanduser( + "~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models/ggml-small.bin") +BIN_WHISPER = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin", "whisper-server") + +# Fijas a proposito: son las que hacen el banco reproducible. Cambiarlas obliga +# a rehacerlo, y esa es justamente la señal de que ya no es comparable. +VOCES = ["es_ES-davefx-medium", "es_MX-claude-high", "es_ES-sharvard-medium"] +VELOCIDADES = [0.9, 1.0, 1.15] +SEMILLA = 20260815 + + +def motor(): + spec = importlib.util.spec_from_file_location("acciones", MOTOR) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +def crear(muestras): + m = motor() + cat = m.Catalogo([CATALOGO]) + rnd = random.Random(SEMILLA) + + casos = [] + for a in cat.acciones: + if not a.frases: + continue + for _ in range(muestras): + casos.append({ + "id": a.id, + "frase": rnd.choice(a.frases), + "voz": rnd.choice(VOCES), + "velocidad": rnd.choice(VELOCIDADES), + }) + print(f" {len(casos)} clips a grabar y transcribir...", flush=True) + + puerto = 8993 + servidor = subprocess.Popen( + [BIN_WHISPER, "-m", MODELO_WHISPER, "-t", "8", "--host", "127.0.0.1", + "--port", str(puerto), "-l", "es", "--no-gpu"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + env={**os.environ, "LD_LIBRARY_PATH": "/usr/local/cuda/lib64"}) + import time + time.sleep(10) + + import urllib.request + hechos = 0 + with tempfile.TemporaryDirectory() as tmp: + for c in casos: + wav = os.path.join(tmp, "c.wav") + entorno = {**os.environ, + "JARVIS_PIPER_VOZ": c["voz"], + "JARVIS_PIPER_TONO": "1.0"} + r = subprocess.run([PIPER, wav, c["frase"]], env=entorno, + capture_output=True, timeout=60) + if not os.path.exists(wav) or os.path.getsize(wav) == 0: + continue + # la velocidad se aplica al vuelo, sin tocar el tono + wav16 = os.path.join(tmp, "c16.wav") + subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", wav, + "-filter:a", f"atempo={c['velocidad']}", + "-ar", "16000", "-ac", "1", wav16], + capture_output=True, timeout=60) + try: + with open(wav16, "rb") as f: + datos = f.read() + lim = "----b" + cuerpo = (f"--{lim}\r\nContent-Disposition: form-data; " + f'name="file"; filename="c.wav"\r\n' + f"Content-Type: audio/wav\r\n\r\n").encode() + datos + \ + f"\r\n--{lim}--\r\n".encode() + req = urllib.request.Request( + f"http://127.0.0.1:{puerto}/inference", data=cuerpo, + headers={"Content-Type": f"multipart/form-data; boundary={lim}"}) + with urllib.request.urlopen(req, timeout=120) as resp: + c["oido"] = json.loads(resp.read().decode()).get("text", "").strip() + hechos += 1 + if hechos % 50 == 0: + print(f" {hechos}/{len(casos)}", flush=True) + except Exception as e: + c["oido"] = "" + servidor.terminate() + + casos = [c for c in casos if c.get("oido")] + os.makedirs(os.path.dirname(BANCO), exist_ok=True) + json.dump({"semilla": SEMILLA, "casos": casos}, open(BANCO, "w"), + ensure_ascii=False, indent=1) + print(f" banco guardado: {len(casos)} transcripciones en {BANCO}") + return 0 + + +def cargar(): + try: + return json.load(open(BANCO))["casos"] + except Exception: + return [] + + +def medir(detalle=False): + casos = cargar() + if not casos: + print("no hay banco; crealo con --crear") + return 1 + m = motor() + cat = m.Catalogo([CATALOGO]) + aciertos, fallos = 0, [] + for c in casos: + a, _ = cat.busca(c["oido"]) + if a is not None and a.id == c["id"]: + aciertos += 1 + else: + fallos.append((c["id"], c["oido"], a.id if a else None)) + pct = 100.0 * aciertos / len(casos) + print(f" acierto {pct:.2f} % ({aciertos}/{len(casos)})") + if detalle: + cuenta = collections.Counter(f[0] for f in fallos) + print(f"\n las acciones que mas se fallan:") + for ident, n in cuenta.most_common(12): + ejemplo = next(f[1] for f in fallos if f[0] == ident) + print(f" {n:3d} {ident:22s} p.ej. se oyo: \"{ejemplo[:44]}\"") + return 0 + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--crear", action="store_true") + p.add_argument("--medir", action="store_true") + p.add_argument("--fallos", action="store_true") + p.add_argument("--muestras", type=int, default=2) + a = p.parse_args() + if a.crear: + return crear(a.muestras) + return medir(detalle=a.fallos) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/comparar_voces.py b/config/comparar_voces.py new file mode 100644 index 0000000..203b96c --- /dev/null +++ b/config/comparar_voces.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Genera la misma frase con las cuatro voces britanicas de Kokoro. + + flatpak run --command=python3 io.github.qwersyk.Newelle//master \ + ~/COFRE/CODERS/JARVIS/config/comparar_voces.py + +Todas salen ya con el tratamiento aplicado, que es como sonaran de verdad. +Elegir de oido y fijar la ganadora con set_voice.py. +""" +import os +import subprocess +import sys + +sys.path.insert(0, "/app/share/newelle") +sys.path.insert(0, os.path.expanduser( + "~/.var/app/io.github.qwersyk.Newelle/config/pip")) + +from gi.repository import Gio # noqa: E402 + +from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402 +from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402 + +VOCES = ["bm_george", "bm_daniel", "bm_lewis", "bm_fable"] + +FRASE = ("Good evening, sir. All systems are running locally. " + "The GPU is idle, and you have 846 gigabytes free. " + "Shall I run the diagnostics?") + +SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras/voces") +CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config") + + +def nivel(path): + """mean/max en dB, para confirmar que ninguna sale floja o recortada.""" + r = subprocess.run( + ["ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect", + "-f", "null", "/dev/null"], + capture_output=True, text=True, + ) + vals = [l.split(":")[-1].strip() for l in r.stderr.splitlines() + if "mean_volume:" in l or "max_volume:" in l] + return " / ".join(vals) if vals else "?" + + +def main(): + os.makedirs(SALIDA, exist_ok=True) + settings = Gio.Settings.new("io.github.qwersyk.Newelle") + handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models")) + + if not handler.is_installed(): + print("instalando kokoro...") + handler.install() + + filtro = jarvis_audio_filter() + print(f"{'voz':<12} {'fichero':<26} nivel (mean / max)") + + for voz in VOCES: + handler.set_voice(voz) + crudo = os.path.join(SALIDA, f"_{voz}_seca.wav") + final = os.path.join(SALIDA, f"{voz}.wav") + try: + handler.save_audio(FRASE, crudo) + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", + "-i", crudo, "-af", filtro, "-y", final], + check=True, + ) + os.remove(crudo) + print(f"{voz:<12} {os.path.basename(final):<26} {nivel(final)}") + except Exception as e: + print(f"{voz:<12} FALLO: {e}") + + # dejar la voz como estaba, que este script solo compara + handler.set_voice(VOCES[0]) + print(f"\nvoz restaurada a {VOCES[0]}") + print(f"escuchalas en: {SALIDA}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/comparar_voces_es.py b/config/comparar_voces_es.py new file mode 100644 index 0000000..0c1e550 --- /dev/null +++ b/config/comparar_voces_es.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Genera la misma frase en castellano con las voces masculinas disponibles. + + flatpak run --command=python3 io.github.qwersyk.Newelle//master \ + ~/COFRE/CODERS/JARVIS/config/comparar_voces_es.py + +Kokoro solo trae DOS voces masculinas en español: em_alex y em_santa. De los +motores que Newelle soporta, los unicos locales son kokoro y espeak; el resto +—gtts, edge, elevenlabs, openai, groq— son de nube y quedan descartados. + +Como ninguna de las dos es especialmente grave, cada una se genera tambien en +version grave: se baja el tono con ffmpeg sin cambiar la velocidad. El truco es +reproducir a menos muestras por segundo (baja tono y alarga) y devolver la +duracion con atempo. A 0.90 baja unos dos semitonos, que se nota sin sonar a +disco rayado; por debajo de 0.85 empieza a sonar artificial. + +Se escuchan las cuatro y se fija la elegida con set_voz_es.py. +""" +import os +import subprocess +import sys + +sys.path.insert(0, "/app/share/newelle") +sys.path.insert(0, os.path.expanduser( + "~/.var/app/io.github.qwersyk.Newelle/config/pip")) + +from gi.repository import Gio # noqa: E402 + +from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402 +from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402 + +VOCES = ["em_alex", "em_santa"] +GRAVEDAD = 0.90 # 1.0 = sin tocar; menos = mas grave +MUESTREO = 24000 # el que saca kokoro + +FRASE = ("Buenas tardes. Todos los sistemas funcionan en local. " + "La tarjeta grafica esta libre y quedan ochocientos cuarenta y seis " + "gigabytes en el disco. Quiere que ejecute el diagnostico?") + +SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras/voces_es") +CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config") + + +def cadena(grave: bool) -> str: + """El tratamiento JARVIS, con o sin bajada de tono delante.""" + base = jarvis_audio_filter() + if not grave: + return base + tono = (f"asetrate={int(MUESTREO * GRAVEDAD)}," + f"aresample={MUESTREO},atempo={1 / GRAVEDAD:.4f}") + return f"{tono},{base}" if base else tono + + +def nivel(path): + """mean/max en dB, para confirmar que ninguna sale floja o recortada.""" + r = subprocess.run( + ["ffmpeg", "-hide_banner", "-i", path, "-af", "volumedetect", + "-f", "null", "/dev/null"], + capture_output=True, text=True, + ) + vals = [l.split(":")[-1].strip() for l in r.stderr.splitlines() + if "mean_volume:" in l or "max_volume:" in l] + return " / ".join(vals) if vals else "?" + + +def main(): + os.makedirs(SALIDA, exist_ok=True) + settings = Gio.Settings.new("io.github.qwersyk.Newelle") + handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models")) + + if not handler.is_installed(): + print("instalando kokoro...") + handler.install() + + previa = handler.get_current_voice() if hasattr(handler, "get_current_voice") else None + print(f"{'fichero':<28} nivel (mean / max)") + + for voz in VOCES: + handler.set_voice(voz) + crudo = os.path.join(SALIDA, f"_{voz}.wav") + try: + handler.save_audio(FRASE, crudo) + except Exception as e: + print(f"{voz:<28} FALLO al generar: {e}") + continue + + for etiqueta, grave in (("", False), ("_grave", True)): + final = os.path.join(SALIDA, f"{voz}{etiqueta}.wav") + try: + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", + "-i", crudo, "-af", cadena(grave), "-y", final], + check=True, + ) + print(f"{os.path.basename(final):<28} {nivel(final)}") + except Exception as e: + print(f"{voz}{etiqueta:<20} FALLO: {e}") + os.remove(crudo) + + if previa: + handler.set_voice(previa) + print(f"\nescuchalas en: {SALIDA}") + print("la elegida se fija con set_voz_es.py [--grave]") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/construir_indice.py b/config/construir_indice.py new file mode 100644 index 0000000..7bd850c --- /dev/null +++ b/config/construir_indice.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Construye el indice del RAG sin abrir la interfaz. + + flatpak run --command=python3 io.github.qwersyk.Newelle//master \ + ~/COFRE/CODERS/JARVIS/config/construir_indice.py + +En la app esto es el boton "Update Index" de los ajustes del RAG, que lanza un +hilo y va enseñando el progreso en una barra. Desde aqui se llama a lo mismo +pero en primer plano, para poder dejarlo corriendo en una terminal y ver si +falla, que con 20 000 fragmentos conviene. + +Antes hay que haber preparado el corpus con indexar.py: este script indexa lo +que haya en la carpeta de documentos, sin filtrar nada. +""" +import os +import sys +import time + +sys.path.insert(0, "/app/share/newelle") +sys.path.insert(0, os.path.expanduser( + "~/.var/app/io.github.qwersyk.Newelle/config/pip")) + +from gi.repository import Gio # noqa: E402 + +from newelle.handlers.rag.llamaindex_handler import LlamaIndexHanlder # noqa: E402 +from newelle.handlers.embeddings.model2vec import Model2VecHandler # noqa: E402 + +CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config") +MODELOS = os.path.join(CONFIG, "models") +DOCUMENTOS = os.path.join(MODELOS, "documents") + + +def main(): + if not os.path.isdir(DOCUMENTOS): + print(f"no existe {DOCUMENTOS}") + print("prepara antes el corpus: python3 config/indexar.py --hacerlo") + return 1 + + ficheros = [f for f in os.listdir(DOCUMENTOS) if not f.startswith(".")] + if not ficheros: + print("la carpeta de documentos esta vacia") + return 1 + + tam = sum(os.path.getsize(os.path.join(DOCUMENTOS, f)) + for f in ficheros) / 1048576 + print(f"{len(ficheros)} documentos · {tam:.1f} MB") + + settings = Gio.Settings.new("io.github.qwersyk.Newelle") + rag = LlamaIndexHanlder(settings, MODELOS) + + if not rag.is_installed(): + print("faltan dependencias del RAG (llama-index, faiss, tiktoken)") + return 1 + + # El RAG necesita el modelo de embeddings para vectorizar, y opcionalmente + # un LLM secundario para reescribir la consulta. Aqui solo hace falta el + # primero: se indexa, no se pregunta. + rag.embedding = Model2VecHandler(settings, MODELOS) + if not rag.embedding.is_installed(): + print("instalando model2vec...") + rag.embedding.install() + # create_index solo le pide load_model() al LLM —para indexar no hace falta + # generar nada— asi que un cascaron sirve y evita cargar 3,7 GB de modelo + # para vectorizar documentos. + class SinModelo: + def load_model(self, *a, **k): + return True + + rag.llm = SinModelo() + rag.secondary_llm = SinModelo() + + trozo = rag.get_setting("chunk_size") + print(f"fragmentos de {trozo} tokens · esto tarda, no lo cortes\n") + + ini = time.time() + try: + rag.create_index() + except Exception as e: + print(f"\nfallo al indexar: {type(e).__name__}: {e}") + return 1 + + minutos = (time.time() - ini) / 60 + _, data_path = rag.get_paths() + peso = sum( + os.path.getsize(os.path.join(dp, f)) + for dp, _, fs in os.walk(data_path) for f in fs) / 1048576 + + # create_index se traga sus propias excepciones e imprime el error, asi que + # terminar sin fallo no significa que haya indice. Se comprueba el disco. + if peso < 0.1: + print(f"\nno se creo indice ({peso:.1f} MB en {data_path})") + print("mira el error de arriba") + return 1 + + print(f"\nindice construido en {minutos:.1f} min · {peso:.1f} MB") + print(f"en {data_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/demo_en_directo.sh b/config/demo_en_directo.sh new file mode 100755 index 0000000..c249a7e --- /dev/null +++ b/config/demo_en_directo.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Le da ordenes a JARVIS en directo, para verlo trabajar en la pantalla. +# +# Cada orden se dice EN VOZ ALTA con la voz activa (para oir como suena de +# verdad, no en un banco de pruebas) y luego se ejecuta la accion que le +# corresponde, igual que haria el asistente al oirte. +# +# No se ejecuta nada destructivo ni nada con root: eso pasa por un dialogo que +# aprueba una persona, y aqui no hay persona delante del dialogo. +# +# ./demo_en_directo.sh las que salen por pantalla incluidas +# ./demo_en_directo.sh --mudo sin voz, solo resultados +set -uo pipefail +RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd) +MUDO=no; [ "${1:-}" = "--mudo" ] && MUDO=si + +CATALOGO=$RAIZ/newelle/data/acciones/acciones.json +TMPWAV=$(mktemp --suffix=.wav) +trap 'rm -f "$TMPWAV"' EXIT + +# La orden que se le diria, y la accion del catalogo que le toca. +# Se eligen las que se VEN o las que contestan algo concreto. +ORDENES=( + "cuanto espacio queda|disco_libre" + "cuanta memoria|memoria" + "que temperatura hay|temperatura" + "que procesos consumen mas|procesos_top" + "cual es mi ip|red_ip" + "cuanto lleva encendido|uptime" + "busca el fichero README|buscar_fichero|README.md" + "busca dentro JARVIS|grep_recursivo|JARVIS" + "hazme una captura|captura_pantalla" + "abre una terminal|terminal_abrir" + "que peliculas tengo|videos_listar" + "pon la pelicula screencast|pelicula_abrir|Screencast" + "abre la carpeta de jarvis|abrir_carpeta|$RAIZ" +) + +dice() { + [ "$MUDO" = si ] && return 0 + "$RAIZ/config/jarvis-voz.sh" "$TMPWAV" "$1" >/dev/null 2>&1 \ + || JARVIS_PIPER_VOZ=es_ES-davefx-medium "$RAIZ/config/jarvis-piper.sh" "$TMPWAV" "$1" >/dev/null 2>&1 + [ -s "$TMPWAV" ] && { paplay "$TMPWAV" 2>/dev/null || aplay -q "$TMPWAV" 2>/dev/null; } +} + +campo() { # campo + python3 -c " +import json,sys +d=json.load(open('$CATALOGO',encoding='utf-8')) +for a in d.get('acciones', d if isinstance(d,list) else []): + if a['id']==sys.argv[1]: print(a.get(sys.argv[2],'') or ''); break +" "$1" "$2" +} + +for entrada in "${ORDENES[@]}"; do + IFS='|' read -r frase accion argumento <<< "$entrada" + comando=$(campo "$accion" comando) + acuse=$(campo "$accion" acuse) + respuesta=$(campo "$accion" respuesta) + + if [ -z "$comando" ]; then + printf '\n ── "%s" → accion %s NO EXISTE en el catalogo\n' "$frase" "$accion" + continue + fi + + printf '\n ── tu dices: "%s"\n' "$frase" + printf ' jarvis: %s\n' "${acuse:-...}" + dice "${acuse:-Un momento}" + + # sustituir el argumento igual que hace el motor + real=${comando//\{argumento\}/$(printf '%q' "${argumento:-}")} + salida=$(timeout 30 bash -lc "$real" 2>&1 | head -6) + + if [ -n "$salida" ]; then + printf ' resultado: %s\n' "$(echo "$salida" | head -1 | cut -c1-100)" + echo "$salida" | tail -n +2 | sed 's/^/ /' | cut -c1-100 + else + printf ' resultado: (hecho, sin salida que enseñar)\n' + fi + + # decir la respuesta con la plantilla, si la tiene y es corta + if [ -n "$respuesta" ] && [ -n "$salida" ]; then + ultima=$(echo "$salida" | tail -1) + hablado=${respuesta//\{ultima\}/$ultima} + hablado=${hablado//\{salida\}/$ultima} + case "$hablado" in + *"{"*) ;; # plantilla con campos que no sabemos + *) [ ${#hablado} -lt 160 ] && { printf ' jarvis: %s\n' "$hablado"; dice "$hablado"; };; + esac + fi + sleep 1 +done + +printf '\n ── fin de la demostracion\n' diff --git a/config/descubrir.py b/config/descubrir.py new file mode 100755 index 0000000..57b372d --- /dev/null +++ b/config/descubrir.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Cataloga las herramientas de COFRE para que JARVIS sepa cuales hay. + + python3 ~/COFRE/CODERS/JARVIS/config/descubrir.py + +Produce jarvis-herramientas.json: una ficha por herramienta con su nombre, que +hace, como se invoca y en que carpeta vive. NO es el catalogo de acciones +rapidas (esas son las ~20 que dices por voz a diario, escritas a mano). Esto es +el registro que el modelo consulta cuando le pides algo que no esta en el +catalogo, para componer el comando en vez de inventarselo. + +Que hace y que NO hace: + - SI: encuentra los puntos de entrada reales (scripts con shebang y binarios + en los primeros niveles de cada proyecto, no los modulos internos). + - SI: saca una descripcion del README, del docstring o de la cabecera, SIN + ejecutar nada. Ejecutar un script desconocido con --help para leerlo seria + ejecutar un script desconocido, que es justo lo que no se quiere hacer a + ciegas. + - NO: ejecuta ninguna herramienta. Esto solo mira. + +El resultado se revisa a mano antes de usarlo: es material de terceros y algun +"tool" sera un ejemplo o un exploit, no algo que quieras ofrecer. +""" +import ast +import json +import os +import re +import stat +import sys + +COFRE = os.path.expanduser("~/COFRE") +SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/config/jarvis-herramientas.json") + +CARPETAS = ["CODERS", "PENTESTERS", "AUTOMATAS", "DOSER", "HARD-WARERS", + "PHISHERS", "PROTECTORS", "REVERSERS", "SNEAKERS"] + +# Solo los primeros niveles: un punto de entrada vive cerca de la raiz del +# proyecto, no enterrado en lib/ o en site-packages. +PROFUNDIDAD = 3 + +FUERA = {"node_modules", ".git", "venv", ".venv", "site-packages", "__pycache__", + "lib", "libs", "tests", "test", "docs", "examples", "vendor", "dist", + "build", ".tox", "exploitdb"} + +# Nombres que casi nunca son la herramienta en si +IGNORAR_NOMBRES = {"setup.py", "conftest.py", "__init__.py", "test.py", + "tests.py", "wsgi.py", "manage.py", "conf.py"} + + +def es_ejecutable(ruta: str) -> bool: + try: + return bool(os.stat(ruta).st_mode & stat.S_IXUSR) + except OSError: + return False + + +def tiene_shebang(ruta: str) -> bool: + try: + with open(ruta, "rb") as f: + return f.read(2) == b"#!" + except OSError: + return False + + +def descripcion_readme(carpeta: str) -> str: + """Primera linea con contenido de un README cercano.""" + for nombre in ("README.md", "README.rst", "README.txt", "README"): + ruta = os.path.join(carpeta, nombre) + if not os.path.exists(ruta): + continue + try: + with open(ruta, errors="ignore") as f: + for linea in f: + limpia = linea.strip().lstrip("#").lstrip(">").strip() + # saltar titulos de una palabra, badges y separadores + if len(limpia) > 25 and not limpia.startswith(("![", "[!", "---", "===")): + return limpia[:200] + except OSError: + pass + return "" + + +def descripcion_script(ruta: str) -> str: + """Docstring del modulo (Python) o cabecera de comentarios (shell).""" + try: + with open(ruta, errors="ignore") as f: + texto = f.read(4000) + except OSError: + return "" + + if ruta.endswith(".py"): + try: + doc = ast.get_docstring(ast.parse(texto)) + if doc: + return doc.strip().split("\n")[0][:200] + except (SyntaxError, ValueError): + pass + + # cabecera de comentarios: las primeras lineas que empiezan por # tras el shebang + lineas = [] + for linea in texto.splitlines()[1:8]: + s = linea.strip() + if s.startswith("#"): + limpio = s.lstrip("#").strip() + if len(limpio) > 15: + lineas.append(limpio) + elif lineas: + break + return " ".join(lineas)[:200] + + +def recorre(): + herramientas = [] + vistos = set() + + for carpeta in CARPETAS: + raiz = os.path.join(COFRE, carpeta) + if not os.path.isdir(raiz): + continue + + for dirpath, dirnames, ficheros in os.walk(raiz): + profundidad = dirpath[len(raiz):].count(os.sep) + if profundidad >= PROFUNDIDAD: + dirnames[:] = [] + continue + dirnames[:] = [d for d in dirnames + if d not in FUERA and not d.startswith(".")] + + for nombre in ficheros: + if nombre in IGNORAR_NOMBRES: + continue + ruta = os.path.join(dirpath, nombre) + ext = os.path.splitext(nombre)[1].lower() + + es_script = ext in (".py", ".sh") and (tiene_shebang(ruta) or es_ejecutable(ruta)) + es_binario = not ext and es_ejecutable(ruta) and not tiene_shebang(ruta) + if not (es_script or es_binario): + continue + + clave = nombre.lower() + if clave in vistos: + continue + vistos.add(clave) + + desc = descripcion_script(ruta) if es_script else "" + if not desc: + desc = descripcion_readme(dirpath) + + herramientas.append({ + "nombre": os.path.splitext(nombre)[0], + "carpeta": carpeta, + "ruta": ruta, + "tipo": "python" if ext == ".py" else "shell" if ext == ".sh" else "binario", + "descripcion": desc, + "invocar": (f"python3 {ruta}" if ext == ".py" + else f"bash {ruta}" if ext == ".sh" + else ruta), + }) + + return herramientas + + +def main(): + print("recorriendo COFRE (solo lectura, no se ejecuta nada)...\n", flush=True) + tools = recorre() + + porcarpeta = {} + con_desc = 0 + for t in tools: + porcarpeta[t["carpeta"]] = porcarpeta.get(t["carpeta"], 0) + 1 + if t["descripcion"]: + con_desc += 1 + + for carpeta in CARPETAS: + if carpeta in porcarpeta: + print(f" {carpeta:<13} {porcarpeta[carpeta]:>4} herramientas") + print(f"\n total {len(tools)}, con descripcion {con_desc} " + f"({100*con_desc//max(1,len(tools))} %)") + + with open(SALIDA, "w") as f: + json.dump({"version": 1, "herramientas": tools}, f, + indent=2, ensure_ascii=False) + print(f"\nescrito {SALIDA}") + print("REVISALO a mano antes de usarlo: es material de terceros y algun") + print("'tool' sera un ejemplo o un exploit, no algo que quieras ofrecer.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/entrenar.py b/config/entrenar.py new file mode 100755 index 0000000..23751ac --- /dev/null +++ b/config/entrenar.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +"""Entrena el reconocimiento solo, sin que nadie hable. + + python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py # tanda normal + python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py --rapido # una voz + python3 ~/COFRE/CODERS/JARVIS/config/entrenar.py --aplicar # guarda alias + +Como funciona: por cada accion del catalogo se dice su frase en voz alta con +Piper, en varias voces y velocidades y con algo de ruido y sala, se transcribe +con el mismo whisper que usa el asistente, y se mira si la transcripcion llega +a la accion correcta. Lo que no llega se apunta como alias candidato. + +Por que sirve: los fallos del reconocedor en castellano son sistematicos, no +aleatorios. "abre el correo" se oye "a ver el correo" siempre, y una tanda de +varios cientos de frases los saca casi todos de una sentada. + +Que NO sustituye: esto es voz sintetica, no la suya. Sirve para las colisiones +foneticas del idioma —que son la mayoria de los fallos— pero no para su acento, +su microfono ni su habitacion. Para eso esta el registro de lo que no entiende +en el uso real, que se revisa con aprender.py. Las dos cosas se suman. + +Un alias solo se propone si no choca con otra accion: si "a ver el correo" +pudiera ser dos cosas distintas, se descarta y se avisa. +""" +import argparse +import importlib.util +import json +import os +import random +import shutil +import subprocess +import sys +import tempfile +import time +from collections import defaultdict + +CASA = os.path.expanduser("~") +JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS") +CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config") +APP = "io.github.qwersyk.Newelle//master" + +PIPER = os.path.join(JARVIS, "voz/piper/piper") +MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos") +WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli") +MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models") + +CATALOGO = os.path.expanduser( + "~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/" + "active/files/share/newelle/acciones/acciones.json") +CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json") +PROPIO = os.path.join(CONFIG, "jarvis-acciones.json") +INFORME = os.path.join(JARVIS, "muestras/entrenamiento.json") + +MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py") + +# Voces distintas para que no aprenda los defectos de una sola, y velocidades +# distintas porque uno no habla igual con prisa que sin ella. +VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"] +VELOCIDADES = [1.0, 0.88, 1.15] + +# Un poco de sala y de ruido de fondo: en seco el reconocedor acierta mas de lo +# que acertara nunca en una habitacion de verdad, y entrenar con eso da una +# falsa sensacion de que todo va bien. +RUIDOS = [ + "", # limpio + "aecho=0.8:0.7:22:0.12", # algo de sala + "volume=0.75,highpass=f=120", # mas lejos del microfono +] + + +def carga_motor(): + spec = importlib.util.spec_from_file_location("acciones", MOTOR) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +def dentro(orden, limite=900): + """Ejecuta algo dentro del flatpak, que es donde vive whisper.""" + return subprocess.run( + ["flatpak", "run", "--command=bash", APP, "-c", orden], + capture_output=True, text=True, timeout=limite) + + +def genera(texto, voz, velocidad, ruido, destino): + crudo = tempfile.mktemp(suffix=".wav") + try: + r = subprocess.run( + [PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"), + "--length_scale", str(1 / velocidad), "--output_file", crudo], + input=texto, capture_output=True, text=True, timeout=60) + if r.returncode != 0 or not os.path.exists(crudo): + return False + filtros = "aresample=16000" + ("," + ruido if ruido else "") + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", "-i", crudo, + "-ac", "1", "-af", filtros, "-y", destino], + check=True, timeout=60) + return os.path.exists(destino) + except (OSError, subprocess.SubprocessError): + return False + finally: + if os.path.exists(crudo): + os.remove(crudo) + + +# Por tandas y no de una: mil frases son un cuarto de hora, y una sola llamada +# con limite de tiempo se corta a la mitad y se pierde todo lo transcrito. Por +# tandas ademas se puede ver el avance, que en algo que tarda tanto importa. +POR_TANDA = 40 + + +def transcribe_lote(carpeta, ficheros, modelo="base", hilos=8): + """Transcribe una lista de wav. Devuelve {fichero: texto}.""" + salida = {} + ini = time.time() + for i in range(0, len(ficheros), POR_TANDA): + tanda = ficheros[i:i + POR_TANDA] + lista = " ".join(f'"{carpeta}/{n}"' for n in tanda) + orden = ( + f'for f in {lista}; do ' + f' echo "### $(basename "$f")"; ' + f' {WHISPER} -m {MODELOS_STT}/ggml-{modelo}.bin -l es -nt -t {hilos} ' + f' -f "$f" 2>/dev/null | tr "\\n" " "; echo; ' + f'done') + try: + r = dentro(orden) + except subprocess.SubprocessError as e: + print(f" tanda {i//POR_TANDA + 1} fallo: {e}", flush=True) + continue + + actual = None + for linea in r.stdout.splitlines(): + if linea.startswith("### "): + actual = linea[4:].strip() + elif actual: + salida[actual] = linea.strip() + actual = None + + hechas = min(i + POR_TANDA, len(ficheros)) + queda = (time.time() - ini) / hechas * (len(ficheros) - hechas) + print(f" transcritas {hechas}/{len(ficheros)} " + f"({queda/60:.0f} min restantes)", flush=True) + return salida + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--rapido", action="store_true", help="una voz y una velocidad") + ap.add_argument("--aplicar", action="store_true", help="guardar los alias encontrados") + ap.add_argument("--tope", type=int, default=0, help="limitar acciones (para probar)") + ap.add_argument("--reanudar", action="store_true", + help="usar los wav ya generados en vez de rehacerlos") + args = ap.parse_args() + + for ruta, que in ((PIPER, "piper"), (WHISPER, "whisper")): + if not os.path.exists(ruta): + print(f"falta {que}: {ruta}") + return 1 + + catalogo = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE + with open(catalogo) as f: + acciones = json.load(f)["acciones"] + if args.tope: + acciones = acciones[:args.tope] + + voces = VOCES[:1] if args.rapido else VOCES + velocidades = VELOCIDADES[:1] if args.rapido else VELOCIDADES + ruidos = RUIDOS[:1] if args.rapido else RUIDOS + + motor = carga_motor() + cat = motor.Catalogo([catalogo]) + + # Las acciones que esperan argumento hay que decirlas CON argumento. Sin el, + # el motor las rechaza a proposito —"busca el fichero" a secas no es una + # orden— y contarlo como fallo del reconocedor es medir mal: en la primera + # tanda hundio el resultado del 48 %, con una cuarta parte de las frases + # condenadas de antemano. + RELLENOS = ["notas", "el informe de ayer", "documentos"] + + # Solo la frase principal de cada accion: las variantes ya estan escritas a + # mano y lo que se busca aqui es como se oye la que uno diria de verdad. + trabajos = [] + for a in acciones: + if not a["frases"]: + continue + frase = a["frases"][0] + if a.get("captura"): + frase = frase + " " + random.choice(RELLENOS) + for voz in voces: + for vel in velocidades: + trabajos.append((a["id"], frase, voz, vel, + random.choice(ruidos))) + + total = len(trabajos) + print(f"{len(acciones)} acciones · {len(voces)} voces · " + f"{len(velocidades)} velocidades = {total} frases habladas") + print("esto tarda; se puede dejar corriendo\n") + + carpeta = os.path.join(CONFIG, "entrenamiento") + + indice = {f"{n:05d}.wav": (ident, frase, voz, vel) + for n, (ident, frase, voz, vel, _) in enumerate(trabajos, 1)} + + if args.reanudar and os.path.isdir(carpeta): + indice = {n: v for n, v in indice.items() + if os.path.exists(os.path.join(carpeta, n))} + print(f"reanudando con {len(indice)} frases ya generadas\n", flush=True) + else: + shutil.rmtree(carpeta, ignore_errors=True) + os.makedirs(carpeta, exist_ok=True) + vivos, ini = {}, time.time() + for n, (ident, frase, voz, vel, ruido) in enumerate(trabajos, 1): + nombre = f"{n:05d}.wav" + if genera(frase, voz, vel, ruido, os.path.join(carpeta, nombre)): + vivos[nombre] = (ident, frase, voz, vel) + if n % 25 == 0 or n == total: + queda = (time.time() - ini) / n * (total - n) + print(f" generadas {n}/{total} ({queda/60:.0f} min restantes)", + flush=True) + indice = vivos + + print(f"\ntranscribiendo {len(indice)} frases...", flush=True) + textos = transcribe_lote(carpeta, sorted(indice)) + print(f"transcritas {len(textos)}\n", flush=True) + + aciertos = 0 + candidatos = defaultdict(lambda: defaultdict(int)) + for nombre, (ident, frase, voz, vel) in indice.items(): + oido = textos.get(nombre, "").strip() + if not oido: + continue + encontrada, _ = cat.busca(oido) + if encontrada is not None and encontrada.id == ident: + aciertos += 1 + else: + candidatos[ident][motor.normaliza(oido)] += 1 + + evaluadas = sum(1 for n in indice if textos.get(n)) + print(f"aciertos: {aciertos}/{evaluadas} " + f"({100*aciertos/max(1,evaluadas):.0f} %)\n") + + # Un alias solo vale si no puede ser otra cosa. Si la misma frase mal oida + # aparece en dos acciones, elegir una seria peor que no hacer nada. + duenos = defaultdict(set) + for ident, frases in candidatos.items(): + for f in frases: + duenos[f].add(ident) + + propuestos, descartados = defaultdict(list), [] + for ident, frases in candidatos.items(): + for f, veces in sorted(frases.items(), key=lambda x: -x[1]): + if len(duenos[f]) > 1: + descartados.append((f, sorted(duenos[f]))) + continue + if len(f.split()) < 2: # una palabra suelta engancha de todo + continue + # Si lo oido no se parece a lo dicho, es que el reconocedor se + # perdio del todo, no que haya una confusion fonetica estable. + # Guardarlo como alias meteria una frase arbitraria en el catalogo + # y algun dia enganchara una conversacion. + original = next((a["frases"][0] for a in acciones if a["id"] == ident), "") + if motor.parecido(f, motor.normaliza(original)) < 0.55: + descartados.append((f, [ident + " (irreconocible)"])) + continue + if cat.busca(f)[0] is not None: # ya lo coge otra cosa + continue + propuestos[ident].append((f, veces)) + + if propuestos: + print("alias que faltan (frase mal oida -> accion):") + for ident, lista in sorted(propuestos.items()): + for f, veces in lista[:3]: + print(f' "{f}" -> {ident} ({veces}x)') + else: + print("no falta ningun alias: todo lo dicho llega a su accion") + + if descartados: + print(f"\n{len(descartados)} descartados por ambiguos " + f"(la misma frase encaja en dos acciones):") + for f, ids in descartados[:5]: + print(f' "{f}" -> {", ".join(ids)}') + + os.makedirs(os.path.dirname(INFORME), exist_ok=True) + with open(INFORME, "w") as f: + json.dump({ + "frases": evaluadas, "aciertos": aciertos, + "propuestos": {k: v for k, v in propuestos.items()}, + "ambiguos": descartados, + }, f, indent=2, ensure_ascii=False) + print(f"\ninforme en {INFORME}") + + if args.aplicar and propuestos: + guarda(propuestos, catalogo) + elif propuestos: + print("para guardarlos: vuelve a lanzarlo con --aplicar") + + print(f"los wav se quedan en {carpeta} por si hay que repasar algo") + return 0 + + +def guarda(propuestos, catalogo): + with open(catalogo) as f: + original = {a["id"]: a for a in json.load(f)["acciones"]} + try: + with open(PROPIO) as f: + propio = json.load(f) + except (OSError, json.JSONDecodeError): + propio = {"version": 1, "acciones": []} + + porid = {a["id"]: a for a in propio.get("acciones", [])} + n = 0 + for ident, lista in propuestos.items(): + entrada = porid.get(ident) + if entrada is None: + entrada = dict(original[ident]) + porid[ident] = entrada + propio.setdefault("acciones", []).append(entrada) + for frase, _ in lista: + if frase not in entrada["frases"]: + entrada["frases"].append(frase) + n += 1 + + with open(PROPIO, "w") as f: + json.dump(propio, f, indent=2, ensure_ascii=False) + print(f"{n} alias guardados en {PROPIO}") + print("se aplican al abrir la pestaña JARVIS otra vez") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/indexar.py b/config/indexar.py new file mode 100755 index 0000000..480b5a6 --- /dev/null +++ b/config/indexar.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Prepara el corpus de COFRE para que JARVIS lo consulte. + + python3 ~/COFRE/CODERS/JARVIS/config/indexar.py # ver que entraria + python3 ~/COFRE/CODERS/JARVIS/config/indexar.py --hacerlo # copiarlo + python3 ~/COFRE/CODERS/JARVIS/config/indexar.py --hacerlo --solo-notas + +Que hace: recorre las carpetas de COFRE, se queda SOLO con lo que es +conocimiento y lo copia a la carpeta de documentos de Newelle, que es de donde +lee el RAG. Despues hay que pulsar "Update Index" en los ajustes de la app. + +Por que hace falta filtrar. Medido en esta maquina: + + COFRE entero (las nueve carpetas) ~120 GB + de eso, ISOs, maquinas virtuales, + APKs, audio y un diccionario de + contraseñas de 9,1 GB ~100 GB + texto de menos de 100 KB 76,6 MB en 35 098 ficheros + quitando exploitdb 20,8 MB en 4 548 ficheros + +exploitdb se queda fuera a proposito: son 32 000 ficheros que aplastarian el +indice y enterrarian tus notas, y ademas NO hacen falta ahi. Tienes +`searchsploit` instalado, que busca en ellos de forma exacta e instantanea. Un +indice de vectores es peor que una busqueda exacta cuando lo que buscas es el +nombre literal de un exploit. + +Los nombres se aplanan conservando la ruta —PENTESTERS__OSCP__nmap.md— para que +al responder se pueda ver de donde salio cada cosa. +""" +import argparse +import hashlib +import os +import shutil +import sys + +COFRE = os.path.expanduser("~/COFRE") +DESTINO = os.path.expanduser( + "~/.var/app/io.github.qwersyk.Newelle/config/models/documents") + +CARPETAS = ["CODERS", "PENTESTERS", "AUTOMATAS", "DOSER", "HARD-WARERS", + "PHISHERS", "PROTECTORS", "REVERSERS", "SNEAKERS"] + +EXTENSIONES = {".md", ".txt", ".rst", ".adoc"} + +# Por tamaño: por encima de esto ya no es prosa, son datos. El diccionario +# kaonashi.txt son 9,1 GB de contraseñas. +TAMANO_MAXIMO = 100 * 1024 +TAMANO_MINIMO = 120 # menos de esto no dice nada + +# Carpetas que no aportan conocimiento y si mucho ruido +FUERA = { + "exploitdb", # 32 000 ficheros; para eso esta searchsploit + "node_modules", ".git", "venv", ".venv", "site-packages", "__pycache__", + "isos", "VirtualBox VMs", "build", "dist", ".cache", "target", + "flatpak-app", ".flatpak-builder", "rag_cache", "entrenamiento", +} + +# Ficheros que son plantilla o licencia y solo ensucian +NOMBRES_FUERA = {"LICENSE.txt", "LICENSE.md", "CHANGELOG.md", "requirements.txt"} + + +def interesa(ruta: str, nombre: str) -> bool: + ext = os.path.splitext(nombre)[1].lower() + if ext not in EXTENSIONES or nombre in NOMBRES_FUERA: + return False + try: + tam = os.path.getsize(ruta) + except OSError: + return False + return TAMANO_MINIMO <= tam <= TAMANO_MAXIMO + + +def recorre(solo_notas: bool): + """Devuelve [(origen, nombre_plano)] de todo lo que merece indexarse.""" + salida = [] + for carpeta in CARPETAS: + raiz = os.path.join(COFRE, carpeta) + if not os.path.isdir(raiz): + continue + for dirpath, dirnames, ficheros in os.walk(raiz): + dirnames[:] = [d for d in dirnames + if d not in FUERA and not d.startswith(".")] + for nombre in ficheros: + if solo_notas and not nombre.lower().endswith(".md"): + continue + origen = os.path.join(dirpath, nombre) + if not interesa(origen, nombre): + continue + relativa = os.path.relpath(origen, COFRE) + plano = relativa.replace(os.sep, "__") + if len(plano) > 180: # algunos nombres son kilometricos + corto = hashlib.sha256(plano.encode()).hexdigest()[:8] + plano = plano[:150] + "_" + corto + os.path.splitext(plano)[1] + salida.append((origen, plano)) + return salida + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--hacerlo", action="store_true", help="copiar de verdad") + ap.add_argument("--solo-notas", action="store_true", + help="solo .md, sin los .txt sueltos") + ap.add_argument("--limpiar", action="store_true", + help="vaciar el destino antes de copiar") + args = ap.parse_args() + + print("recorriendo COFRE...", flush=True) + lista = recorre(args.solo_notas) + + porcarpeta, total = {}, 0 + for origen, _ in lista: + carpeta = os.path.relpath(origen, COFRE).split(os.sep)[0] + tam = os.path.getsize(origen) + n, b = porcarpeta.get(carpeta, (0, 0)) + porcarpeta[carpeta] = (n + 1, b + tam) + total += tam + + print(f"\n{'carpeta':<14}{'ficheros':>10}{'texto':>12}") + for carpeta in CARPETAS: + if carpeta in porcarpeta: + n, b = porcarpeta[carpeta] + print(f"{carpeta:<14}{n:>10}{b/1048576:>10.1f} MB") + print(f"{'TOTAL':<14}{len(lista):>10}{total/1048576:>10.1f} MB") + print(f"\nfragmentos de 256 tokens: ~{total // 1024}") + + if not args.hacerlo: + print("\nesto es solo el recuento; para copiarlo: --hacerlo") + return 0 + + if args.limpiar and os.path.isdir(DESTINO): + shutil.rmtree(DESTINO) + os.makedirs(DESTINO, exist_ok=True) + + copiados = saltados = 0 + for origen, plano in lista: + destino = os.path.join(DESTINO, plano) + try: + # solo si cambio: reindexar 4500 ficheros iguales no tiene sentido + if (os.path.exists(destino) + and os.path.getmtime(destino) >= os.path.getmtime(origen)): + saltados += 1 + continue + shutil.copy2(origen, destino) + copiados += 1 + except OSError as e: + print(f" no se pudo copiar {plano}: {e}") + + print(f"\n{copiados} copiados, {saltados} ya estaban al dia") + print(f"en {DESTINO}") + print("\nAhora, en Newelle: Ajustes -> RAG -> Update Index") + print("La primera vez tarda; son unos 21 000 fragmentos.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/jarvis-herramientas.json b/config/jarvis-herramientas.json new file mode 100644 index 0000000..475ff6f --- /dev/null +++ b/config/jarvis-herramientas.json @@ -0,0 +1,4197 @@ +{ + "version": 1, + "herramientas": [ + { + "nombre": "start_gui_oasis", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/start_gui_oasis.sh", + "tipo": "shell", + "descripcion": "============================== OASIS — Lanzador GUI (simple) + Desktop Entry ==============================", + "invocar": "bash /home/sito/COFRE/CODERS/start_gui_oasis.sh" + }, + { + "nombre": "start_gui", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/start_gui.sh", + "tipo": "shell", + "descripcion": "============================== OASIS — Lanzador del instalador GUI (con logs) ==============================", + "invocar": "bash /home/sito/COFRE/CODERS/start_gui.sh" + }, + { + "nombre": "main", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/FLUJOS/BACK_BACK/main.py", + "tipo": "python", + "descripcion": "Configura la fuente", + "invocar": "python3 /home/sito/COFRE/CODERS/FLUJOS/BACK_BACK/main.py" + }, + { + "nombre": ".env", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/FLUJOS/BACK_BACK/.env", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/CODERS/FLUJOS/BACK_BACK/.env" + }, + { + "nombre": "start_panel_v3", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/OASIS_USABILITY/INSTALLERS_GUI_LINLUX/start_panel_v3.sh", + "tipo": "shell", + "descripcion": "============================================================= SOLAR NET HUB — Panel v3 (WebKit2GTK) =============================================================", + "invocar": "bash /home/sito/COFRE/CODERS/OASIS_USABILITY/INSTALLERS_GUI_LINLUX/start_panel_v3.sh" + }, + { + "nombre": "keyd", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/keyd/bin/keyd", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/CODERS/keyd/bin/keyd" + }, + { + "nombre": "run", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/keyd/t/run.sh", + "tipo": "shell", + "descripcion": "TODO: make this more robust", + "invocar": "bash /home/sito/COFRE/CODERS/keyd/t/run.sh" + }, + { + "nombre": "runner", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/keyd/t/runner.py", + "tipo": "python", + "descripcion": "Copyright © 2019 Raheman Vaiya. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Softwa", + "invocar": "python3 /home/sito/COFRE/CODERS/keyd/t/runner.py" + }, + { + "nombre": "connect", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/GRANJA/02_device_lab/connect.sh", + "tipo": "shell", + "descripcion": "Conecta adb a todas las instancias redroid levantadas por docker-compose.yml", + "invocar": "bash /home/sito/COFRE/CODERS/GRANJA/02_device_lab/connect.sh" + }, + { + "nombre": "install-apk", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/GRANJA/02_device_lab/install-apk.sh", + "tipo": "shell", + "descripcion": "Instala un APK en todas las instancias redroid conectadas. Uso: ./install-apk.sh ruta/al/app.apk", + "invocar": "bash /home/sito/COFRE/CODERS/GRANJA/02_device_lab/install-apk.sh" + }, + { + "nombre": "tui", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/karvan-protocol/tui.sh", + "tipo": "shell", + "descripcion": "federated · encrypted · sovereign · hybrid-crypto P2P protocol", + "invocar": "bash /home/sito/COFRE/CODERS/karvan-protocol/tui.sh" + }, + { + "nombre": "basic", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/karvan-protocol/basic.sh", + "tipo": "shell", + "descripcion": "federated · encrypted · sovereign · hybrid-crypto P2P protocol", + "invocar": "bash /home/sito/COFRE/CODERS/karvan-protocol/basic.sh" + }, + { + "nombre": "update", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/karvan-protocol/scripts/update.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/CODERS/karvan-protocol/scripts/update.sh" + }, + { + "nombre": "build-deb", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/karvan-protocol/scripts/build-deb.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/CODERS/karvan-protocol/scripts/build-deb.sh" + }, + { + "nombre": ".lastModified", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/gephi-0.10.1/platform/.lastModified", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/CODERS/gephi-0.10.1/platform/.lastModified" + }, + { + "nombre": "scraper", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/SCRAPER/scraper.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/CODERS/SCRAPER/scraper.py" + }, + { + "nombre": "install", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/OASIS_MOBILE_OFFICIAL/oasis-oasis-android-v0.8.2/install.sh", + "tipo": "shell", + "descripcion": "Oasis is a **libre, open-source, encrypted, peer-to-peer, distributed (not decentralized!) & federated**... project networking application", + "invocar": "bash /home/sito/COFRE/CODERS/OASIS_MOBILE_OFFICIAL/oasis-oasis-android-v0.8.2/install.sh" + }, + { + "nombre": "oasis", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/OASIS_MOBILE_OFFICIAL/oasis-oasis-android-v0.8.2/oasis.sh", + "tipo": "shell", + "descripcion": "Oasis is a **libre, open-source, encrypted, peer-to-peer, distributed (not decentralized!) & federated**... project networking application", + "invocar": "bash /home/sito/COFRE/CODERS/OASIS_MOBILE_OFFICIAL/oasis-oasis-android-v0.8.2/oasis.sh" + }, + { + "nombre": "installer_oasis", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/INSTALLER/installer_oasis.sh", + "tipo": "shell", + "descripcion": "========================= OASIS Installer (simple) =========================", + "invocar": "bash /home/sito/COFRE/CODERS/INSTALLER/installer_oasis.sh" + }, + { + "nombre": "installer", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/INSTALLER/installer.sh", + "tipo": "shell", + "descripcion": "========================= SOLAR NET HUB - Installer =========================", + "invocar": "bash /home/sito/COFRE/CODERS/INSTALLER/installer.sh" + }, + { + "nombre": "server", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/andromeda/server.py", + "tipo": "python", + "descripcion": "pub-andromeda.oasis status API — lightweight Flask server", + "invocar": "python3 /home/sito/COFRE/CODERS/andromeda/server.py" + }, + { + "nombre": "_detect-amend", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/rufus/rufus/_detect-amend.sh", + "tipo": "shell", + "descripcion": "This script detects whether git commit is being executed in amend or regular mode Needed to determine whether the build number should be incremented or not.", + "invocar": "bash /home/sito/COFRE/CODERS/rufus/rufus/_detect-amend.sh" + }, + { + "nombre": "bootstrap", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/rufus/rufus/bootstrap.sh", + "tipo": "shell", + "descripcion": "Rufus: The Reliable USB Formatting Utility", + "invocar": "bash /home/sito/COFRE/CODERS/rufus/rufus/bootstrap.sh" + }, + { + "nombre": ".gitignore", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/rufus/rufus/.gitignore", + "tipo": "binario", + "descripcion": "Rufus: The Reliable USB Formatting Utility", + "invocar": "/home/sito/COFRE/CODERS/rufus/rufus/.gitignore" + }, + { + "nombre": "_set_git_hooks", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/rufus/rufus/_set_git_hooks.sh", + "tipo": "shell", + "descripcion": "Sets the git hooks on a new git development system", + "invocar": "bash /home/sito/COFRE/CODERS/rufus/rufus/_set_git_hooks.sh" + }, + { + "nombre": "_pre-commit", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/rufus/rufus/_pre-commit.sh", + "tipo": "shell", + "descripcion": "Bumps the micro version according to the number of commits on this branch To have git run this script on commit, create a \"pre-commit\" text file in .git/hooks/ with the following content: if [ -x ./_p", + "invocar": "bash /home/sito/COFRE/CODERS/rufus/rufus/_pre-commit.sh" + }, + { + "nombre": ".gitattributes", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/rufus/rufus/.gitattributes", + "tipo": "binario", + "descripcion": "Rufus: The Reliable USB Formatting Utility", + "invocar": "/home/sito/COFRE/CODERS/rufus/rufus/.gitattributes" + }, + { + "nombre": "_release", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/rufus/rufus/_release.sh", + "tipo": "shell", + "descripcion": "Rufus: The Reliable USB Formatting Utility", + "invocar": "bash /home/sito/COFRE/CODERS/rufus/rufus/_release.sh" + }, + { + "nombre": "_chver", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/rufus/rufus/_chver.sh", + "tipo": "shell", + "descripcion": "Changes the version number !!!THIS SCRIPT IS FOR INTERNAL DEVELOPER USE ONLY!!!", + "invocar": "bash /home/sito/COFRE/CODERS/rufus/rufus/_chver.sh" + }, + { + "nombre": "preview_reactor", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/preview_reactor.py", + "tipo": "python", + "descripcion": "Renderiza el reactor a PNG sin arrancar Newelle.", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/preview_reactor.py" + }, + { + "nombre": "jarvis-launcher", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/jarvis-launcher.sh", + "tipo": "shell", + "descripcion": "JARVIS — lanzador del asistente local (Newelle) sin argumentos abre JARVIS a pantalla completa, sin pasar por el chat --chat abre Newelle normal, con sus pestañas y su historial --stop ", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/jarvis-launcher.sh" + }, + { + "nombre": "build_locale", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/newelle/build_locale.sh", + "tipo": "shell", + "descripcion": "\"Newelle\"", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/newelle/build_locale.sh" + }, + { + "nombre": "06_privacidad", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/pruebas/06_privacidad.sh", + "tipo": "shell", + "descripcion": "Que siga siendo local. Es la premisa del proyecto, así que se verifica igual que cualquier otra cosa en vez de darla por hecha. Lo que SI puede salir, y no es fallo: la búsqueda web cuando el modelo d", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/pruebas/06_privacidad.sh" + }, + { + "nombre": "ejecutar", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/pruebas/ejecutar.sh", + "tipo": "shell", + "descripcion": "Pasa todas las pruebas y resume. ./ejecutar.sh todas menos la que abre la app ./ejecutar.sh --todo tambien la que abre la app (05) ./ejecutar.sh 01 04 solo esas La 05 se queda fuer", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/pruebas/ejecutar.sh" + }, + { + "nombre": "07_parar", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/pruebas/07_parar.sh", + "tipo": "shell", + "descripcion": "El botón de parar, de punta a punta. No se comprueba que el botón cambie de color: eso lo hace el HUD solo y no demuestra nada. Se comprueba que **el micrófono queda libre**, mirando el sistema de aud", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/pruebas/07_parar.sh" + }, + { + "nombre": "05_app", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/pruebas/05_app.sh", + "tipo": "shell", + "descripcion": "La app de verdad: abrir la pestaña, ponerla a pantalla completa y comprobar que la llamada arranca sola. Es la unica prueba que levanta Newelle. Se conduce por --run-action en vez de por teclado: xdot", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/pruebas/05_app.sh" + }, + { + "nombre": "04_hud", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/pruebas/04_hud.sh", + "tipo": "shell", + "descripcion": "El puente Python -> HUD, evento por evento. Se prueba sobre los ficheros que la app carga de verdad (config/jarvis-hud), no sobre los del repositorio: si se edita la copia y se olvida llevarla al fuen", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/pruebas/04_hud.sh" + }, + { + "nombre": "01_motor", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/pruebas/01_motor.sh", + "tipo": "shell", + "descripcion": "El cerebro: que Ollama responda, en GPU y sin ponerse a razonar. El razonamiento es el fallo que mas caro sale y no da la cara: no hay error ninguno, solo tarda 40 s en vez de 2,5. Por eso se mide, no", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/pruebas/01_motor.sh" + }, + { + "nombre": "02_ajustes", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/pruebas/02_ajustes.sh", + "tipo": "shell", + "descripcion": "Los ajustes de la app. Esto es lo que se descolocó una vez sin avisar: el asistente del arranque dejó el micrófono yendo a Google (tts=gtts, stt=google_sr) justo cuando se pedía lo contrario. Se compr", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/pruebas/02_ajustes.sh" + }, + { + "nombre": "03_build", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/pruebas/03_build.sh", + "tipo": "shell", + "descripcion": "Que el build instalado sea el nuestro y esté completo. Dos trampas conocidas, las dos silenciosas: - los fuentes de Python se listan a mano en src/meson.build; un fichero sin declarar ahí compila, se ", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/pruebas/03_build.sh" + }, + { + "nombre": "comun", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/pruebas/comun.sh", + "tipo": "shell", + "descripcion": "Utilidades compartidas por las pruebas. No se ejecuta suelto.", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/pruebas/comun.sh" + }, + { + "nombre": "08_acciones", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/pruebas/08_acciones.sh", + "tipo": "shell", + "descripcion": "El catalogo de acciones rapidas. Lo que se comprueba no es que el fichero exista, sino que las frases que uno diria de verdad acaben en la accion correcta, que ninguna se coma a otra, y que un argumen", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/pruebas/08_acciones.sh" + }, + { + "nombre": "09_castellano", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/pruebas/09_castellano.sh", + "tipo": "shell", + "descripcion": "Que hable y escuche en castellano, y que la voz salga grave. El tono se mide, no se supone: la queja era \"el ingles no mola\" y la respuesta facil habria sido cambiar la voz y dar por hecho que suena b", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/pruebas/09_castellano.sh" + }, + { + "nombre": "set_persona", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/set_persona.py", + "tipo": "python", + "descripcion": "Registra la personalidad JARVIS en user-custom-prompts.", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/set_persona.py" + }, + { + "nombre": "descubrir", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/descubrir.py", + "tipo": "python", + "descripcion": "Cataloga las herramientas de COFRE para que JARVIS sepa cuales hay.", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/descubrir.py" + }, + { + "nombre": "muestra_voz", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/muestra_voz.py", + "tipo": "python", + "descripcion": "Genera muestras de la voz JARVIS: seca y con tratamiento.", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/muestra_voz.py" + }, + { + "nombre": "entrenar", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/entrenar.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/entrenar.py" + }, + { + "nombre": "set_voz_piper", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/set_voz_piper.py", + "tipo": "python", + "descripcion": "Pone la voz de Piper: castellano de España, grave.", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/set_voz_piper.py" + }, + { + "nombre": "comparar_voces_es", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/comparar_voces_es.py", + "tipo": "python", + "descripcion": "Genera la misma frase en castellano con las voces masculinas disponibles.", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/comparar_voces_es.py" + }, + { + "nombre": "construir_indice", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/construir_indice.py", + "tipo": "python", + "descripcion": "Construye el indice del RAG sin abrir la interfaz.", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/construir_indice.py" + }, + { + "nombre": "indexar", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/indexar.py", + "tipo": "python", + "descripcion": "Prepara el corpus de COFRE para que JARVIS lo consulte.", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/indexar.py" + }, + { + "nombre": "set_castellano", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/set_castellano.py", + "tipo": "python", + "descripcion": "Pasa JARVIS a castellano: voz, idioma y personalidad.", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/set_castellano.py" + }, + { + "nombre": "set_ruido", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/set_ruido.py", + "tipo": "python", + "descripcion": "Endurece la escucha para que no se dispare con ruido de fuera.", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/set_ruido.py" + }, + { + "nombre": "comparar_voces", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/comparar_voces.py", + "tipo": "python", + "descripcion": "Genera la misma frase con las cuatro voces britanicas de Kokoro.", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/comparar_voces.py" + }, + { + "nombre": "jarvis-piper", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/jarvis-piper.sh", + "tipo": "shell", + "descripcion": "Voz de JARVIS con Piper: castellano de España, no latinoamericano. jarvis-piper.sh Kokoro, que es lo que trae Newelle, no tiene ni una voz de España: sus dos masculinas en españo", + "invocar": "bash /home/sito/COFRE/CODERS/JARVIS/config/jarvis-piper.sh" + }, + { + "nombre": "aprender", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/aprender.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/aprender.py" + }, + { + "nombre": "set_backend", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/JARVIS/config/set_backend.py", + "tipo": "python", + "descripcion": "Pone el cerebro en Ollama sobre GPU y mata el razonamiento.", + "invocar": "python3 /home/sito/COFRE/CODERS/JARVIS/config/set_backend.py" + }, + { + "nombre": "TODO", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/katoolin3/TODO", + "tipo": "binario", + "descripcion": "Katoolin3 brings all programs available in Kali Linux to Debian and Ubuntu.", + "invocar": "/home/sito/COFRE/CODERS/katoolin3/TODO" + }, + { + "nombre": "conf", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/katoolin3/conf.sh", + "tipo": "shell", + "descripcion": "The installation directory:", + "invocar": "bash /home/sito/COFRE/CODERS/katoolin3/conf.sh" + }, + { + "nombre": "LICENSE", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/katoolin3/LICENSE", + "tipo": "binario", + "descripcion": "Katoolin3 brings all programs available in Kali Linux to Debian and Ubuntu.", + "invocar": "/home/sito/COFRE/CODERS/katoolin3/LICENSE" + }, + { + "nombre": "katoolin3", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/katoolin3/katoolin3.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- katoolin3 - Install Kali packages on your Debian/Ubuntu machine Copyright (C) 2019 s-h-3-l-l This program is free software; you can redistribute it and/or modify it under the ter", + "invocar": "python3 /home/sito/COFRE/CODERS/katoolin3/katoolin3.py" + }, + { + "nombre": "uninstall", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/katoolin3/uninstall.sh", + "tipo": "shell", + "descripcion": "Make sure the repository gets deleted", + "invocar": "bash /home/sito/COFRE/CODERS/katoolin3/uninstall.sh" + }, + { + "nombre": "missing", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/katoolin3/maintenance/missing.py", + "tipo": "python", + "descripcion": "missing.py: Check if all packages in katoolin3s", + "invocar": "python3 /home/sito/COFRE/CODERS/katoolin3/maintenance/missing.py" + }, + { + "nombre": "search", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/katoolin3/maintenance/search.py", + "tipo": "python", + "descripcion": "search.py: Search the Kali repostiories.", + "invocar": "python3 /home/sito/COFRE/CODERS/katoolin3/maintenance/search.py" + }, + { + "nombre": "toollist", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/katoolin3/maintenance/toollist.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/CODERS/katoolin3/maintenance/toollist.py" + }, + { + "nombre": "sort", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/katoolin3/maintenance/sort.py", + "tipo": "python", + "descripcion": "sort.py: Takes katoolin3s package list, sorts it and outputs", + "invocar": "python3 /home/sito/COFRE/CODERS/katoolin3/maintenance/sort.py" + }, + { + "nombre": "socket", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/SSB_SECRETS/SSB_personal_backup_2026-05-25/socket", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/CODERS/SSB_SECRETS/SSB_personal_backup_2026-05-25/socket" + }, + { + "nombre": "secret", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/SSB_SECRETS/SSB_3DCOMUNITY/secret", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/CODERS/SSB_SECRETS/SSB_3DCOMUNITY/secret" + }, + { + "nombre": "hello", + "carpeta": "CODERS", + "ruta": "/home/sito/COFRE/CODERS/assembler/hello", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/CODERS/assembler/hello" + }, + { + "nombre": "phonesploitpro", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/phone/PhoneSploit-Pro/phonesploitpro.py", + "tipo": "python", + "descripcion": "Script : PhoneSploit Pro", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/phone/PhoneSploit-Pro/phonesploitpro.py" + }, + { + "nombre": "cmsmap", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/CMSmap/cmsmap.py", + "tipo": "python", + "descripcion": "CMSmap is a python open source CMS scanner that automates the process of detecting security flaws of the most popular CMSs. The main purpose of CMSmap is to integrate common vulnerabilities for differ", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/CMSmap/cmsmap.py" + }, + { + "nombre": "Dockerfile", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/CMSmap/setoolkit/Dockerfile", + "tipo": "binario", + "descripcion": "The Social-Engineer Toolkit (SET)", + "invocar": "/home/sito/COFRE/PENTESTERS/CMSmap/setoolkit/Dockerfile" + }, + { + "nombre": "version", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/CMSmap/cmsmap/version.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/CMSmap/cmsmap/version.py" + }, + { + "nombre": "PKG-INFO", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/CMSmap/cmsmap.egg-info/PKG-INFO", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/CMSmap/cmsmap.egg-info/PKG-INFO" + }, + { + "nombre": "cropper", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/cropper-pentesting/cropper.py", + "tipo": "python", + "descripcion": "Cropper-Pentester-Tool para uso Institucional", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/cropper-pentesting/cropper.py" + }, + { + "nombre": "mitm", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/cropper-pentesting/modules/mitm.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/cropper-pentesting/modules/mitm.py" + }, + { + "nombre": "colors", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/cropper-pentesting/modules/colors.py", + "tipo": "python", + "descripcion": "Author: Mrx04programmer Github : https://github.com/mrx04programmer", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/cropper-pentesting/modules/colors.py" + }, + { + "nombre": "language_strings", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/airgeddon/language_strings.sh", + "tipo": "shell", + "descripcion": "Title........: language_strings.sh Description..: All the translated strings that airgeddon uses are located here. Author.......: v1s1t0r Bash Version.: 4.2 or later", + "invocar": "bash /home/sito/COFRE/PENTESTERS/airgeddon/language_strings.sh" + }, + { + "nombre": ".editorconfig", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/airgeddon/.editorconfig", + "tipo": "binario", + "descripcion": "This is a multi-use bash script for Linux systems to audit wireless networks.", + "invocar": "/home/sito/COFRE/PENTESTERS/airgeddon/.editorconfig" + }, + { + "nombre": "airgeddon", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/airgeddon/airgeddon.sh", + "tipo": "shell", + "descripcion": "Title........: airgeddon.sh Description..: This is a multi-use bash script for Linux systems to audit wireless networks. Author.......: v1s1t0r Version......: 11.21 Usage........: bash airgeddon.sh Ba", + "invocar": "bash /home/sito/COFRE/PENTESTERS/airgeddon/airgeddon.sh" + }, + { + "nombre": ".airgeddonrc", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/airgeddon/.airgeddonrc", + "tipo": "binario", + "descripcion": "This is a multi-use bash script for Linux systems to audit wireless networks.", + "invocar": "/home/sito/COFRE/PENTESTERS/airgeddon/.airgeddonrc" + }, + { + "nombre": "plugin_template", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/airgeddon/plugins/plugin_template.sh", + "tipo": "shell", + "descripcion": "Global shellcheck disabled warnings shellcheck disable=SC2034,SC2154", + "invocar": "bash /home/sito/COFRE/PENTESTERS/airgeddon/plugins/plugin_template.sh" + }, + { + "nombre": "missing_dependencies", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/airgeddon/plugins/missing_dependencies.sh", + "tipo": "shell", + "descripcion": "Global shellcheck disabled warnings shellcheck disable=SC2034", + "invocar": "bash /home/sito/COFRE/PENTESTERS/airgeddon/plugins/missing_dependencies.sh" + }, + { + "nombre": "carbon", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/Carbon/carbon.py", + "tipo": "python", + "descripcion": "
", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/Carbon/carbon.py" + }, + { + "nombre": "pivotool", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/pivotool/pivotool.sh", + "tipo": "shell", + "descripcion": "pivotool -- by artusec # pivotool in ethical hacking, as expected, is a simple tool written in bash # that could help you in the post exploitati", + "invocar": "bash /home/sito/COFRE/PENTESTERS/pivotool/pivotool.sh" + }, + { + "nombre": "Dockerfile_SSH", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/pivotool/Dockerfile_SSH", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/pivotool/Dockerfile_SSH" + }, + { + "nombre": "Dockerfile_Attacker", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/pivotool/Dockerfile_Attacker", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/pivotool/Dockerfile_Attacker" + }, + { + "nombre": ".flake8", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/.flake8", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/netdata/.flake8" + }, + { + "nombre": ".eslintrc", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/.eslintrc", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/netdata/.eslintrc" + }, + { + "nombre": "cppcheck", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/cppcheck.sh", + "tipo": "shell", + "descripcion": "echo >>/tmp/cppcheck.log \"cppcheck ${*}\"", + "invocar": "bash /home/sito/COFRE/PENTESTERS/netdata/cppcheck.sh" + }, + { + "nombre": "netdata-installer", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/netdata-installer.sh", + "tipo": "shell", + "descripcion": "SPDX-License-Identifier: GPL-3.0-or-later", + "invocar": "bash /home/sito/COFRE/PENTESTERS/netdata/netdata-installer.sh" + }, + { + "nombre": "coverity-scan", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/coverity-scan.sh", + "tipo": "shell", + "descripcion": "Coverity scan script Copyright: SPDX-License-Identifier: GPL-3.0-or-later Author : Costa Tsaousis (costa@netdata.cloud) Author : Pawel Krupa (paulfantom)", + "invocar": "bash /home/sito/COFRE/PENTESTERS/netdata/coverity-scan.sh" + }, + { + "nombre": ".clang-format", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/.clang-format", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/netdata/.clang-format" + }, + { + "nombre": ".dockerignore", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/.dockerignore", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/netdata/.dockerignore" + }, + { + "nombre": ".gitmodules", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/.gitmodules", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/netdata/.gitmodules" + }, + { + "nombre": ".eslintignore", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/.eslintignore", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/netdata/.eslintignore" + }, + { + "nombre": "build", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/diagrams/build.sh", + "tipo": "shell", + "descripcion": "SPDX-License-Identifier: GPL-3.0-or-later", + "invocar": "bash /home/sito/COFRE/PENTESTERS/netdata/diagrams/build.sh" + }, + { + "nombre": "bundle-protobuf", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/packaging/bundle-protobuf.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/PENTESTERS/netdata/packaging/bundle-protobuf.sh" + }, + { + "nombre": "bundle-ebpf-co-re", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/packaging/bundle-ebpf-co-re.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/PENTESTERS/netdata/packaging/bundle-ebpf-co-re.sh" + }, + { + "nombre": "bundle-libbpf", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/packaging/bundle-libbpf.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/PENTESTERS/netdata/packaging/bundle-libbpf.sh" + }, + { + "nombre": "version", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/packaging/version", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/netdata/packaging/version" + }, + { + "nombre": "check-kernel-config", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/packaging/check-kernel-config.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/PENTESTERS/netdata/packaging/check-kernel-config.sh" + }, + { + "nombre": "build_package_install_test", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/packaging/build_package_install_test.sh", + "tipo": "shell", + "descripcion": "SPDX-License-Identifier: GPL-3.0-or-later", + "invocar": "bash /home/sito/COFRE/PENTESTERS/netdata/packaging/build_package_install_test.sh" + }, + { + "nombre": "bundle-ebpf", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/packaging/bundle-ebpf.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/PENTESTERS/netdata/packaging/bundle-ebpf.sh" + }, + { + "nombre": "gen_docs_integrations", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/integrations/gen_docs_integrations.py", + "tipo": "python", + "descripcion": "Dictionary responsible for making the symbolic links at the end of the script's run.", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/netdata/integrations/gen_docs_integrations.py" + }, + { + "nombre": "gen_integrations", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/integrations/gen_integrations.py", + "tipo": "python", + "descripcion": "To generate a copy of `integrations.js` locally, you will need:", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/netdata/integrations/gen_integrations.py" + }, + { + "nombre": "check_collector_metadata", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/integrations/check_collector_metadata.py", + "tipo": "python", + "descripcion": "To generate a copy of `integrations.js` locally, you will need:", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/netdata/integrations/check_collector_metadata.py" + }, + { + "nombre": ".install-type", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/system/.install-type", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/netdata/system/.install-type" + }, + { + "nombre": "system-info", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/daemon/system-info.sh", + "tipo": "shell", + "descripcion": "------------------------------------------------------------------------------------------------- detect the kernel", + "invocar": "bash /home/sito/COFRE/PENTESTERS/netdata/daemon/system-info.sh" + }, + { + "nombre": "nc-exporting", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/exporting/nc-exporting.sh", + "tipo": "shell", + "descripcion": "SPDX-License-Identifier: GPL-3.0-or-later", + "invocar": "bash /home/sito/COFRE/PENTESTERS/netdata/exporting/nc-exporting.sh" + }, + { + "nombre": "requests", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/netdata/autom4te.cache/requests", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/netdata/autom4te.cache/requests" + }, + { + "nombre": "evil_script", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/wifinator/evil_script.sh", + "tipo": "shell", + "descripcion": "Directorio para guardar los datos capturados", + "invocar": "bash /home/sito/COFRE/PENTESTERS/wifinator/evil_script.sh" + }, + { + "nombre": "wifi_monitor", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/wifinator/wifi_monitor.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/wifinator/wifi_monitor.py" + }, + { + "nombre": "netdiscover2", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/wifinator/netdiscover2/netdiscover2.sh", + "tipo": "shell", + "descripcion": "Configuración de la interfaz de red en modo monitor", + "invocar": "bash /home/sito/COFRE/PENTESTERS/wifinator/netdiscover2/netdiscover2.sh" + }, + { + "nombre": "graph", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/wifinator/netdiscover2/graph.py", + "tipo": "python", + "descripcion": "Extraer los nombres de las redes y las potencias en listas separadas", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/wifinator/netdiscover2/graph.py" + }, + { + "nombre": "entrada_casa_real", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/wifinator/GUARDAR_WIFIS/entrada_casa_real", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/wifinator/GUARDAR_WIFIS/entrada_casa_real" + }, + { + "nombre": "cni_dario_aparicio", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/wifinator/GUARDAR_WIFIS/cni_dario_aparicio", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/wifinator/GUARDAR_WIFIS/cni_dario_aparicio" + }, + { + "nombre": "NMCLI_MONCLOA", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/wifinator/GUARDAR_WIFIS/NMCLI_MONCLOA", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/wifinator/GUARDAR_WIFIS/NMCLI_MONCLOA" + }, + { + "nombre": "cni_principal", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/wifinator/GUARDAR_WIFIS/cni_principal", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/wifinator/GUARDAR_WIFIS/cni_principal" + }, + { + "nombre": "generateCerts", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/OSWP/WiFiChallengeLab-docker/generateCerts.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/PENTESTERS/OSWP/WiFiChallengeLab-docker/generateCerts.sh" + }, + { + "nombre": "ancrypt", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/Anubis/ancrypt.py", + "tipo": "python", + "descripcion": "
", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/Anubis/ancrypt.py" + }, + { + "nombre": "anubis", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/Anubis/anubis.py", + "tipo": "python", + "descripcion": "
", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/Anubis/anubis.py" + }, + { + "nombre": "script-obf", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/Anubis/example/script-obf.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/Anubis/example/script-obf.py" + }, + { + "nombre": "script", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/Anubis/example/script.py", + "tipo": "python", + "descripcion": "Store the puzzle (problem) size and the number of valid solutions", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/Anubis/example/script.py" + }, + { + "nombre": "Makefile", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/kismet/Makefile", + "tipo": "binario", + "descripcion": "https://www.kismetwireless.net", + "invocar": "/home/sito/COFRE/PENTESTERS/kismet/Makefile" + }, + { + "nombre": "README", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/kismet/protobuf_cpp/README", + "tipo": "binario", + "descripcion": "This directory is populated by the C++ compiled versions of the protobuf parsers; it will be", + "invocar": "/home/sito/COFRE/PENTESTERS/kismet/protobuf_cpp/README" + }, + { + "nombre": "kismetdb_strip_packets", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/kismet/log_tools/kismetdb_strip_packets", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/kismet/log_tools/kismetdb_strip_packets" + }, + { + "nombre": "make_version_h", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/kismet/tools/make_version_h.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/kismet/tools/make_version_h.py" + }, + { + "nombre": "compile_uav_conf", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/kismet/tools/compile_uav_conf.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/kismet/tools/compile_uav_conf.py" + }, + { + "nombre": "create_icao_db", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/kismet/tools/create_icao_db.py", + "tipo": "python", + "descripcion": "Downloads http://registry.faa.gov/database/ReleasableAircraft.zip and extracts it in ram to generate the aircraft ICAO database. Used during Kismet release tagging to generate the aircraft db", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/kismet/tools/create_icao_db.py" + }, + { + "nombre": "create_oui_db", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/kismet/tools/create_oui_db.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/kismet/tools/create_oui_db.py" + }, + { + "nombre": "Changelog", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/lscript/Changelog", + "tipo": "binario", + "descripcion": "*The lazy script has not been updated for a while due to me being very busy with my life. I will try my best to upload an update when I find the time*", + "invocar": "/home/sito/COFRE/PENTESTERS/lscript/Changelog" + }, + { + "nombre": "l132", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/lscript/ls/l132.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/PENTESTERS/lscript/ls/l132.sh" + }, + { + "nombre": "l131", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/lscript/ls/l131.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/PENTESTERS/lscript/ls/l131.sh" + }, + { + "nombre": "l133", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/lscript/ls/l133.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/PENTESTERS/lscript/ls/l133.sh" + }, + { + "nombre": "MANIFEST", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/exiftool/MANIFEST", + "tipo": "binario", + "descripcion": "ExifTool by Phil Harvey (philharvey66 at gmail.com)", + "invocar": "/home/sito/COFRE/PENTESTERS/exiftool/MANIFEST" + }, + { + "nombre": "Changes", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/exiftool/Changes", + "tipo": "binario", + "descripcion": "ExifTool by Phil Harvey (philharvey66 at gmail.com)", + "invocar": "/home/sito/COFRE/PENTESTERS/exiftool/Changes" + }, + { + "nombre": "build_collector", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/CrackMapExec/build_collector.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/CrackMapExec/build_collector.py" + }, + { + "nombre": "logger", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/CrackMapExec/cme/logger.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/CrackMapExec/cme/logger.py" + }, + { + "nombre": "context", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/CrackMapExec/cme/context.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/CrackMapExec/cme/context.py" + }, + { + "nombre": "crackmapexec", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/CrackMapExec/cme/crackmapexec.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/CrackMapExec/cme/crackmapexec.py" + }, + { + "nombre": "cmedb", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/CrackMapExec/cme/cmedb.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/CrackMapExec/cme/cmedb.py" + }, + { + "nombre": "connection", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/CrackMapExec/cme/connection.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/CrackMapExec/cme/connection.py" + }, + { + "nombre": "first_run", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/CrackMapExec/cme/first_run.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/CrackMapExec/cme/first_run.py" + }, + { + "nombre": "cli", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/CrackMapExec/cme/cli.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/CrackMapExec/cme/cli.py" + }, + { + "nombre": "wifi_troll", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/wifi_troll/Wifi_Troll/wifi_troll.sh", + "tipo": "shell", + "descripcion": "Herramienta creada por XDeadHackerX", + "invocar": "bash /home/sito/COFRE/PENTESTERS/wifi_troll/Wifi_Troll/wifi_troll.sh" + }, + { + "nombre": "agent", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/ligolo-ng/agent", + "tipo": "binario", + "descripcion": "Ligolo-ng : Tunneling like a VPN", + "invocar": "/home/sito/COFRE/PENTESTERS/ligolo-ng/agent" + }, + { + "nombre": "proxy", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/ligolo-ng/proxy", + "tipo": "binario", + "descripcion": "Ligolo-ng : Tunneling like a VPN", + "invocar": "/home/sito/COFRE/PENTESTERS/ligolo-ng/proxy" + }, + { + "nombre": "cd", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/wireshark/cd", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/wireshark/cd" + }, + { + "nombre": "ENERO_ANDROID_PRUEBA", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/wireshark/ENERO_ANDROID_PRUEBA", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/wireshark/ENERO_ANDROID_PRUEBA" + }, + { + "nombre": "85763edcb2114358b8ff8490b0bfa3ba", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/BurpSuiteCommunity/burpbrowser/85763edcb2114358b8ff8490b0bfa3ba", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/BurpSuiteCommunity/burpbrowser/85763edcb2114358b8ff8490b0bfa3ba" + }, + { + "nombre": "release", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/BurpSuiteCommunity/jre/release", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PENTESTERS/BurpSuiteCommunity/jre/release" + }, + { + "nombre": "Licence", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/Wi-Fi-Jammer/Licence", + "tipo": "binario", + "descripcion": "

", + "invocar": "/home/sito/COFRE/PENTESTERS/Wi-Fi-Jammer/Licence" + }, + { + "nombre": "wifijammer", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/Wi-Fi-Jammer/wifijammer.py", + "tipo": "python", + "descripcion": "

", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/Wi-Fi-Jammer/wifijammer.py" + }, + { + "nombre": "add_mitre_tags", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/PEASS-ng/scripts/add_mitre_tags.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/PEASS-ng/scripts/add_mitre_tags.py" + }, + { + "nombre": "peas2json", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/PEASS-ng/parsers/peas2json.py", + "tipo": "python", + "descripcion": "Pattern to identify main section titles", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/PEASS-ng/parsers/peas2json.py" + }, + { + "nombre": "json2pdf", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/PEASS-ng/parsers/json2pdf.py", + "tipo": "python", + "descripcion": "Privilege Escalation Awesome Scripts Parsers", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/PEASS-ng/parsers/json2pdf.py" + }, + { + "nombre": "validate_windows_version_defs", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/PEASS-ng/build_lists/validate_windows_version_defs.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/PEASS-ng/build_lists/validate_windows_version_defs.py" + }, + { + "nombre": "download_regexes", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/PEASS-ng/build_lists/download_regexes.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/PEASS-ng/build_lists/download_regexes.py" + }, + { + "nombre": "update_windows_version_defs", + "carpeta": "PENTESTERS", + "ruta": "/home/sito/COFRE/PENTESTERS/PEASS-ng/build_lists/update_windows_version_defs.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PENTESTERS/PEASS-ng/build_lists/update_windows_version_defs.py" + }, + { + "nombre": "xpath_compile", + "carpeta": "AUTOMATAS", + "ruta": "/home/sito/COFRE/AUTOMATAS/InstaPy/instapy/xpath_compile.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/AUTOMATAS/InstaPy/instapy/xpath_compile.py" + }, + { + "nombre": "options", + "carpeta": "DOSER", + "ruta": "/home/sito/COFRE/DOSER/psy/Border-Check/options.py", + "tipo": "python", + "descripcion": "BC (Border-Check) is a tool to retrieve info of traceroute tests over website navigation routes.", + "invocar": "python3 /home/sito/COFRE/DOSER/psy/Border-Check/options.py" + }, + { + "nombre": "xml_exporter", + "carpeta": "DOSER", + "ruta": "/home/sito/COFRE/DOSER/psy/Border-Check/xml_exporter.py", + "tipo": "python", + "descripcion": "-*- coding: iso-8859-15 -*-", + "invocar": "python3 /home/sito/COFRE/DOSER/psy/Border-Check/xml_exporter.py" + }, + { + "nombre": "get-pip", + "carpeta": "DOSER", + "ruta": "/home/sito/COFRE/DOSER/psy/Border-Check/get-pip.py", + "tipo": "python", + "descripcion": "You may be wondering what this giant blob of binary data here is, you might even be worried that we're up to something nefarious (good for you for being paranoid!). This is a base85 encoding of a zip ", + "invocar": "python3 /home/sito/COFRE/DOSER/psy/Border-Check/get-pip.py" + }, + { + "nombre": "webserver", + "carpeta": "DOSER", + "ruta": "/home/sito/COFRE/DOSER/psy/Border-Check/webserver.py", + "tipo": "python", + "descripcion": "-*- coding: iso-8859-15 -*-", + "invocar": "python3 /home/sito/COFRE/DOSER/psy/Border-Check/webserver.py" + }, + { + "nombre": "config", + "carpeta": "DOSER", + "ruta": "/home/sito/COFRE/DOSER/psy/Border-Check/config.py", + "tipo": "python", + "descripcion": "Border Check (BC) is a browser extension that illustrates the physical and political realities of the internet's infrastructure using free software tools.", + "invocar": "python3 /home/sito/COFRE/DOSER/psy/Border-Check/config.py" + }, + { + "nombre": "loic-net4.0", + "carpeta": "DOSER", + "ruta": "/home/sito/COFRE/DOSER/loicinstaller/LOIC/loic-net4.0.sh", + "tipo": "shell", + "descripcion": "Copyfuck © 2010 q Edited by NewEraCracker This script installs, updates and runs LOIC on Linux. Supported distributions:", + "invocar": "bash /home/sito/COFRE/DOSER/loicinstaller/LOIC/loic-net4.0.sh" + }, + { + "nombre": "WORK_IN_PROGRESS", + "carpeta": "DOSER", + "ruta": "/home/sito/COFRE/DOSER/loicinstaller/LOIC/WORK_IN_PROGRESS", + "tipo": "binario", + "descripcion": "Low Orbit Ion Cannon (**LOIC**) is an open source network stress tool, written in C#.", + "invocar": "/home/sito/COFRE/DOSER/loicinstaller/LOIC/WORK_IN_PROGRESS" + }, + { + "nombre": "loic", + "carpeta": "DOSER", + "ruta": "/home/sito/COFRE/DOSER/loicinstaller/LOIC/loic.sh", + "tipo": "shell", + "descripcion": "Copyfuck © 2010 q Edited by NewEraCracker This script installs, updates and runs LOIC on Linux. Supported distributions:", + "invocar": "bash /home/sito/COFRE/DOSER/loicinstaller/LOIC/loic.sh" + }, + { + "nombre": "covbuild", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/covbuild.sh", + "tipo": "shell", + "descripcion": "The Proxmark3 is the swiss-army tool of RFID, allowing for interactions with the vast majority of RFID tags on a global scale. Originally built by Jonathan Westhues, the device is now the goto tool fo", + "invocar": "bash /home/sito/COFRE/HARD-WARERS/proxmark3/covbuild.sh" + }, + { + "nombre": "covconfig", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/covconfig.sh", + "tipo": "shell", + "descripcion": "cov-configure --list-configured-compilers text", + "invocar": "bash /home/sito/COFRE/HARD-WARERS/proxmark3/covconfig.sh" + }, + { + "nombre": ".mailmap", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/.mailmap", + "tipo": "binario", + "descripcion": "The Proxmark3 is the swiss-army tool of RFID, allowing for interactions with the vast majority of RFID tags on a global scale. Originally built by Jonathan Westhues, the device is now the goto tool fo", + "invocar": "/home/sito/COFRE/HARD-WARERS/proxmark3/.mailmap" + }, + { + "nombre": ".lsan_suppressions", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/.lsan_suppressions", + "tipo": "binario", + "descripcion": "The Proxmark3 is the swiss-army tool of RFID, allowing for interactions with the vast majority of RFID tags on a global scale. Originally built by Jonathan Westhues, the device is now the goto tool fo", + "invocar": "/home/sito/COFRE/HARD-WARERS/proxmark3/.lsan_suppressions" + }, + { + "nombre": "covsubmit", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/covsubmit.sh", + "tipo": "shell", + "descripcion": "delete all previous tarballs", + "invocar": "bash /home/sito/COFRE/HARD-WARERS/proxmark3/covsubmit.sh" + }, + { + "nombre": "lf_IDTECK_idk50_PSK", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/traces/lf_IDTECK_idk50_PSK", + "tipo": "binario", + "descripcion": "* `.pm3` are analog signal files to be loaded with `data load` and displayed with `data plot`", + "invocar": "/home/sito/COFRE/HARD-WARERS/proxmark3/traces/lf_IDTECK_idk50_PSK" + }, + { + "nombre": "ldscript", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/armsrc/ldscript", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/HARD-WARERS/proxmark3/armsrc/ldscript" + }, + { + "nombre": "update_amiibo_tools_lua", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/client/update_amiibo_tools_lua.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/client/update_amiibo_tools_lua.py" + }, + { + "nombre": "atr_scrap_pcsctools", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/client/atr_scrap_pcsctools.py", + "tipo": "python", + "descripcion": "----------------------------------------------------------------------------- Copyright (C) Proxmark3 contributors. See AUTHORS.md for details. This program is free software: you can redistribute it a", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/client/atr_scrap_pcsctools.py" + }, + { + "nombre": "README-bitlib", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/client/README-bitlib", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/HARD-WARERS/proxmark3/client/README-bitlib" + }, + { + "nombre": "gen_pm3mfsim_script", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/client/gen_pm3mfsim_script.sh", + "tipo": "shell", + "descripcion": "Andrei Costin , 2011 gen_pm3mfsim_script.sh Generates Mifare Classic emulation script file that will load a given EML dump into PM3 and start emulation automagically", + "invocar": "bash /home/sito/COFRE/HARD-WARERS/proxmark3/client/gen_pm3mfsim_script.sh" + }, + { + "nombre": "emojis_scrap_github", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/client/emojis_scrap_github.py", + "tipo": "python", + "descripcion": "----------------------------------------------------------------------------- Borrowed initially from https://github.com/mrowa44/emojify Copyright (c) 2015 Justyna Rachowicz Copyright (C) Proxmark3 co", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/client/emojis_scrap_github.py" + }, + { + "nombre": "proxmark3", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/client/proxmark3", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/HARD-WARERS/proxmark3/client/proxmark3" + }, + { + "nombre": "build-all", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/docker/build-all.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/HARD-WARERS/proxmark3/docker/build-all.sh" + }, + { + "nombre": "get_lz4", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/common/get_lz4.sh", + "tipo": "shell", + "descripcion": "Copyright (C) Proxmark3 contributors. See AUTHORS.md for details. This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published", + "invocar": "bash /home/sito/COFRE/HARD-WARERS/proxmark3/common/get_lz4.sh" + }, + { + "nombre": "ldscript-flash", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/bootrom/ldscript-flash", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/HARD-WARERS/proxmark3/bootrom/ldscript-flash" + }, + { + "nombre": "pm3_mfd2eml", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_mfd2eml.py", + "tipo": "python", + "descripcion": "# Andrei Costin , 2011", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_mfd2eml.py" + }, + { + "nombre": "build_all_firmwares", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/build_all_firmwares.sh", + "tipo": "shell", + "descripcion": "(Need bash because of Bash Arrays) ----------------------------------------------------------------------------- Copyright (C) Proxmark3 contributors. See AUTHORS.md for details. This program is free ", + "invocar": "bash /home/sito/COFRE/HARD-WARERS/proxmark3/tools/build_all_firmwares.sh" + }, + { + "nombre": "pm3_eml2upper", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_eml2upper.sh", + "tipo": "shell", + "descripcion": "Andrei Costin , 2011 pm3_eml2upper.sh Converts PM3 Mifare Classic emulator EML file to UPPER case (for easier comparison in some text-comparison tools)", + "invocar": "bash /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_eml2upper.sh" + }, + { + "nombre": "pm3_eml2mfd", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_eml2mfd.py", + "tipo": "python", + "descripcion": "# Andrei Costin , 2011", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_eml2mfd.py" + }, + { + "nombre": "analyzesize", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/analyzesize.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/analyzesize.py" + }, + { + "nombre": "pm3_eml2lower", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_eml2lower.sh", + "tipo": "shell", + "descripcion": "Andrei Costin , 2011 pm3_eml2lower.sh Converts PM3 Mifare Classic emulator EML file to lower case (for easier comparison in some text-comparison tools)", + "invocar": "bash /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_eml2lower.sh" + }, + { + "nombre": "xorcheck", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/xorcheck.py", + "tipo": "python", + "descripcion": "xorcheck.py - find xor values for 8-bit LRC Adam Laurie http://rfidiot.org/ This code is copyright (c) Adam Laurie, 2009, All rights reserved.", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/xorcheck.py" + }, + { + "nombre": "pm3_reblay-emulating", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_reblay-emulating.py", + "tipo": "python", + "descripcion": "//-----------------------------------------------------------------------------", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_reblay-emulating.py" + }, + { + "nombre": "pm3_online_check", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_online_check.py", + "tipo": "python", + "descripcion": "pm3_online_check.py Christian Herrmann, Iceman, 2020 version = 'v1.0.5'", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_online_check.py" + }, + { + "nombre": "recover_pk", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/recover_pk.py", + "tipo": "python", + "descripcion": "Copyright (c) 2020 @doegox", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/recover_pk.py" + }, + { + "nombre": "pm3_reblay-reading", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_reblay-reading.py", + "tipo": "python", + "descripcion": "//-----------------------------------------------------------------------------", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_reblay-reading.py" + }, + { + "nombre": "release_tests", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/release_tests.sh", + "tipo": "shell", + "descripcion": "To be run from proxmark3 root directory", + "invocar": "bash /home/sito/COFRE/HARD-WARERS/proxmark3/tools/release_tests.sh" + }, + { + "nombre": "pm3_eml_mfd_test", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_eml_mfd_test.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_eml_mfd_test.py" + }, + { + "nombre": "pm3_mfdread", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_mfdread.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_mfdread.py" + }, + { + "nombre": "pm3_key_file_diff", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_key_file_diff.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_key_file_diff.py" + }, + { + "nombre": "findbits", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/findbits.py", + "tipo": "python", + "descripcion": "findbits.py - find Binary, Octal, Decimal or Hex number in bitstream Adam Laurie http://rfidiot.org/ This code is copyright (c) Adam Laurie, 2009, All rights reserved.", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/findbits.py" + }, + { + "nombre": "pm3_tests", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_tests.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_tests.sh" + }, + { + "nombre": "mkversion", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/mkversion.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/HARD-WARERS/proxmark3/tools/mkversion.sh" + }, + { + "nombre": "pm3_pm32wav", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_pm32wav.py", + "tipo": "python", + "descripcion": "Simple script to convert pm3 traces to something audible", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_pm32wav.py" + }, + { + "nombre": "pm3_mf7b_wipe", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_mf7b_wipe.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- VULNERS OPENSOURCE __________________ Vulners Project [https://vulners.com] All Rights Reserved.", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/pm3_mf7b_wipe.py" + }, + { + "nombre": "findbits_test", + "carpeta": "HARD-WARERS", + "ruta": "/home/sito/COFRE/HARD-WARERS/proxmark3/tools/findbits_test.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/HARD-WARERS/proxmark3/tools/findbits_test.py" + }, + { + "nombre": ".htaccess", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/RAT_collection/Fragus/.htaccess", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PHISHERS/RAT_collection/Fragus/.htaccess" + }, + { + "nombre": "ratte_module", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/setoolkit/modules/ratte_module.py", + "tipo": "python", + "descripcion": "These are required fields", + "invocar": "python3 /home/sito/COFRE/PHISHERS/setoolkit/modules/ratte_module.py" + }, + { + "nombre": "ratte_only_module", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/setoolkit/modules/ratte_only_module.py", + "tipo": "python", + "descripcion": "These are required fields", + "invocar": "python3 /home/sito/COFRE/PHISHERS/setoolkit/modules/ratte_only_module.py" + }, + { + "nombre": "google_analytics_attack", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/setoolkit/modules/google_analytics_attack.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/setoolkit/modules/google_analytics_attack.py" + }, + { + "nombre": "CREDITS", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/setoolkit/readme/CREDITS", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PHISHERS/setoolkit/readme/CREDITS" + }, + { + "nombre": "eggshell", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/EggShell-RAT/eggshell.py", + "tipo": "python", + "descripcion": "Created By lucas.py 8-18-16 TODO: Gain root, and fix for any system() call locally", + "invocar": "python3 /home/sito/COFRE/PHISHERS/EggShell-RAT/eggshell.py" + }, + { + "nombre": "PowershellRAT", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/Powershell-RAT/PowershellRAT.py", + "tipo": "python", + "descripcion": "Python based backdoor that uses Gmail to exfiltrate data as an e-mail attachment.", + "invocar": "python3 /home/sito/COFRE/PHISHERS/Powershell-RAT/PowershellRAT.py" + }, + { + "nombre": "updater", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/thorse/updater.py", + "tipo": "python", + "descripcion": "

THorse

", + "invocar": "python3 /home/sito/COFRE/PHISHERS/thorse/updater.py" + }, + { + "nombre": "encrypt_code", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/thorse/encrypt_code.py", + "tipo": "python", + "descripcion": "

THorse

", + "invocar": "python3 /home/sito/COFRE/PHISHERS/thorse/encrypt_code.py" + }, + { + "nombre": "payload", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/thorse/payload.py", + "tipo": "python", + "descripcion": "============================================================== 4,8 to 11 lines for \"send_mail_with_attachment()\" function ==============================================================", + "invocar": "python3 /home/sito/COFRE/PHISHERS/thorse/payload.py" + }, + { + "nombre": "installer_linux", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/thorse/installer_linux.sh", + "tipo": "shell", + "descripcion": "

THorse

", + "invocar": "bash /home/sito/COFRE/PHISHERS/thorse/installer_linux.sh" + }, + { + "nombre": "get_chrome_pass", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/thorse/get_chrome_pass.py", + "tipo": "python", + "descripcion": "

THorse

", + "invocar": "python3 /home/sito/COFRE/PHISHERS/thorse/get_chrome_pass.py" + }, + { + "nombre": "installer_linux", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/thorse/installer_linux.py", + "tipo": "python", + "descripcion": "

THorse

", + "invocar": "python3 /home/sito/COFRE/PHISHERS/thorse/installer_linux.py" + }, + { + "nombre": "paygen", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/thorse/paygen.py", + "tipo": "python", + "descripcion": "

THorse

", + "invocar": "python3 /home/sito/COFRE/PHISHERS/thorse/paygen.py" + }, + { + "nombre": "password_stealer", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/thorse/password_stealer.py", + "tipo": "python", + "descripcion": "

THorse

", + "invocar": "python3 /home/sito/COFRE/PHISHERS/thorse/password_stealer.py" + }, + { + "nombre": "get_wifi_pass", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/thorse/get_wifi_pass.py", + "tipo": "python", + "descripcion": "

THorse

", + "invocar": "python3 /home/sito/COFRE/PHISHERS/thorse/get_wifi_pass.py" + }, + { + "nombre": "banners", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/thorse/banners.py", + "tipo": "python", + "descripcion": "

THorse

", + "invocar": "python3 /home/sito/COFRE/PHISHERS/thorse/banners.py" + }, + { + "nombre": "pylintrc", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/wifiphisher/pylintrc", + "tipo": "binario", + "descripcion": "

", + "invocar": "/home/sito/COFRE/PHISHERS/wifiphisher/pylintrc" + }, + { + "nombre": "pywifiphisher", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/wifiphisher/wifiphisher/pywifiphisher.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- pylint: skip-file", + "invocar": "python3 /home/sito/COFRE/PHISHERS/wifiphisher/wifiphisher/pywifiphisher.py" + }, + { + "nombre": "prep_file", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/theZoo/prep_file.py", + "tipo": "python", + "descripcion": "Name: Prep File", + "invocar": "python3 /home/sito/COFRE/PHISHERS/theZoo/prep_file.py" + }, + { + "nombre": "theZoo", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/theZoo/theZoo.py", + "tipo": "python", + "descripcion": "Malware DB - the most awesome free malware database on the air Copyright (C) 2014, Yuval Nativ, Lahad Ludar, 5Fingers", + "invocar": "python3 /home/sito/COFRE/PHISHERS/theZoo/theZoo.py" + }, + { + "nombre": "update_handler", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/theZoo/imports/update_handler.py", + "tipo": "python", + "descripcion": "Malware DB - the most awesome free malware database on the air Copyright (C) 2014, Yuval Nativ, Lahad Ludar, 5Fingers", + "invocar": "python3 /home/sito/COFRE/PHISHERS/theZoo/imports/update_handler.py" + }, + { + "nombre": "terminal_handler", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/theZoo/imports/terminal_handler.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/theZoo/imports/terminal_handler.py" + }, + { + "nombre": "globals", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/theZoo/imports/globals.py", + "tipo": "python", + "descripcion": "Malware DB - the most awesome free malware database on the air Copyright (C) 2014, Yuval Nativ, Lahad Ludar, 5Fingers", + "invocar": "python3 /home/sito/COFRE/PHISHERS/theZoo/imports/globals.py" + }, + { + "nombre": "manysearches", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/theZoo/imports/manysearches.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/theZoo/imports/manysearches.py" + }, + { + "nombre": "eula_handler", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/theZoo/imports/eula_handler.py", + "tipo": "python", + "descripcion": "Malware DB - the most awesome free malware database on the air Copyright (C) 2014, Yuval Nativ, Lahad Ludar, 5Fingers", + "invocar": "python3 /home/sito/COFRE/PHISHERS/theZoo/imports/eula_handler.py" + }, + { + "nombre": "winreadline", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/theZoo/imports/winreadline.py", + "tipo": "python", + "descripcion": "readline.py -- Readline Alternative for Windows", + "invocar": "python3 /home/sito/COFRE/PHISHERS/theZoo/imports/winreadline.py" + }, + { + "nombre": "muchmuchstrings", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/theZoo/imports/muchmuchstrings.py", + "tipo": "python", + "descripcion": "Malware DB - the most awesome free malware database on the air Copyright (C) 2014, Yuval Nativ, Lahad Ludar, 5Fingers", + "invocar": "python3 /home/sito/COFRE/PHISHERS/theZoo/imports/muchmuchstrings.py" + }, + { + "nombre": "db_handler", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/theZoo/imports/db_handler.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/theZoo/imports/db_handler.py" + }, + { + "nombre": "prettytable", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/theZoo/imports/prettytable.py", + "tipo": "python", + "descripcion": "Copyright (c) 2009-2013, Luke Maurits All rights reserved. With contributions from: * Klein Stephane", + "invocar": "python3 /home/sito/COFRE/PHISHERS/theZoo/imports/prettytable.py" + }, + { + "nombre": "build", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/GonnaCry/src/build.py", + "tipo": "python", + "descripcion": "This directory contains the Python original code of the GonnaCry ransomware", + "invocar": "python3 /home/sito/COFRE/PHISHERS/GonnaCry/src/build.py" + }, + { + "nombre": "active_scan", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/active_scan.py", + "tipo": "python", + "descripcion": "* Performs OSINT on a domain / email / username / phone and find out information from different sources.", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/active_scan.py" + }, + { + "nombre": "config_sample", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/config_sample.py", + "tipo": "python", + "descripcion": "added to gitignore so will not be syned", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/config_sample.py" + }, + { + "nombre": "usernameOsint", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/usernameOsint.py", + "tipo": "python", + "descripcion": "* Performs OSINT on a domain / email / username / phone and find out information from different sources.", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/usernameOsint.py" + }, + { + "nombre": "osint_runner", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/osint_runner.py", + "tipo": "python", + "descripcion": "* Performs OSINT on a domain / email / username / phone and find out information from different sources.", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/osint_runner.py" + }, + { + "nombre": "domainOsint", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domainOsint.py", + "tipo": "python", + "descripcion": "* Performs OSINT on a domain / email / username / phone and find out information from different sources.", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domainOsint.py" + }, + { + "nombre": "datasploit_config", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/datasploit_config.py", + "tipo": "python", + "descripcion": "* Performs OSINT on a domain / email / username / phone and find out information from different sources.", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/datasploit_config.py" + }, + { + "nombre": "emailOsint", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/emailOsint.py", + "tipo": "python", + "descripcion": "* Performs OSINT on a domain / email / username / phone and find out information from different sources.", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/emailOsint.py" + }, + { + "nombre": "ipOsint", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/ipOsint.py", + "tipo": "python", + "descripcion": "* Performs OSINT on a domain / email / username / phone and find out information from different sources.", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/ipOsint.py" + }, + { + "nombre": "dep_check", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/dep_check.py", + "tipo": "python", + "descripcion": "* Performs OSINT on a domain / email / username / phone and find out information from different sources.", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/dep_check.py" + }, + { + "nombre": "datasploit", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/datasploit.py", + "tipo": "python", + "descripcion": "* Performs OSINT on a domain / email / username / phone and find out information from different sources.", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/datasploit.py" + }, + { + "nombre": "active_default_file_check", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/active_default_file_check.py", + "tipo": "python", + "descripcion": "* Performs OSINT on a domain / email / username / phone and find out information from different sources.", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/active_default_file_check.py" + }, + { + "nombre": "vault", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/vault.py", + "tipo": "python", + "descripcion": "* Performs OSINT on a domain / email / username / phone and find out information from different sources.", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/vault.py" + }, + { + "nombre": "ip_shodan", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/ip/ip_shodan.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/ip/ip_shodan.py" + }, + { + "nombre": "ip_whois", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/ip/ip_whois.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/ip/ip_whois.py" + }, + { + "nombre": "template", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/ip/template.py", + "tipo": "python", + "descripcion": "Control whether the module is enabled or not", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/ip/template.py" + }, + { + "nombre": "ip_virustotal", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/ip/ip_virustotal.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/ip/ip_virustotal.py" + }, + { + "nombre": "base", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/ip/base.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/ip/base.py" + }, + { + "nombre": "email_basic_checks", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/emails/email_basic_checks.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/emails/email_basic_checks.py" + }, + { + "nombre": "email_pastes", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/emails/email_pastes.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/emails/email_pastes.py" + }, + { + "nombre": "email_slideshare", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/emails/email_slideshare.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/emails/email_slideshare.py" + }, + { + "nombre": "email_fullcontact", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/emails/email_fullcontact.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/emails/email_fullcontact.py" + }, + { + "nombre": "email_whoismind", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/emails/email_whoismind.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/emails/email_whoismind.py" + }, + { + "nombre": "email_scribd", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/emails/email_scribd.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/emails/email_scribd.py" + }, + { + "nombre": "email_hacked_emails", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/emails/email_hacked_emails.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/emails/email_hacked_emails.py" + }, + { + "nombre": "email_haveibeenpwned", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/emails/email_haveibeenpwned.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/emails/email_haveibeenpwned.py" + }, + { + "nombre": "email_clearbit", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/emails/email_clearbit.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/emails/email_clearbit.py" + }, + { + "nombre": "username_keybase", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/username/username_keybase.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/username/username_keybase.py" + }, + { + "nombre": "username_gitemails", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/username/username_gitemails.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/username/username_gitemails.py" + }, + { + "nombre": "username_usernamesearch", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/username/username_usernamesearch.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/username/username_usernamesearch.py" + }, + { + "nombre": "username_gitscrape", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/username/username_gitscrape.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/username/username_gitscrape.py" + }, + { + "nombre": "username_redditdetails", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/username/username_redditdetails.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/username/username_redditdetails.py" + }, + { + "nombre": "username_twitterdetails", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/username/username_twitterdetails.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/username/username_twitterdetails.py" + }, + { + "nombre": "username_traviscidetails", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/username/username_traviscidetails.py", + "tipo": "python", + "descripcion": "Credits: https://github.com/int0x80/tcispy", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/username/username_traviscidetails.py" + }, + { + "nombre": "username_profilepic", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/username/username_profilepic.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/username/username_profilepic.py" + }, + { + "nombre": "username_tinder", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/username/username_tinder.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/username/username_tinder.py" + }, + { + "nombre": "username_gituserdetails", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/username/username_gituserdetails.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/username/username_gituserdetails.py" + }, + { + "nombre": "username_youtubedetails", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/username/username_youtubedetails.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/username/username_youtubedetails.py" + }, + { + "nombre": "username_gitlabdetails", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/username/username_gitlabdetails.py", + "tipo": "python", + "descripcion": "module dependencies", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/username/username_gitlabdetails.py" + }, + { + "nombre": "domain_censys", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_censys.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_censys.py" + }, + { + "nombre": "domain_emailhunter", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_emailhunter.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_emailhunter.py" + }, + { + "nombre": "domain_dnsrecords", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_dnsrecords.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_dnsrecords.py" + }, + { + "nombre": "domain_shodan", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_shodan.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_shodan.py" + }, + { + "nombre": "domain_subdomains", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_subdomains.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_subdomains.py" + }, + { + "nombre": "domain_zoomeye", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_zoomeye.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_zoomeye.py" + }, + { + "nombre": "domain_wikileaks", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_wikileaks.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_wikileaks.py" + }, + { + "nombre": "domain_googletracking", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_googletracking.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_googletracking.py" + }, + { + "nombre": "domain_forumsearch", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_forumsearch.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_forumsearch.py" + }, + { + "nombre": "domain_wappalyzer", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_wappalyzer.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_wappalyzer.py" + }, + { + "nombre": "domain_pastes", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_pastes.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_pastes.py" + }, + { + "nombre": "domain_checkpunkspider", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_checkpunkspider.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_checkpunkspider.py" + }, + { + "nombre": "domain_GooglePDF", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_GooglePDF.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_GooglePDF.py" + }, + { + "nombre": "domain_github", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_github.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_github.py" + }, + { + "nombre": "domain_urlscanio", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_urlscanio.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_urlscanio.py" + }, + { + "nombre": "domain_pagelinks", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_pagelinks.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_pagelinks.py" + }, + { + "nombre": "domain_whois", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_whois.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_whois.py" + }, + { + "nombre": "domain_history", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/datasploit/domain/domain_history.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/PHISHERS/datasploit/domain/domain_history.py" + }, + { + "nombre": ".DS_Store", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/rat-collection/10 DroidJack/.DS_Store", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/PHISHERS/rat-collection/10 DroidJack/.DS_Store" + }, + { + "nombre": "SocialBox", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/SocialBox/SocialBox.sh", + "tipo": "shell", + "descripcion": "SocialBox is a Bruteforce Attack Framework [ Facebook , Gmail , Instagram ,Twitter ] , Coded By Belahsan Ouerghi", + "invocar": "bash /home/sito/COFRE/PHISHERS/SocialBox/SocialBox.sh" + }, + { + "nombre": "install-sb", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/SocialBox/install-sb.sh", + "tipo": "shell", + "descripcion": "SocialBox is a Bruteforce Attack Framework [ Facebook , Gmail , Instagram ,Twitter ] , Coded By Belahsan Ouerghi", + "invocar": "bash /home/sito/COFRE/PHISHERS/SocialBox/install-sb.sh" + }, + { + "nombre": "gemailhack", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/SocialBox/Gemail-Hack/gemailhack.py", + "tipo": "python", + "descripcion": "python script for Hack gmail account brute force", + "invocar": "python3 /home/sito/COFRE/PHISHERS/SocialBox/Gemail-Hack/gemailhack.py" + }, + { + "nombre": "Pipfile", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/Pipfile", + "tipo": "binario", + "descripcion": "King Phisher [![Documentation Status][doc-status]][doc-link] [![GitHub Issues][issue-status]][issue-link] [![GitHub Downloads][downloads-status]][downloads-link] [![Slack Status][slack-status]][slack-", + "invocar": "/home/sito/COFRE/PHISHERS/king-phisher/Pipfile" + }, + { + "nombre": "debug_smtp_server", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/tools/debug_smtp_server.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- tools/debug_smtp_server.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/tools/debug_smtp_server.py" + }, + { + "nombre": "targets_from_recon_ng", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/tools/targets_from_recon_ng.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- tools/targets_from_recon_ng.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/tools/targets_from_recon_ng.py" + }, + { + "nombre": "otp_enroll", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/tools/otp_enroll.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- tools/otp_enroll.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/tools/otp_enroll.py" + }, + { + "nombre": "certbot_wrapper", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/tools/certbot_wrapper.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- tools/certbot_wrapper.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/tools/certbot_wrapper.py" + }, + { + "nombre": "spf_check", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/tools/spf_check.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- tools/spf_check.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/tools/spf_check.py" + }, + { + "nombre": "database_console", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/tools/database_console.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- tools/database_console.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/tools/database_console.py" + }, + { + "nombre": "serializers", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/serializers.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/serializers.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/serializers.py" + }, + { + "nombre": "catalog", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/catalog.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/catalog.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/catalog.py" + }, + { + "nombre": "find", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/find.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/find.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/find.py" + }, + { + "nombre": "errors", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/errors.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/errors.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/errors.py" + }, + { + "nombre": "its", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/its.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/its.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/its.py" + }, + { + "nombre": "ssh_forward", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/ssh_forward.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/ssh_forward.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/ssh_forward.py" + }, + { + "nombre": "utilities", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/utilities.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/utilities.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/utilities.py" + }, + { + "nombre": "ics", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/ics.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/ics.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/ics.py" + }, + { + "nombre": "spf", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/spf.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/spf.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/spf.py" + }, + { + "nombre": "templates", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/templates.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/templates.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/templates.py" + }, + { + "nombre": "constants", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/constants.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/constants.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/constants.py" + }, + { + "nombre": "color", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/color.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/color.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/color.py" + }, + { + "nombre": "smtp_server", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/smtp_server.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/smtp_server.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/smtp_server.py" + }, + { + "nombre": "startup", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/startup.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/startup.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/startup.py" + }, + { + "nombre": "sms", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/sms.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/sms.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/sms.py" + }, + { + "nombre": "security_keys", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/security_keys.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/security_keys.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/security_keys.py" + }, + { + "nombre": "testing", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/testing.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/testing.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/testing.py" + }, + { + "nombre": "xor", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/xor.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/xor.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/xor.py" + }, + { + "nombre": "archive", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/archive.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/archive.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/archive.py" + }, + { + "nombre": "geoip", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/geoip.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/geoip.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/geoip.py" + }, + { + "nombre": "ua_parser", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/ua_parser.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/ua_parser.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/ua_parser.py" + }, + { + "nombre": "ipaddress", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/ipaddress.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/ipaddress.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/ipaddress.py" + }, + { + "nombre": "plugins", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/king-phisher/king_phisher/plugins.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*- king_phisher/plugins.py Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are", + "invocar": "python3 /home/sito/COFRE/PHISHERS/king-phisher/king_phisher/plugins.py" + }, + { + "nombre": "lockphish", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/lockphish/lockphish.sh", + "tipo": "shell", + "descripcion": "Coded by: github.com/kali-linux-tutorial/lockphish Twitter: twitter.com/kalilinux_in https://www.kalilinux.in Read the License before using any part from this code.", + "invocar": "bash /home/sito/COFRE/PHISHERS/lockphish/lockphish.sh" + }, + { + "nombre": "ngrok", + "carpeta": "PHISHERS", + "ruta": "/home/sito/COFRE/PHISHERS/lockphish/ngrok", + "tipo": "binario", + "descripcion": "Lockphish it's the first tool (07/04/2020) for phishing attacks on the lock screen, designed to grab Windows credentials, Android PIN and iPhone Passcode using a https link.", + "invocar": "/home/sito/COFRE/PHISHERS/lockphish/ngrok" + }, + { + "nombre": "autogen", + "carpeta": "PROTECTORS", + "ruta": "/home/sito/COFRE/PROTECTORS/cryptsetup/autogen.sh", + "tipo": "shell", + "descripcion": "Run this to generate all the initial makefiles, etc.", + "invocar": "bash /home/sito/COFRE/PROTECTORS/cryptsetup/autogen.sh" + }, + { + "nombre": "meson_dist_convert", + "carpeta": "PROTECTORS", + "ruta": "/home/sito/COFRE/PROTECTORS/cryptsetup/man/meson_dist_convert.sh", + "tipo": "shell", + "descripcion": "generates manpages from AsciiDoc files when building dist tarball run asciidoctor in parallel on `nproc` cores", + "invocar": "bash /home/sito/COFRE/PROTECTORS/cryptsetup/man/meson_dist_convert.sh" + }, + { + "nombre": "env", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/env.sh", + "tipo": "shell", + "descripcion": "\"screenshot\"", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/env.sh" + }, + { + "nombre": "ldconfig", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/ldconfig.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/ldconfig.sh" + }, + { + "nombre": "android-build", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/android-build.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/android-build.sh" + }, + { + "nombre": "wasm", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/wasm.sh", + "tipo": "shell", + "descripcion": "TODO: add support for ccache", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/wasm.sh" + }, + { + "nombre": "ref", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/ref.sh", + "tipo": "shell", + "descripcion": "> git grep R_API open.c |cut -d : -f 2- | sed -e 's, {,;,'", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/ref.sh" + }, + { + "nombre": "make-jobs.inc", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/make-jobs.inc.sh", + "tipo": "shell", + "descripcion": "Get OS and platform to decide if we need to limit memory usage during the build", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/make-jobs.inc.sh" + }, + { + "nombre": "create_r2", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/create_r2.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/create_r2.sh" + }, + { + "nombre": "filc", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/filc.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/filc.sh" + }, + { + "nombre": "menu", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/menu.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/menu.sh" + }, + { + "nombre": "hex2rapatch", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/hex2rapatch.py", + "tipo": "python", + "descripcion": "Portable python script to convert Intel hex file to rapatch", + "invocar": "python3 /home/sito/COFRE/REVERSERS/radare2/sys/hex2rapatch.py" + }, + { + "nombre": "wasi", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/wasi.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/wasi.sh" + }, + { + "nombre": "jq-r2r-js", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/jq-r2r-js.sh", + "tipo": "shell", + "descripcion": "Parse r2r JSON output to inspect test results The r2r.json file is generated by `r2r -j` and can be taken from CI Useful to find out which tests are broken, fixed, failed, or slow", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/jq-r2r-js.sh" + }, + { + "nombre": "debian", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/debian.sh", + "tipo": "shell", + "descripcion": "run this from a debian system, docker is fine :)", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/debian.sh" + }, + { + "nombre": "clone-r2-bindings", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/clone-r2-bindings.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/clone-r2-bindings.sh" + }, + { + "nombre": "clang-analyzer", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/clang-analyzer.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/clang-analyzer.sh" + }, + { + "nombre": "build-shlib", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/build-shlib.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/build-shlib.sh" + }, + { + "nombre": "ios-static", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/ios-static.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/ios-static.sh" + }, + { + "nombre": "ios-cydia32", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/ios-cydia32.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/ios-cydia32.sh" + }, + { + "nombre": "r2env", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/r2env.sh", + "tipo": "shell", + "descripcion": "Sets up env for programs that use r2 libs, and r2 is installed in a non-standard location.", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/r2env.sh" + }, + { + "nombre": "lint-assert", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/lint-assert.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/lint-assert.sh" + }, + { + "nombre": "subst", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/subst.sh", + "tipo": "shell", + "descripcion": "[ -z \"$a\" ] && a=\"r_str_chop \"", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/subst.sh" + }, + { + "nombre": "r2api", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/r2api.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/r2api.sh" + }, + { + "nombre": "translate", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/translate.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/translate.sh" + }, + { + "nombre": "check_includes", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/check_includes.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/REVERSERS/radare2/sys/check_includes.py" + }, + { + "nombre": "docker_build_alpine_image", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/docker_build_alpine_image.sh", + "tipo": "shell", + "descripcion": "Requires ~140MB of free disk space Build docker image with: $ ./sys/docker_build_alpine_image.sh", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/docker_build_alpine_image.sh" + }, + { + "nombre": "mingw32", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/mingw32.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/mingw32.sh" + }, + { + "nombre": "macos-env", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/macos-env.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/macos-env.sh" + }, + { + "nombre": "android-shell", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/android-shell.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/android-shell.sh" + }, + { + "nombre": "android-ndk-install", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/android-ndk-install.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/android-ndk-install.sh" + }, + { + "nombre": "macos-cert", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/macos-cert.sh", + "tipo": "shell", + "descripcion": "Credits to https://github.com/derekparker/delve/blob/master/scripts/gencert.sh", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/macos-cert.sh" + }, + { + "nombre": "heaptrack", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/heaptrack.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/heaptrack.sh" + }, + { + "nombre": "derizin", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/derizin.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/derizin.sh" + }, + { + "nombre": "tarball", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/tarball.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/tarball.sh" + }, + { + "nombre": "sanitize", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/sanitize.sh", + "tipo": "shell", + "descripcion": "SANITIZE=\"address leak memory undefined\" SANITIZE=\"address signed-integer-overflow\" # Faster build SANITIZE=\"-fsanitize-address-use-after-return=always\"", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/sanitize.sh" + }, + { + "nombre": "sdk", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/sdk.sh", + "tipo": "shell", + "descripcion": "Unified SDK script", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/sdk.sh" + }, + { + "nombre": "wasi-common", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/wasi-common.sh", + "tipo": "shell", + "descripcion": "Common functions for WASI build scripts", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/wasi-common.sh" + }, + { + "nombre": "android-release", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/android-release.sh", + "tipo": "shell", + "descripcion": "path to radare2-bin repo", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/android-release.sh" + }, + { + "nombre": "tiny", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/tiny.sh", + "tipo": "shell", + "descripcion": "TODO: add support for ccache", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/tiny.sh" + }, + { + "nombre": "purge", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/purge.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/purge.sh" + }, + { + "nombre": "termux", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/termux.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/termux.sh" + }, + { + "nombre": "mipsbe", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/mipsbe.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/mipsbe.sh" + }, + { + "nombre": "static", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/static.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/static.sh" + }, + { + "nombre": "profiling", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/profiling.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/profiling.sh" + }, + { + "nombre": "counters", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/counters.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/counters.sh" + }, + { + "nombre": "extras", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/extras.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/extras.sh" + }, + { + "nombre": "dist", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/dist.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/dist.sh" + }, + { + "nombre": "release-notes", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/release-notes.sh", + "tipo": "shell", + "descripcion": "This script parses the commit messages between the last 2 git tags if the last commit contains the word \"Release\", otherwise it shows all the changes between the last tag and HEAD. Commits are grouped", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/release-notes.sh" + }, + { + "nombre": "sdk-common", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/sdk-common.sh", + "tipo": "shell", + "descripcion": "Common functions for SDK building", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/sdk-common.sh" + }, + { + "nombre": "ios-simulator", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/ios-simulator.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/ios-simulator.sh" + }, + { + "nombre": "sdk-ios", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/sdk-ios.sh", + "tipo": "shell", + "descripcion": "iOS specific variables", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/sdk-ios.sh" + }, + { + "nombre": "afl", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/afl.sh", + "tipo": "shell", + "descripcion": "export AFL_USE_ASAN=1", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/afl.sh" + }, + { + "nombre": "lint", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/lint.sh", + "tipo": "shell", + "descripcion": "NAME=no preincrement/predecrement in 3rd part of for statement", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/lint.sh" + }, + { + "nombre": "build-m32", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/build-m32.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/build-m32.sh" + }, + { + "nombre": "ios-static-appstore", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/ios-static-appstore.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/ios-static-appstore.sh" + }, + { + "nombre": "ios-env", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/ios-env.sh", + "tipo": "shell", + "descripcion": "Default building in /var/jb/user prefix. use ROOTLESS=0 to rootful packaging", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/ios-env.sh" + }, + { + "nombre": "wasi-browser", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/wasi-browser.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/wasi-browser.sh" + }, + { + "nombre": "prai", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/prai.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/prai.sh" + }, + { + "nombre": "cross", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/cross.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/cross.sh" + }, + { + "nombre": "osx-pkg", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/osx-pkg.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/osx-pkg.sh" + }, + { + "nombre": "sync", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/sync.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/sync.sh" + }, + { + "nombre": "user", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/user.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/user.sh" + }, + { + "nombre": "download-artifacts", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/download-artifacts.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/download-artifacts.sh" + }, + { + "nombre": "dist-all", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/dist-all.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/dist-all.sh" + }, + { + "nombre": "update_urls", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/update_urls.sh", + "tipo": "shell", + "descripcion": "Script to update HTTP URLs to HTTPS in source files Only updates domains that are known to support HTTPS", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/update_urls.sh" + }, + { + "nombre": "ios-cydia", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/ios-cydia.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/ios-cydia.sh" + }, + { + "nombre": "shellcheck", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/shellcheck.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/shellcheck.sh" + }, + { + "nombre": "codespell", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/codespell.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/codespell.sh" + }, + { + "nombre": "wasi-api", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/wasi-api.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/wasi-api.sh" + }, + { + "nombre": "dist-bin", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/dist-bin.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/dist-bin.sh" + }, + { + "nombre": "install-rev", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/install-rev.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/install-rev.sh" + }, + { + "nombre": "build-harden", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/build-harden.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/build-harden.sh" + }, + { + "nombre": "sdk-macos", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/sdk-macos.sh", + "tipo": "shell", + "descripcion": "macOS SDK builder", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/sdk-macos.sh" + }, + { + "nombre": "wasi-env", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/wasi-env.sh", + "tipo": "shell", + "descripcion": "Detect architecture and OS for the SDK path", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/wasi-env.sh" + }, + { + "nombre": "release", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/release.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/release.sh" + }, + { + "nombre": "meson", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/meson.py", + "tipo": "python", + "descripcion": "Meson build for radare2", + "invocar": "python3 /home/sito/COFRE/REVERSERS/radare2/sys/meson.py" + }, + { + "nombre": "user-uninstall", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/user-uninstall.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/user-uninstall.sh" + }, + { + "nombre": "emscripten", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/emscripten.sh", + "tipo": "shell", + "descripcion": "TODO: add support for ccache", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/emscripten.sh" + }, + { + "nombre": "sdb_version", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/sdb_version.py", + "tipo": "python", + "descripcion": "Portable python script to read version from configure.acr", + "invocar": "python3 /home/sito/COFRE/REVERSERS/radare2/sys/sdb_version.py" + }, + { + "nombre": "tcc", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/tcc.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/tcc.sh" + }, + { + "nombre": "test-egg-shellcodes", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/test-egg-shellcodes.sh", + "tipo": "shell", + "descripcion": "Test shellcode assembly files against original C versions Usage: sys/test-egg-shellcodes.sh [path-to-sc-dir]", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/test-egg-shellcodes.sh" + }, + { + "nombre": "rebuild", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/rebuild.sh", + "tipo": "shell", + "descripcion": "Requires GNU Make, but some distros probably don't have the gmake symlink.", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/rebuild.sh" + }, + { + "nombre": "ios-libr", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/ios-libr.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/ios-libr.sh" + }, + { + "nombre": "meson-sdk", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/meson-sdk.sh", + "tipo": "shell", + "descripcion": "sys/meson.py || exit 1", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/meson-sdk.sh" + }, + { + "nombre": "zig", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/zig.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/zig.sh" + }, + { + "nombre": "msvc", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/msvc.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/msvc.sh" + }, + { + "nombre": "mingw64", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/mingw64.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/mingw64.sh" + }, + { + "nombre": "install-debs", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/sys/install-debs.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/sys/install-debs.sh" + }, + { + "nombre": "stripsyms", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/libr/stripsyms.sh", + "tipo": "shell", + "descripcion": "skip this thing. it slow downs the build too much", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/libr/stripsyms.sh" + }, + { + "nombre": "count", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/libr/count.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/libr/count.sh" + }, + { + "nombre": "bash_autocompletion", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/doc/bash_autocompletion.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/doc/bash_autocompletion.sh" + }, + { + "nombre": "dexdump", + "carpeta": "REVERSERS", + "ruta": "/home/sito/COFRE/REVERSERS/radare2/doc/dexdump.sh", + "tipo": "shell", + "descripcion": "Import DEX/Dalvik information from `dexdump` r2 -c '.!doc/dexdump.sh $FILE' classes.dex", + "invocar": "bash /home/sito/COFRE/REVERSERS/radare2/doc/dexdump.sh" + }, + { + "nombre": "CODEGUIDELINES", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/DEEPFAKE/DeepFaceLab/CODEGUIDELINES", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/SNEAKERS/DEEPFAKE/DeepFaceLab/CODEGUIDELINES" + }, + { + "nombre": "THANKS", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Gource/THANKS", + "tipo": "binario", + "descripcion": "Gource is a visualization tool for source control repositories.", + "invocar": "/home/sito/COFRE/SNEAKERS/Gource/THANKS" + }, + { + "nombre": "COPYING", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Gource/COPYING", + "tipo": "binario", + "descripcion": "Gource is a visualization tool for source control repositories.", + "invocar": "/home/sito/COFRE/SNEAKERS/Gource/COPYING" + }, + { + "nombre": "INSTALL", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Gource/INSTALL", + "tipo": "binario", + "descripcion": "Gource is a visualization tool for source control repositories.", + "invocar": "/home/sito/COFRE/SNEAKERS/Gource/INSTALL" + }, + { + "nombre": "svn-gource", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Gource/contrib/svn-gource.py", + "tipo": "python", + "descripcion": "Copyright (c) 2009 Cameron Hart (cam@bitshifter.net.nz) All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following ", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Gource/contrib/svn-gource.py" + }, + { + "nombre": "POLIS_CACOS_ACTIVISTAS", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/HACKMEETING/POLIS_CACOS_ACTIVISTAS", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/SNEAKERS/HACKMEETING/POLIS_CACOS_ACTIVISTAS" + }, + { + "nombre": "automate", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/automate.py", + "tipo": "python", + "descripcion": "you can change the name of each \"job\" after \"def\" if you'd like.", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/automate.py" + }, + { + "nombre": "feed", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/twint/feed.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/twint/feed.py" + }, + { + "nombre": "verbose", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/twint/verbose.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/twint/verbose.py" + }, + { + "nombre": "run", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/twint/run.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/twint/run.py" + }, + { + "nombre": "url", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/twint/url.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/twint/url.py" + }, + { + "nombre": "token", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/twint/token.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/twint/token.py" + }, + { + "nombre": "output", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/twint/output.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/twint/output.py" + }, + { + "nombre": "datelock", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/twint/datelock.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/twint/datelock.py" + }, + { + "nombre": "user", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/twint/user.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/twint/user.py" + }, + { + "nombre": "format", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/twint/format.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/twint/format.py" + }, + { + "nombre": "get", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/twint/get.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/twint/get.py" + }, + { + "nombre": "__version__", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/twint/__version__.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/twint/__version__.py" + }, + { + "nombre": "tweet", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/twint/twint/tweet.py", + "tipo": "python", + "descripcion": "- https://github.com/x0rzkov/py-googletrans#basic-usage", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/twint/twint/tweet.py" + }, + { + "nombre": "seeker", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/seeker/seeker/seeker.py", + "tipo": "python", + "descripcion": "

", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/seeker/seeker/seeker.py" + }, + { + "nombre": "infer-web", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/infer-web.py", + "tipo": "python", + "descripcion": "

Retrieval-based-Voice-Conversion-WebUI

", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/infer-web.py" + }, + { + "nombre": "gui_v1", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/gui_v1.py", + "tipo": "python", + "descripcion": "

Retrieval-based-Voice-Conversion-WebUI

", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/gui_v1.py" + }, + { + "nombre": "venv", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/venv.sh", + "tipo": "shell", + "descripcion": "

Retrieval-based-Voice-Conversion-WebUI

", + "invocar": "bash /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/venv.sh" + }, + { + "nombre": "MIT协议暨相关引用库协议", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/MIT协议暨相关引用库协议", + "tipo": "binario", + "descripcion": "

Retrieval-based-Voice-Conversion-WebUI

", + "invocar": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/MIT协议暨相关引用库协议" + }, + { + "nombre": "i18n", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/i18n/i18n.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/i18n/i18n.py" + }, + { + "nombre": "scan_i18n", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/i18n/scan_i18n.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/i18n/scan_i18n.py" + }, + { + "nombre": "locale_diff", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/i18n/locale_diff.py", + "tipo": "python", + "descripcion": "Define the standard file name", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/i18n/locale_diff.py" + }, + { + "nombre": "rvc_for_realtime", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/rvc_for_realtime.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/rvc_for_realtime.py" + }, + { + "nombre": "infer_cli", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/infer_cli.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/infer_cli.py" + }, + { + "nombre": "calc_rvc_model_similarity", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/calc_rvc_model_similarity.py", + "tipo": "python", + "descripcion": "Fill in the path of the model to be queried and the root directory of the reference models, and this script will return the similarity between the model to be queried and all reference models.", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/calc_rvc_model_similarity.py" + }, + { + "nombre": "download_models", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/download_models.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/download_models.py" + }, + { + "nombre": "export_onnx", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/export_onnx.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/export_onnx.py" + }, + { + "nombre": "infer_batch_rvc", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/infer_batch_rvc.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/infer_batch_rvc.py" + }, + { + "nombre": "onnx_inference_demo", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/onnx_inference_demo.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/onnx_inference_demo.py" + }, + { + "nombre": "dlmodels", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/dlmodels.sh", + "tipo": "shell", + "descripcion": "", + "invocar": "bash /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/dlmodels.sh" + }, + { + "nombre": "app", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/app.py", + "tipo": "python", + "descripcion": "os.system(\"wget -P cvec/ https://huggingface.co/lj1995/VoiceConversionWebUI/resolve/main/hubert_base.pt\")", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/Retrieval-based-Voice-Conversion-WebUI/tools/app.py" + }, + { + "nombre": "test_wholeimage_swapmulti", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/test_wholeimage_swapmulti.py", + "tipo": "python", + "descripcion": "Author: Naiyuan liu", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/test_wholeimage_swapmulti.py" + }, + { + "nombre": "test_wholeimage_swapspecific", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/test_wholeimage_swapspecific.py", + "tipo": "python", + "descripcion": "Author: Naiyuan liu", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/test_wholeimage_swapspecific.py" + }, + { + "nombre": "download-weights", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/download-weights.sh", + "tipo": "shell", + "descripcion": "SimSwap: An Efficient Framework For High Fidelity Face Swapping", + "invocar": "bash /home/sito/COFRE/SNEAKERS/SimSwap/download-weights.sh" + }, + { + "nombre": "test_video_swapmulti", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/test_video_swapmulti.py", + "tipo": "python", + "descripcion": "Author: Naiyuan liu", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/test_video_swapmulti.py" + }, + { + "nombre": "predict", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/predict.py", + "tipo": "python", + "descripcion": "SimSwap: An Efficient Framework For High Fidelity Face Swapping", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/predict.py" + }, + { + "nombre": "test_wholeimage_swap_multispecific", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/test_wholeimage_swap_multispecific.py", + "tipo": "python", + "descripcion": "Author: Naiyuan liu", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/test_wholeimage_swap_multispecific.py" + }, + { + "nombre": "test_wholeimage_swapsingle", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/test_wholeimage_swapsingle.py", + "tipo": "python", + "descripcion": "Author: Naiyuan liu", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/test_wholeimage_swapsingle.py" + }, + { + "nombre": "test_one_image", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/test_one_image.py", + "tipo": "python", + "descripcion": "SimSwap: An Efficient Framework For High Fidelity Face Swapping", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/test_one_image.py" + }, + { + "nombre": "test_video_swapsingle", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/test_video_swapsingle.py", + "tipo": "python", + "descripcion": "Author: Naiyuan liu", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/test_video_swapsingle.py" + }, + { + "nombre": "test_video_swapspecific", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/test_video_swapspecific.py", + "tipo": "python", + "descripcion": "Author: Naiyuan liu", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/test_video_swapspecific.py" + }, + { + "nombre": "train", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/train.py", + "tipo": "python", + "descripcion": "-*- coding:utf-8 -*- Created Date: Monday December 27th 2021 Author: Chen Xuanhong Email: chenxuanhongzju@outlook.com Last Modified: Friday, 22nd April 2022 10:49:26 am", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/train.py" + }, + { + "nombre": "test_video_swap_multispecific", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/test_video_swap_multispecific.py", + "tipo": "python", + "descripcion": "SimSwap: An Efficient Framework For High Fidelity Face Swapping", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/test_video_swap_multispecific.py" + }, + { + "nombre": "train_options", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/options/train_options.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/options/train_options.py" + }, + { + "nombre": "test_options", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/options/test_options.py", + "tipo": "python", + "descripcion": "Author: Naiyuan liu", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/options/test_options.py" + }, + { + "nombre": "base_options", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/options/base_options.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/options/base_options.py" + }, + { + "nombre": "projected_discriminator", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/pg_modules/projected_discriminator.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/pg_modules/projected_discriminator.py" + }, + { + "nombre": "diffaug", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/pg_modules/diffaug.py", + "tipo": "python", + "descripcion": "Shengyu Zhao, Zhijian Liu, Ji Lin, Jun-Yan Zhu, and Song Han https://arxiv.org/pdf/2006.10738", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/pg_modules/diffaug.py" + }, + { + "nombre": "blocks", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/pg_modules/blocks.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/pg_modules/blocks.py" + }, + { + "nombre": "projector", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/pg_modules/projector.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/pg_modules/projector.py" + }, + { + "nombre": "face_detect_crop_single", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/insightface_func/face_detect_crop_single.py", + "tipo": "python", + "descripcion": "Author: Naiyuan liu", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/insightface_func/face_detect_crop_single.py" + }, + { + "nombre": "face_detect_crop_multi", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/insightface_func/face_detect_crop_multi.py", + "tipo": "python", + "descripcion": "Author: Naiyuan liu", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/insightface_func/face_detect_crop_multi.py" + }, + { + "nombre": "model", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/parsing_model/model.py", + "tipo": "python", + "descripcion": "-*- encoding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/parsing_model/model.py" + }, + { + "nombre": "resnet", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/parsing_model/resnet.py", + "tipo": "python", + "descripcion": "-*- encoding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/parsing_model/resnet.py" + }, + { + "nombre": "data_loader_Swapping", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/data/data_loader_Swapping.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/data/data_loader_Swapping.py" + }, + { + "nombre": "networks", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/models/networks.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/models/networks.py" + }, + { + "nombre": "fs_networks_fix", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/models/fs_networks_fix.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/models/fs_networks_fix.py" + }, + { + "nombre": "fs_model", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/models/fs_model.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/models/fs_model.py" + }, + { + "nombre": "fs_networks", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/models/fs_networks.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/models/fs_networks.py" + }, + { + "nombre": "projectionhead", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/models/projectionhead.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/models/projectionhead.py" + }, + { + "nombre": "models", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/models/models.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/models/models.py" + }, + { + "nombre": "fs_networks_512", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/models/fs_networks_512.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/models/fs_networks_512.py" + }, + { + "nombre": "projected_model", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/models/projected_model.py", + "tipo": "python", + "descripcion": "-*- coding:utf-8 -*- File: fs_model_fix_idnorm_donggp_saveoptim copy.py Created Date: Wednesday January 12th 2022 Author: Chen Xuanhong Email: chenxuanhongzju@outlook.com Last Modified: Saturday, 13t", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/models/projected_model.py" + }, + { + "nombre": "arcface_models", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/models/arcface_models.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/models/arcface_models.py" + }, + { + "nombre": "base_model", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/models/base_model.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/models/base_model.py" + }, + { + "nombre": "ui_model", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/models/ui_model.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/models/ui_model.py" + }, + { + "nombre": "pix2pixHD_model", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/models/pix2pixHD_model.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/models/pix2pixHD_model.py" + }, + { + "nombre": "save_heatmap", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/save_heatmap.py", + "tipo": "python", + "descripcion": "-*- coding:utf-8 -*- File: save_heatmap.py Created Date: Friday January 15th 2021 Author: Chen Xuanhong Email: chenxuanhongzju@outlook.com Last Modified: Wednesday, 19th January 2022 1:22:47 am", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/save_heatmap.py" + }, + { + "nombre": "util", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/util.py", + "tipo": "python", + "descripcion": "Converts a Tensor into a Numpy array", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/util.py" + }, + { + "nombre": "image_pool", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/image_pool.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/image_pool.py" + }, + { + "nombre": "html", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/html.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/html.py" + }, + { + "nombre": "logo_class", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/logo_class.py", + "tipo": "python", + "descripcion": "-*- coding:utf-8 -*- File: logo_class.py Created Date: Tuesday June 29th 2021 Author: Chen Xuanhong Email: chenxuanhongzju@outlook.com Last Modified: Monday, 11th October 2021 12:39:55 am", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/logo_class.py" + }, + { + "nombre": "visualizer", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/visualizer.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/visualizer.py" + }, + { + "nombre": "videoswap_multispecific", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/videoswap_multispecific.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/videoswap_multispecific.py" + }, + { + "nombre": "json_config", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/json_config.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/json_config.py" + }, + { + "nombre": "add_watermark", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/add_watermark.py", + "tipo": "python", + "descripcion": "from torchvision import transforms", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/add_watermark.py" + }, + { + "nombre": "videoswap", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/videoswap.py", + "tipo": "python", + "descripcion": "Author: Naiyuan liu", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/videoswap.py" + }, + { + "nombre": "plot", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/plot.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/plot.py" + }, + { + "nombre": "reverse2original", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/reverse2original.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/reverse2original.py" + }, + { + "nombre": "videoswap_specific", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/videoswap_specific.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/videoswap_specific.py" + }, + { + "nombre": "norm", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/SimSwap/util/norm.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/SimSwap/util/norm.py" + }, + { + "nombre": "cupp", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/PASSWORD_PROFILER/cupp/cupp.py", + "tipo": "python", + "descripcion": "Common User Passwords Profiler", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/PASSWORD_PROFILER/cupp/cupp.py" + }, + { + "nombre": "test_cupp", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/PASSWORD_PROFILER/cupp/test_cupp.py", + "tipo": "python", + "descripcion": "CUPP - Common User Passwords Profiler", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/PASSWORD_PROFILER/cupp/test_cupp.py" + }, + { + "nombre": "bloodcat", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/BloodCat/bloodcat.py", + "tipo": "python", + "descripcion": "███████╗ ██╗ ██╗ ██╗ ██╗ ██████╗ ██╗ ██╗ ██╗ ██████╗ ██╔════╝ ██║ ██║ ██║ ██║ ██╔════╝ ██║ ██╔╝ ███║ ╚════██╗ ███████╗ █████╗ █████", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/BloodCat/bloodcat.py" + }, + { + "nombre": "play", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/BloodCat/play.sh", + "tipo": "shell", + "descripcion": "███████╗ ██╗ ██╗ ██╗ ██╗ ██████╗ ██╗ ██╗ ██╗ ██████╗ ██╔════╝ ██║ ██║ ██║ ██║ ██╔════╝ ██║ ██╔╝ ███║ ╚════██╗ ███████╗ █████╗ █████", + "invocar": "bash /home/sito/COFRE/SNEAKERS/BloodCat/play.sh" + }, + { + "nombre": "saycheese", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/saycheese/saycheese.sh", + "tipo": "shell", + "descripcion": "coded by: github.com/thelinuxchoice/saycheese If you use any part from this code, giving me the credits. Read the Lincense!", + "invocar": "bash /home/sito/COFRE/SNEAKERS/saycheese/saycheese.sh" + }, + { + "nombre": "Ruben_garcia", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/GENTE/Ruben_garcia/Ruben_garcia", + "tipo": "binario", + "descripcion": "", + "invocar": "/home/sito/COFRE/SNEAKERS/GENTE/Ruben_garcia/Ruben_garcia" + }, + { + "nombre": "sock", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/sock_you/Sock-You/sock.sh", + "tipo": "shell", + "descripcion": "Terminal based fake identity generator.", + "invocar": "bash /home/sito/COFRE/SNEAKERS/sock_you/Sock-You/sock.sh" + }, + { + "nombre": "skiptracer", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/skiptracer/skiptracer/skiptracer.py", + "tipo": "python", + "descripcion": "!/usr/bin/env python", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/skiptracer/skiptracer/skiptracer.py" + }, + { + "nombre": "tracker", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/OPRecon/files/tracker.py", + "tipo": "python", + "descripcion": "-*- coding: utf-8 -*-", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/OPRecon/files/tracker.py" + }, + { + "nombre": "ipgeolocation", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/OPRecon/files/ipgeolocation.py", + "tipo": "python", + "descripcion": "This file is part of IPGeoLocation tool.", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/OPRecon/files/ipgeolocation.py" + }, + { + "nombre": "phoneinfoga", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/OPRecon/files/phoneinfoga.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/OPRecon/files/phoneinfoga.py" + }, + { + "nombre": "ghunt", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/GHunt/ghunt/ghunt.py", + "tipo": "python", + "descripcion": "", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/GHunt/ghunt/ghunt.py" + }, + { + "nombre": ".pyre_configuration", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/theHarv/theHarvester/.pyre_configuration", + "tipo": "binario", + "descripcion": "theHarvester is a simple to use, yet powerful tool designed to be used during the reconnaissance stage of a red
", + "invocar": "/home/sito/COFRE/SNEAKERS/theHarv/theHarvester/.pyre_configuration" + }, + { + "nombre": "theHarvester", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/theHarv/theHarvester/theHarvester.py", + "tipo": "python", + "descripcion": "Note: This script runs theHarvester", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/theHarv/theHarvester/theHarvester.py" + }, + { + "nombre": "restfulHarvest", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/theHarv/theHarvester/restfulHarvest.py", + "tipo": "python", + "descripcion": "theHarvester is a simple to use, yet powerful tool designed to be used during the reconnaissance stage of a red
", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/theHarv/theHarvester/restfulHarvest.py" + }, + { + "nombre": "infoga", + "carpeta": "SNEAKERS", + "ruta": "/home/sito/COFRE/SNEAKERS/infoga/Infoga/infoga.py", + "tipo": "python", + "descripcion": "-*- coding:utf-8 -*- @name : Infoga - Email OSINT @url : http://github.com/m4ll0k @author : Momo Outaadi (m4ll0k)", + "invocar": "python3 /home/sito/COFRE/SNEAKERS/infoga/Infoga/infoga.py" + } + ] +} \ No newline at end of file diff --git a/config/jarvis-piper.sh b/config/jarvis-piper.sh new file mode 100755 index 0000000..f725a25 --- /dev/null +++ b/config/jarvis-piper.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Voz de JARVIS con Piper: castellano de España, no latinoamericano. +# +# jarvis-piper.sh +# +# Kokoro, que es lo que trae Newelle, no tiene ni una voz de España: sus dos +# masculinas en español (em_alex, em_santa) suenan latinas. Piper si las tiene, +# es libre y corre local. Se invoca desde el handler custom_command, que ya +# ejecuta en el host. +# +# El descenso de tono se hace aqui y no en la cadena de la app porque alli se +# usa asetrate, que necesita saber la frecuencia de muestreo: esta calculada +# para los 24 kHz de Kokoro y Piper saca 22050, asi que aplicaria una bajada +# distinta de la pedida. Aqui se usa rubberband, que trabaja en semitonos y le +# da igual la frecuencia. Existe en el ffmpeg del sistema pero no en el del +# flatpak, y este script ya corre fuera. +# +# Ajustes por entorno: +# JARVIS_PIPER_VOZ es_ES-sharvard-medium (defecto) | es_ES-davefx-medium +# | es_ES-carlfm-x_low +# JARVIS_PIPER_TONO 0.90 (defecto). 1.0 no toca el tono; por debajo de +# 0.85 empieza a sonar artificial. + +set -uo pipefail + +# La raiz se deduce de donde esta este fichero, no del $HOME de nadie: asi el +# arbol se puede mover o clonar en otra maquina y sigue funcionando sin editar +# rutas. `cd -P` resuelve enlaces simbolicos, que el lanzador de escritorio +# puede invocarlo por uno. +RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd) +BASE=$RAIZ/voz +VOZ=${JARVIS_PIPER_VOZ:-es_ES-sharvard-medium} +TONO=${JARVIS_PIPER_TONO:-0.90} + +SALIDA=${1:-} +TEXTO=${2:-} + +[ -n "$SALIDA" ] || { echo "falta el fichero de salida" >&2; exit 1; } +[ -n "$TEXTO" ] || { echo "sin texto que decir" >&2; exit 1; } + +MODELO=$BASE/modelos/$VOZ.onnx +[ -f "$MODELO" ] || { echo "no esta el modelo $MODELO" >&2; exit 1; } + +CRUDO=$(mktemp --suffix=.wav) +trap 'rm -f "$CRUDO"' EXIT + +printf '%s' "$TEXTO" | "$BASE/piper/piper" \ + --model "$MODELO" --output_file "$CRUDO" 2>/dev/null + +[ -s "$CRUDO" ] || { echo "piper no genero audio" >&2; exit 1; } + +if [ "$TONO" = "1.0" ] || ! command -v ffmpeg >/dev/null; then + cp "$CRUDO" "$SALIDA" +else + ffmpeg -hide_banner -loglevel error -i "$CRUDO" \ + -af "rubberband=pitch=$TONO" -y "$SALIDA" 2>/dev/null \ + || cp "$CRUDO" "$SALIDA" +fi diff --git a/config/jarvis-voz.sh b/config/jarvis-voz.sh new file mode 100755 index 0000000..a20e44c --- /dev/null +++ b/config/jarvis-voz.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Voz de JARVIS con la clonada (XTTS-v2) servida desde cache. +# +# jarvis-voz.sh +# +# Si el texto esta pre-generado en cache_voz/, lo sirve al instante (un cp, sin +# tocar GPU). Si no, cae a Piper. La clave es sha1(texto normalizado), la misma +# que calcula generar_cache_voz.py -> los aciertos son exactos. +# +# El clon ya trae el tono correcto, asi que en un acierto NO se aplica el +# descenso de tono de Piper; solo se copia. En un fallo, Piper hace lo suyo +# (incluido su rubberband) como hasta ahora. +set -uo pipefail + +# La raiz se deduce de donde esta este fichero, no del $HOME de nadie: asi el +# arbol se puede mover o clonar en otra maquina y sigue funcionando sin editar +# rutas. `cd -P` resuelve enlaces simbolicos, que el lanzador de escritorio +# puede invocarlo por uno. +RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd) +BASE=$RAIZ/voz +CACHE=$BASE/cache_voz +PIPER=$RAIZ/config/jarvis-piper.sh + +SALIDA=${1:-} +TEXTO=${2:-} + +[ -n "$SALIDA" ] || { echo "falta el fichero de salida" >&2; exit 1; } +[ -n "$TEXTO" ] || { echo "sin texto que decir" >&2; exit 1; } + +# clave = sha1(texto con espacios colapsados y recortado), identica a la de +# Python: re.sub(r'\s+',' ',t).strip() -> sha1(utf-8) +T=$(printf '%s' "$TEXTO" | tr -s '[:space:]' ' ' | sed 's/^ //; s/ $//') +KEY=$(printf '%s' "$T" | sha1sum | cut -d' ' -f1) +HIT=$CACHE/$KEY.wav + +if [ -f "$HIT" ]; then + cp -f "$HIT" "$SALIDA" + exit 0 +fi + +# fallo de cache -> registrar la frase (normalizada) para calentar la cache +# luego con la voz clonada, y de momento reescribir siglas + Piper. +printf '%s\n' "$T" >> "$BASE/misses.log" 2>/dev/null || true +TEXTO2=$(python3 "$BASE/pronuncia.py" "$TEXTO" 2>/dev/null) +[ -n "$TEXTO2" ] || TEXTO2=$TEXTO +exec "$PIPER" "$SALIDA" "$TEXTO2" diff --git a/config/mejora_nocturna.py b/config/mejora_nocturna.py new file mode 100755 index 0000000..d927dc0 --- /dev/null +++ b/config/mejora_nocturna.py @@ -0,0 +1,484 @@ +#!/usr/bin/env python3 +"""Bucle de mejora que corre solo, ronda tras ronda, y se protege de empeorar. + + python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py # hasta la mañana + python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py --horas 2 # un rato + python3 ~/COFRE/CODERS/JARVIS/config/mejora_nocturna.py --ronda # una sola + +Que hace en cada ronda: + 1. Dice en voz alta (Piper) las frases del catalogo, en varias voces, + velocidades y con ruido y sala aleatorios, distintos cada ronda. + 2. Las transcribe con el mismo Whisper del asistente. + 3. Mira que porcentaje llega a su accion. Ese es el marcador de la ronda. + 4. De lo que NO llega, se queda con los fallos SISTEMATICOS (repetidos) y + SIN AMBIGUEDAD (que no puedan ser dos acciones), y los propone como alias. + 5. GUARDA: antes de aplicar nada, comprueba en una copia que + a) el acierto sube, y + b) la conversacion normal SIGUE yendo al modelo (no se cuela ninguna). + Solo si las dos cosas se cumplen, aplica los alias de verdad. + 6. Copia de seguridad del catalogo antes de tocarlo, y todo al registro. + +Por que es seguro dejarlo sin mirar: nunca aplica algo que baje el acierto ni +que haga que una frase de charla acabe ejecutando un comando. Si una ronda +empeorara, revierte. Y cada cambio queda respaldado, asi que siempre se puede +volver atras a mano. + +Que NO hace: reentrenar el modelo (no cabe en 4 GB de VRAM) ni tocar Whisper o +Piper. Mejora la capa de emparejamiento —alias y umbrales—, que es donde estan +casi todos los fallos y donde una mejora es inmediata, legible y reversible. + +En marcha se puede seguir con: + tail -f ~/COFRE/CODERS/JARVIS/muestras/mejora/registro.log + cat ~/COFRE/CODERS/JARVIS/muestras/mejora/marcador.json +""" +import argparse +import importlib.util +import json +import os +import random +import shutil +import subprocess +import sys +import tempfile +import time +from collections import defaultdict + +CASA = os.path.expanduser("~") +JARVIS = os.path.join(CASA, "COFRE/CODERS/JARVIS") +CONFIG = os.path.join(CASA, ".var/app/io.github.qwersyk.Newelle/config") +APP = "io.github.qwersyk.Newelle//master" + +PIPER = os.path.join(JARVIS, "voz/piper/piper") +MODELOS_VOZ = os.path.join(JARVIS, "voz/modelos") +WHISPER = os.path.join(CONFIG, "models/whisper/whisper.cpp/build/bin/whisper-cli") +MODELOS_STT = os.path.join(CONFIG, "models/whisper/whisper.cpp/models") + +CATALOGO = os.path.expanduser( + "~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/" + "active/files/share/newelle/acciones/acciones.json") +CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json") +PROPIO = os.path.join(CONFIG, "jarvis-acciones.json") +MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py") + +SALA = os.path.join(JARVIS, "muestras/mejora") +REGISTRO = os.path.join(SALA, "registro.log") +MARCADOR = os.path.join(SALA, "marcador.json") +COPIAS = os.path.join(SALA, "copias") +WAVS = os.path.join(CONFIG, "mejora_wavs") + +VOCES = ["es_ES-sharvard-medium", "es_ES-davefx-medium", "es_ES-carlfm-x_low"] +# Cada ronda escoge una rejilla distinta de velocidades y ruidos, para que no +# se estanque midiendo siempre lo mismo y siga sacando fallos raros. +VELOCIDADES = [0.85, 0.92, 1.0, 1.08, 1.18] +RUIDOS = [ + "", + "aecho=0.8:0.7:22:0.12", + "volume=0.72,highpass=f=120", + "aecho=0.6:0.5:35:0.2,volume=0.8", + "highpass=f=150,lowpass=f=6500", # microfono cutre + "volume=0.65", # lejos +] +RELLENOS = ["notas", "el informe", "documentos", "la reunion de ayer", "gatos"] + +# Frases de charla que NUNCA deben acabar en una accion. Es el guardarrail: si +# despues de aplicar alias alguna se cuela, la ronda se revierte. +CHARLA = [ + "cuentame un chiste sobre gatos", "que opinas del proyecto", + "hola que tal estas", "gracias por todo", "no se que hacer con esto", + "me parece que esto no funciona bien", "buenas tardes que tal", + "cuentame algo interesante", "estoy cansado de programar", + "que raro todo esto la verdad", "a ver si esto va mejor ahora", + "pues no tengo ni idea de que decir", "vale perfecto muchas gracias", + "oye una cosa que te queria preguntar", +] + +TOPE_ALIAS_POR_ACCION = 25 # que una accion no acumule cientos de alias +UMBRAL_SISTEMATICO = 2 # veces que hay que fallar igual para creerselo +DRY_PARA_PARAR = 6 # rondas seguidas sin nada nuevo -> converge + + +def log(msg): + linea = f"[{time.strftime('%H:%M:%S')}] {msg}" + print(linea, flush=True) + try: + with open(REGISTRO, "a") as f: + f.write(linea + "\n") + except OSError: + pass + + +def disco_libre_gb() -> float: + """GB libres en la particion del home. Guardarrail: aunque el bucle apenas + ocupa 3 MB por ronda y los limpia, si algo mas llenara el disco durante la + noche conviene parar en vez de dejar el sistema sin espacio.""" + try: + s = os.statvfs(CASA) + return s.f_bavail * s.f_frsize / 1e9 + except OSError: + return 999.0 + + +def carga_motor(): + # se recarga cada ronda: el catalogo propio cambia y el motor debe verlo + spec = importlib.util.spec_from_file_location("acciones", MOTOR) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +def dentro(orden, limite=1200): + return subprocess.run( + ["flatpak", "run", "--command=bash", APP, "-c", orden], + capture_output=True, text=True, timeout=limite) + + +def genera(texto, voz, velocidad, ruido, destino): + crudo = tempfile.mktemp(suffix=".wav") + try: + r = subprocess.run( + [PIPER, "--model", os.path.join(MODELOS_VOZ, voz + ".onnx"), + "--length_scale", str(1 / velocidad), "--output_file", crudo], + input=texto, capture_output=True, text=True, timeout=60) + if r.returncode != 0 or not os.path.exists(crudo): + return False + filtros = "aresample=16000" + ("," + ruido if ruido else "") + subprocess.run(["ffmpeg", "-hide_banner", "-loglevel", "error", + "-i", crudo, "-ac", "1", "-af", filtros, "-y", destino], + check=True, timeout=60) + return os.path.exists(destino) + except (OSError, subprocess.SubprocessError): + return False + finally: + if os.path.exists(crudo): + os.remove(crudo) + + +def transcribe(carpeta, ficheros, hilos=8, por_tanda=40): + salida = {} + for i in range(0, len(ficheros), por_tanda): + tanda = ficheros[i:i + por_tanda] + lista = " ".join(f'"{carpeta}/{n}"' for n in tanda) + orden = (f'for f in {lista}; do echo "### $(basename "$f")"; ' + f'{WHISPER} -m {MODELOS_STT}/ggml-base.bin -l es -nt -t {hilos} ' + f'-f "$f" 2>/dev/null | tr "\\n" " "; echo; done') + try: + r = dentro(orden) + except subprocess.SubprocessError: + continue + actual = None + for linea in r.stdout.splitlines(): + if linea.startswith("### "): + actual = linea[4:].strip() + elif actual: + salida[actual] = linea.strip() + actual = None + return salida + + +def catalogo_base(): + ruta = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE + with open(ruta) as f: + return json.load(f)["acciones"], ruta + + +def evalua(motor, textos, indice): + """(aciertos, total, candidatos{id: {frase_mal: veces}}).""" + aciertos = 0 + candidatos = defaultdict(lambda: defaultdict(int)) + for nombre, (ident, frase) in indice.items(): + oido = textos.get(nombre, "").strip() + if not oido: + continue + a, _ = _CAT.busca(oido) + if a is not None and a.id == ident: + aciertos += 1 + else: + candidatos[ident][motor.normaliza(oido)] += 1 + return aciertos, len([1 for n in indice if textos.get(n)]), candidatos + + +_CAT = None + + +# Palabras de funcion: aparecen en cualquier frase y no distinguen una orden de +# una conversacion. Un alias hecho solo de estas ("que me tengo") es una trampa. +VACIAS = { + "que", "de", "la", "el", "los", "las", "un", "una", "me", "te", "se", "lo", + "mi", "tu", "su", "es", "esta", "estan", "hay", "por", "para", "con", "en", + "y", "o", "a", "al", "del", "como", "cuanto", "cuanta", "muy", "ya", "he", + "ha", "tengo", "tiene", "esto", "eso", "aqui", "ahi", "va", "van", "asi", +} + + +def contenido(frase: str) -> set: + """Las palabras con significado: las que de verdad identifican la orden.""" + return {p for p in frase.split() if len(p) > 3 and p not in VACIAS} + + +def filtra_candidatos(motor, candidatos, acciones): + """Solo los alias que valen: sistematicos, sin ambiguedad, y que conservan + una palabra distintiva de la orden original. + + El guardarrail clave es el ultimo: una confusion del reconocedor que aun + sirve de alias mantiene al menos una palabra con significado ("kernel", + "wikipedia", "bateria"). Si las perdio todas y solo quedan palabras de + relleno, es indistinguible de una frase de charla y se descarta, por mucho + que se haya repetido. Es lo que separa "que kernel tengo" -> "que kernel me + tengo" (vale) de "que me tengo" (no vale). + """ + porfrase = defaultdict(set) + for ident, frases in candidatos.items(): + for f in frases: + porfrase[f].add(ident) + + porid = {a["id"]: a for a in acciones} + + propuestos = defaultdict(list) + for ident, frases in candidatos.items(): + accion = porid.get(ident) + if accion is None: + continue + # Las de argumento no generan alias: el relleno de prueba ("notas") se + # cuela en la transcripcion y quedaria horneado en el alias. Su + # emparejamiento ya es por parecido del arranque, que las cubre. + if accion.get("captura"): + continue + + orig = motor.normaliza(accion["frases"][0] if accion["frases"] else "") + claves = contenido(orig) + + for f, veces in frases.items(): + if veces < UMBRAL_SISTEMATICO: + continue + if len(porfrase[f]) > 1: # la misma frase, dos acciones + continue + if len(f.split()) < 2: # una palabra engancha de todo + continue + if _CAT.busca(f)[0] is not None: # ya lo coge algo + continue + if motor.parecido(f, orig) < 0.6: # se perdio del todo: ruido + continue + # conserva una palabra distintiva? exacta o casi (el reconocedor + # cambia una letra: "wikipedia" -> "wikipeda") + palabras_f = set(f.split()) + if claves and not any( + c in palabras_f or any(motor.parecido(c, w) > 0.8 for w in palabras_f) + for c in claves): + continue + propuestos[ident].append((f, veces)) + return propuestos + + +def aplica(propuestos, acciones_orig): + """Escribe los alias en el catalogo propio. Devuelve cuantos.""" + try: + with open(PROPIO) as f: + propio = json.load(f) + except (OSError, json.JSONDecodeError): + propio = {"version": 1, "acciones": []} + porid = {a["id"]: a for a in propio.get("acciones", [])} + original = {a["id"]: a for a in acciones_orig} + + n = 0 + for ident, lista in propuestos.items(): + entrada = porid.get(ident) + if entrada is None: + entrada = dict(original[ident]) + porid[ident] = entrada + propio.setdefault("acciones", []).append(entrada) + if len(entrada["frases"]) >= TOPE_ALIAS_POR_ACCION: + continue + for f, _ in lista: + if f not in entrada["frases"]: + entrada["frases"].append(f) + n += 1 + with open(PROPIO, "w") as f: + json.dump(propio, f, indent=2, ensure_ascii=False) + return n + + +def charla_se_cuela(motor): + """True si alguna frase de charla acaba en una accion. El guardarrail.""" + for frase in CHARLA: + a, _ = _CAT.busca(frase) + if a is not None: + return frase, a.id + return None + + +def marcador_guarda(datos): + try: + with open(MARCADOR, "w") as f: + json.dump(datos, f, indent=2, ensure_ascii=False) + except OSError: + pass + + +def copia_catalogo(ronda): + if not os.path.exists(PROPIO): + return + os.makedirs(COPIAS, exist_ok=True) + shutil.copy2(PROPIO, os.path.join(COPIAS, f"ronda_{ronda:03d}.json")) + + +def una_ronda(motor, ronda, muestras_por_acap): + global _CAT + acciones, _ = catalogo_base() + + # muestras de esta ronda: condiciones aleatorias, distintas cada vez + trabajos = [] + for a in acciones: + if not a["frases"]: + continue + frase = a["frases"][0] + if a.get("captura"): + frase += " " + random.choice(RELLENOS) + for _ in range(muestras_por_acap): + trabajos.append((a["id"], frase, random.choice(VOCES), + random.choice(VELOCIDADES), random.choice(RUIDOS))) + random.shuffle(trabajos) + + shutil.rmtree(WAVS, ignore_errors=True) + os.makedirs(WAVS, exist_ok=True) + indice = {} + for n, (ident, frase, voz, vel, ruido) in enumerate(trabajos, 1): + nombre = f"{n:05d}.wav" + if genera(frase, voz, vel, ruido, os.path.join(WAVS, nombre)): + indice[nombre] = (ident, frase) + + log(f"ronda {ronda}: {len(indice)} frases dichas, transcribiendo...") + textos = transcribe(WAVS, sorted(indice)) + + # medir ANTES de tocar nada + _CAT = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)] + or [CATALOGO_FUENTE]) + aciertos, total, candidatos = evalua(motor, textos, indice) + antes = 100 * aciertos / max(1, total) + log(f"ronda {ronda}: acierto {antes:.1f} % ({aciertos}/{total})") + + propuestos = filtra_candidatos(motor, candidatos, acciones) + nuevos = sum(len(v) for v in propuestos.values()) + if not nuevos: + log(f"ronda {ronda}: sin alias nuevos que valgan") + return antes, 0, False + + # aplicar en el catalogo propio, con copia de seguridad antes + copia_catalogo(ronda) + n = aplica(propuestos, acciones) + + # revalidar: recargar el catalogo con los alias puestos + _CAT = motor.Catalogo([p for p in (PROPIO, CATALOGO) if os.path.exists(p)] + or [CATALOGO_FUENTE]) + + # GUARDARRAIL 1: la charla no se cuela + cuela = charla_se_cuela(motor) + if cuela: + frase, ident = cuela + log(f"ronda {ronda}: REVERTIDA — '{frase}' se colaria en {ident}") + shutil.copy2(os.path.join(COPIAS, f"ronda_{ronda:03d}.json"), PROPIO) + return antes, 0, False + + # GUARDARRAIL 2: el acierto sube en las mismas muestras + aciertos2, _, _ = evalua(motor, textos, indice) + despues = 100 * aciertos2 / max(1, total) + if despues < antes - 0.5: + log(f"ronda {ronda}: REVERTIDA — el acierto bajaria a {despues:.1f} %") + shutil.copy2(os.path.join(COPIAS, f"ronda_{ronda:03d}.json"), PROPIO) + return antes, 0, False + + log(f"ronda {ronda}: +{n} alias · acierto {antes:.1f} -> {despues:.1f} %") + for ident, lista in sorted(propuestos.items()): + for f, v in lista[:2]: + log(f" {ident} <- \"{f}\" ({v}x)") + return despues, n, True + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--horas", type=float, default=0, help="parar tras N horas") + ap.add_argument("--ronda", action="store_true", help="una sola ronda") + ap.add_argument("--muestras", type=int, default=3, + help="muestras por accion y ronda") + ap.add_argument("--dry", type=int, default=DRY_PARA_PARAR, + help="rondas secas seguidas antes de parar") + args = ap.parse_args() + + for ruta, que in ((PIPER, "piper"), (WHISPER, "whisper")): + if not os.path.exists(ruta): + print(f"falta {que}: {ruta}") + return 1 + + os.makedirs(SALA, exist_ok=True) + limite = time.time() + args.horas * 3600 if args.horas else None + motor = carga_motor() + + log("=" * 56) + log(f"arranca la mejora nocturna · {args.muestras} muestras/accion/ronda") + if limite: + log(f"parara en {args.horas} h") + else: + log("corre hasta converger o hasta que la pares") + + historial = [] + dry = 0 + ronda = 0 + try: + while True: + ronda += 1 + libre = disco_libre_gb() + if libre < 10: + log(f'PARADA: solo quedan {libre:.1f} GB de disco. Fin por seguridad.') + break + ini = time.time() + try: + score, nuevos, aplicado = una_ronda(motor, ronda, args.muestras) + except Exception as e: + log(f"ronda {ronda}: error, se salta — {type(e).__name__}: {e}") + time.sleep(5) + continue + + historial.append({"ronda": ronda, "acierto": round(score, 1), + "alias_nuevos": nuevos, + "minutos": round((time.time() - ini) / 60, 1)}) + marcador_guarda({ + "actualizado": time.strftime("%Y-%m-%d %H:%M"), + "rondas": ronda, "ultimo_acierto": round(score, 1), + "alias_totales": alias_totales(), + "historial": historial[-40:], + }) + + dry = dry + 1 if nuevos == 0 else 0 + if args.ronda: + break + if dry >= args.dry: + log(f"converge: {DRY_PARA_PARAR} rondas sin nada nuevo. Fin.") + break + if limite and time.time() > limite: + log("se acabo el tiempo pedido. Fin.") + break + except KeyboardInterrupt: + log("parada a mano") + + shutil.rmtree(WAVS, ignore_errors=True) + log(f"terminado tras {ronda} rondas · acierto final " + f"{historial[-1]['acierto'] if historial else '?'} % · " + f"{alias_totales()} alias en el catalogo propio") + log("los cambios estan en jarvis-acciones.json; se aplican al abrir JARVIS") + return 0 + + +def alias_totales(): + try: + with open(PROPIO) as f: + d = json.load(f) + base = {a["id"]: len(a["frases"]) for a in + json.load(open(CATALOGO if os.path.exists(CATALOGO) + else CATALOGO_FUENTE))["acciones"]} + return sum(max(0, len(a["frases"]) - base.get(a["id"], 0)) + for a in d.get("acciones", [])) + except (OSError, json.JSONDecodeError): + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/muestra_voz.py b/config/muestra_voz.py new file mode 100644 index 0000000..9a7c15c --- /dev/null +++ b/config/muestra_voz.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Genera muestras de la voz JARVIS: seca y con tratamiento. + +Se ejecuta DENTRO del flatpak, que es donde viven Kokoro y ffmpeg: + + flatpak run --command=python3 io.github.qwersyk.Newelle//master \ + ~/COFRE/CODERS/JARVIS/config/muestra_voz.py + +Sirve para juzgar la voz de oido antes de montar una conversacion entera, +y para comparar el efecto del filtro (que es la mitad del caracter JARVIS). +Deja los wav en ~/COFRE/CODERS/JARVIS/muestras/. +""" +import os +import subprocess +import sys + +sys.path.insert(0, "/app/share/newelle") +# Los modulos que Newelle instala bajo demanda (kokoro-onnx, soundfile...) no +# van al site-packages del sistema sino a config/pip, que la app añade al +# sys.path al arrancar. Un script suelto tiene que hacerlo a mano. +sys.path.insert(0, os.path.expanduser( + "~/.var/app/io.github.qwersyk.Newelle/config/pip")) + +from gi.repository import Gio # noqa: E402 + +from newelle.handlers.tts.kokoro_handler import KokoroTTSHandler # noqa: E402 +from newelle.handlers.tts.tts import jarvis_audio_filter # noqa: E402 + +FRASE = ("Good evening, sir. All systems are running locally. " + "You have 846 gigabytes free and the GPU is idle.") + +SALIDA = os.path.expanduser("~/COFRE/CODERS/JARVIS/muestras") +CONFIG = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config") + + +def main(): + os.makedirs(SALIDA, exist_ok=True) + settings = Gio.Settings.new("io.github.qwersyk.Newelle") + handler = KokoroTTSHandler(settings, os.path.join(CONFIG, "models")) + + if not handler.is_installed(): + print("Kokoro no instalado, instalando (pip + modelos, tarda)...") + handler.install() + print("instalado") + + voz = handler.get_current_voice() + print(f"voz: {voz}") + + seca = os.path.join(SALIDA, "jarvis_seca.wav") + handler.save_audio(FRASE, seca) + print(f"seca: {seca} ({os.path.getsize(seca)} bytes)") + + filtro = jarvis_audio_filter() + tratada = os.path.join(SALIDA, "jarvis_tratada.wav") + subprocess.run( + ["ffmpeg", "-hide_banner", "-loglevel", "error", + "-i", seca, "-af", filtro, "-y", tratada], + check=True, + ) + print(f"tratada: {tratada} ({os.path.getsize(tratada)} bytes)") + print(f"filtro: {filtro}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/noche.sh b/config/noche.sh new file mode 100755 index 0000000..3a78c9d --- /dev/null +++ b/config/noche.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# Pipeline nocturno: JARVIS se mejora solo mientras nadie mira. +# +# Cada ciclo hace lo mismo: mira los datos que ha dejado el uso real, propone +# un cambio, lo aplica, y lo somete a config/puerta.sh. Si la puerta dice que +# no, se revierte ENTERO con git y se pasa al siguiente. Nada se queda sin +# pasar por ahi. +# +# Por que git y no una copia: el arbol ya esta versionado, `git checkout` es +# atomico y deja rastro de lo que se intento. Al terminar la noche se puede ver +# commit a commit que se cambio y por que. +# +# noche.sh corre hasta que se le diga que pare +# noche.sh --ciclos 5 solo cinco vueltas +# noche.sh --seco propone y valida, pero NO commitea (para mirar) +# +# Para pararlo: touch ~/COFRE/CODERS/JARVIS/PARAR +# +# ── Que puede mejorar solo, y que no ──────────────────────────────────────── +# +# Se mejora solo lo que se puede VALIDAR solo. Eso es: +# · el vocabulario: alias nuevos para ordenes que no se entendieron +# · las frases del catalogo: variantes de las que fallan +# · la voz: regenerar en la voz buena las frases que cayeron a la de reserva +# · el prompt: mantenerlo al dia con el catalogo +# · acciones nuevas a partir de lo que se pidio y no existia +# +# NO se toca solo el motor, el panel ni la logica de la aplicacion. No porque +# sea imposible generarlo, sino porque no hay forma automatica de comprobar que +# un cambio ahi es bueno: la suite dice si algo se rompio, no si algo mejoro. +# Un bucle que reescribe su propio validador acaba pasando siempre. +set -uo pipefail + +# ── DESACTIVADO EL 15 DE AGOSTO DE 2026 ──────────────────────────────────── +# +# Este bucle modifica el arbol de `newelle/` y COMMITEA solo. Ese arbol esta +# congelado en la etiqueta jarvis-newelle-final desde que el desarrollo se +# mudo a nucleo/: dejarlo correr escribiria commits automaticos sobre un +# repositorio que por definicion ya no cambia. +# +# Se ha quitado tambien su temporizador de systemd —estaba armado para las +# 03:30 y habria saltado solo—, movido a ~/.config/systemd/user/desactivados/. +# +# Esto de aqui es la segunda cerradura: aunque alguien lo lance a mano, se +# niega. Para retomarlo hay que apuntarlo primero al nucleo, y eso no es +# cambiar una ruta: la puerta (config/puerta.sh) valida el catalogo, el +# generador y la suite VIEJOS, y el nucleo tiene los suyos. +if [ "${JARVIS_NOCHE_A_SABIENDAS:-}" != "si" ]; then + cat >&2 <<'AVISO' +noche.sh esta desactivado. + +Modifica y commitea sobre newelle/, que esta congelado en jarvis-newelle-final +desde que el desarrollo se mudo a nucleo/. Su temporizador de systemd tambien +se retiro. + +Para retomarlo hay que reescribirlo contra el nucleo: la puerta valida el +catalogo, el generador y la suite viejos. + +Si de verdad quieres correrlo tal cual sobre el arbol congelado: + JARVIS_NOCHE_A_SABIENDAS=si config/noche.sh +AVISO + exit 78 +fi + +RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd) +cd "$RAIZ" +PARADA=$RAIZ/PARAR +DIARIO=$RAIZ/noche.log +CICLOS=0; SECO=no +while [ $# -gt 0 ]; do + case "$1" in + --ciclos) CICLOS=${2:-0}; shift 2;; + --seco) SECO=si; shift;; + *) shift;; + esac +done + +log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*" | tee -a "$DIARIO"; } + +# ── salvaguarda ──────────────────────────────────────────────────────────── +cd "$RAIZ/newelle" +if [ -n "$(git status --porcelain)" ]; then + log "ABORTO: el fork tiene cambios sin commitear. Commitealos o descartalos." + exit 1 +fi +ANCLA=$(git rev-parse HEAD) +git tag -f "noche-$(date +%Y%m%d-%H%M)" >/dev/null 2>&1 +log "ancla: $ANCLA (para volver: git reset --hard $ANCLA)" +cd "$RAIZ" + +# ── las mejoras, cada una en su funcion ──────────────────────────────────── +# Cada una devuelve 0 si propuso algo, 1 si no habia nada que hacer. + +mejora_vocabulario() { + # Lo que el usuario dijo y no se entendio. Es la fuente mas valiosa porque + # son fallos REALES, no inventados. + local registro=$HOME/.var/app/io.github.qwersyk.Newelle/config/jarvis-sin-accion.jsonl + [ -f "$registro" ] || return 1 + python3 "$RAIZ/config/proponer_alias.py" --aplicar 2>&1 | tail -3 + return ${PIPESTATUS[0]} +} + +mejora_frases() { + # Variantes de las frases del catalogo, generadas con el modelo local y + # filtradas duro. Sube la cobertura sin tocar comandos. + python3 "$RAIZ/config/proponer_frases.py" --aplicar 2>&1 | tail -3 + return ${PIPESTATUS[0]} +} + +mejora_voz() { + # Las frases que cayeron a la voz de reserva se regeneran con la clonada. + [ -s "$RAIZ/voz/misses.log" ] || return 1 + "$RAIZ/voz/clonador-venv/bin/python" "$RAIZ/voz/calienta_cache.py" cpu 2>&1 | tail -2 + return 0 +} + +mejora_prompt() { + # El prompt lleva la lista de capacidades: si cambio el catalogo, hay que + # regenerarlo o el modelo sigue creyendo que sabe hacer otra cosa. + flatpak run --command=python3 io.github.qwersyk.Newelle//master \ + "$RAIZ/config/set_persona.py" 2>&1 | grep -v WARNING | tail -1 + return 0 +} + +mejora_reconocimiento() { + # La unica que genera sus PROPIOS datos: dice las frases del catalogo con + # varias voces, velocidades y ruidos, las transcribe con el mismo Whisper + # del asistente, y propone alias para lo que no llega a su accion. + # + # Es la que hace productiva la noche aunque nadie hable con JARVIS. Ya trae + # su propio guardarrail —solo aplica si el acierto sube Y la charla sigue + # yendo al modelo— y ademas pasa por la puerta como todo lo demas. + timeout 3000 python3 "$RAIZ/config/mejora_nocturna.py" --ronda --muestras 2 2>&1 | tail -4 + return ${PIPESTATUS[0]} +} + +MEJORAS=(mejora_reconocimiento mejora_vocabulario mejora_frases mejora_prompt mejora_voz) + +# ── el bucle ─────────────────────────────────────────────────────────────── +vuelta=0 +aceptados=0; rechazados=0; sin_nada=0 +log "=== empieza la noche ===" + +while true; do + [ -f "$PARADA" ] && { log "parada pedida (existe $PARADA)"; break; } + [ "$CICLOS" -gt 0 ] && [ "$vuelta" -ge "$CICLOS" ] && { log "hechas $CICLOS vueltas"; break; } + vuelta=$((vuelta+1)) + + for mejora in "${MEJORAS[@]}"; do + [ -f "$PARADA" ] && break + log "── vuelta $vuelta · $mejora" + + salida=$($mejora 2>&1); resultado=$? + [ -n "$salida" ] && echo "$salida" | sed 's/^/ /' | tee -a "$DIARIO" >/dev/null + + cd "$RAIZ/newelle" + cambios=$(git status --porcelain) + cd "$RAIZ" + if [ -z "$cambios" ] && [ "$resultado" -ne 0 ]; then + log " nada que proponer" + sin_nada=$((sin_nada+1)) + continue + fi + + if [ -z "$cambios" ]; then + log " aplicado fuera del fork (voz o ajustes), sin commit" + aceptados=$((aceptados+1)) + continue + fi + + # ── LA PUERTA ── + if timeout 2400 "$RAIZ/config/puerta.sh" completa > "$RAIZ/.puerta.txt" 2>&1; then + if [ "$SECO" = si ]; then + log " [seco] habria pasado la puerta; se revierte igual" + (cd "$RAIZ/newelle" && git checkout -- . && git clean -fd >/dev/null 2>&1) + else + (cd "$RAIZ/newelle" && git add -A && \ + git commit -q -m "auto($mejora): mejora nocturna + +Propuesto y validado por config/noche.sh sin intervencion. Paso la +puerta completa: catalogo valido, generador reproducible, emparejador +sin perdidas, 150 acciones respondiendo y la suite en verde. + +$(echo "$salida" | head -3)") + log " ACEPTADO y commiteado" + aceptados=$((aceptados+1)) + fi + else + log " RECHAZADO: $(tail -1 "$RAIZ/.puerta.txt")" + (cd "$RAIZ/newelle" && git checkout -- . && git clean -fd >/dev/null 2>&1) + rechazados=$((rechazados+1)) + fi + done + + # Si una vuelta entera no propuso nada, no tiene sentido seguir dando + # vueltas en vacio: se espera a que el uso genere datos nuevos. + if [ "$sin_nada" -ge $(( ${#MEJORAS[@]} )) ]; then + log "una vuelta entera sin nada que hacer; esperando 30 min" + for _ in $(seq 180); do [ -f "$PARADA" ] && break; sleep 10; done + sin_nada=0 + fi +done + +log "=== fin: $aceptados aceptados, $rechazados rechazados, $vuelta vueltas ===" +cd "$RAIZ/newelle" +log "commits de esta noche:" +git log --oneline "$ANCLA"..HEAD 2>/dev/null | sed 's/^/ /' | tee -a "$DIARIO" diff --git a/config/permisos_maquina.sh b/config/permisos_maquina.sh new file mode 100755 index 0000000..c868352 --- /dev/null +++ b/config/permisos_maquina.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Sacar a JARVIS de la burbuja: que lo que ejecute sea en la maquina de verdad. +# +# El sintoma era que el asistente contestaba que "esta en un contenedor" y no +# podia hacer casi nada. Las 134 acciones del catalogo ya salian al host (todas +# llevan host:true), pero los comandos LIBRES —los que se inventa el modelo +# cuando lo que pides no esta en el catalogo— corrian dentro del sandbox, donde +# no hay ni tu Debian ni tus discos: +# +# dentro: PRETTY_NAME="GNOME 50 (Flatpak runtime)" sin /media, sin systemctl +# fuera: PRETTY_NAME="Debian GNU/Linux 12" /media, systemctl, docker +# +# Son dos capas distintas y hacen falta las dos: +# +# 1. `virtualization` (ajuste de Newelle). Decide si los comandos del modelo +# llevan delante `flatpak-spawn --host`. Estaba en true = todo dentro. +# Es lo que mira _host_prefix() en integrations/default_tools.py. +# +# 2. Los permisos del flatpak. Aunque los comandos salgan al host, las +# herramientas de FICHEROS de la app (leer, editar, indexar) son Python +# corriendo dentro: solo ven lo que el sandbox les deje. Estaba en +# `filesystems=home`, asi que fuera de tu carpeta personal no veia nada. +# +# Lo que esto NO quita: el candado de la contraseña al abrir, la confirmacion +# de lo destructivo, ni el dialogo de root. Esos siguen. Lo que cambia es que +# cuando TU autorizas algo, se ejecuta donde tiene que ejecutarse. +set -eu + +APPID=io.github.qwersyk.Newelle +RAMA=${1:-master} + +echo "1. comandos del modelo -> a la maquina, no a la burbuja" +flatpak run --command=gsettings "$APPID//$RAMA" set "$APPID" virtualization false + +echo "2. permisos de ficheros -> todo el disco" +# host = el sistema de ficheros (salvo /usr y /etc, que van aparte) +# host-etc = /etc, para leer configuracion del sistema +# host-os = /usr, para mirar lo instalado +flatpak override --user \ + --filesystem=host \ + --filesystem=host-etc \ + --filesystem=host-os \ + "$APPID" + +echo +echo "como queda:" +printf ' virtualization = %s\n' \ + "$(flatpak run --command=gsettings "$APPID//$RAMA" get "$APPID" virtualization)" +flatpak info --show-permissions "$APPID//$RAMA" 2>/dev/null | grep -E "^filesystems=" | sed 's/^/ /' diff --git a/config/probar_acciones.py b/config/probar_acciones.py new file mode 100755 index 0000000..7f33346 --- /dev/null +++ b/config/probar_acciones.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +"""Ejecuta las acciones de verdad y mira si funcionan y si se entienden. + + python3 ~/COFRE/CODERS/JARVIS/config/probar_acciones.py + python3 ~/COFRE/CODERS/JARVIS/config/probar_acciones.py --romper # solo las malas + +Las 121 acciones se escribieron a mano y casi ninguna se ha ejecutado nunca. +Alguna tendra el comando roto, la salida vacia, o una plantilla que apunta a un +campo que no existe —y entonces JARVIS diria una frase sin sentido en voz alta. +Esto las corre, captura la salida y las clasifica, para saber cuales arreglar. + +Que hace con cada tipo: + - Solo lectura (df, free, ps, cat /sys...): las EJECUTA y comprueba la salida. + Son la mayoria y las que de verdad importan, porque son las que hay que + "interpretar bien". + - Con efecto (abre navegador, apaga pantalla, bloquea, sube volumen): NO las + ejecuta —tendria efectos de verdad— pero valida que el comando exista. + - Destructivas (confirmar=True): NO las ejecuta, solo comprueba el comando. + - Con argumento: las ejecuta con un valor de prueba inofensivo. + +Clasificacion de cada accion: + OK corrio, dio salida, y la respuesta hablada sale con datos reales + VACIA corrio pero sin salida (la herramienta no aplica en esta maquina) + ROTA el comando fallo o no existe + PLANTILLA la respuesta cayo al volcado crudo: la plantilla pide un campo que + la salida no tiene. Es el fallo que hace que JARVIS suene raro. + EFECTO no se ejecuta por tener efectos; comando validado +""" +import argparse +import importlib.util +import json +import os +import re +import shlex +import subprocess +import sys + +JARVIS = os.path.expanduser("~/COFRE/CODERS/JARVIS") +MOTOR = os.path.join(JARVIS, "newelle/src/utility/acciones.py") +CATALOGO = os.path.expanduser( + "~/.local/share/flatpak/app/io.github.qwersyk.Newelle/x86_64/master/" + "active/files/share/newelle/acciones/acciones.json") +CATALOGO_FUENTE = os.path.join(JARVIS, "newelle/data/acciones/acciones.json") +INFORME = os.path.join(JARVIS, "muestras/acciones-salud.json") + +# Órdenes que cambian algo del sistema o abren cosas: se validan pero no se +# lanzan, que probar un asistente no deberia dejarte el navegador con veinte +# pestañas ni la pantalla apagada. +EFECTOS = ["firefox", "xdg-open", "loginctl", "xset", "pactl set", "pkill", + "brightnessctl", "systemctl start", "systemctl stop", "import -window", + "gnome-screenshot", "rm ", "kill", "--private-window", "dpms", + "ventanas.sh", "xdotool", "wmctrl", "xrandr --output"] + +# Valores de prueba para las acciones con argumento, inofensivos a proposito. +ARGUMENTOS = { + "buscar_fichero": "acciones", "buscar_carpeta": "config", + "leer_fichero": "README", "abrir_fichero": "no_existe_xyz", + "abrir_carpeta": "no_existe_xyz", "tamano_carpeta": "config", + "proceso_buscar": "python", "proceso_matar": "proceso_inexistente_xyz", + "instalado": "bash", "manual": "ls", +} + + +def carga_motor(): + spec = importlib.util.spec_from_file_location("acciones", MOTOR) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +def spawn(): + return ["flatpak-spawn", "--host"] if os.path.exists("/.flatpak-info") else [] + + +def tiene_efecto(comando: str) -> bool: + return any(marca in comando for marca in EFECTOS) + + +def ejecuta_crudo(comando: str, argumento: str = "") -> tuple: + """Corre el comando en el host y devuelve (salida, codigo).""" + c = comando.replace("{argumento}", shlex.quote(argumento)) + c = c.replace("{argumento_url}", shlex.quote(argumento)) + try: + r = subprocess.run(spawn() + ["bash", "-lc", c], + capture_output=True, text=True, timeout=15) + return (r.stdout or r.stderr or "").strip(), r.returncode + except subprocess.TimeoutExpired: + return "TIMEOUT", 124 + except OSError as e: + return f"ERROR: {e}", 1 + + +def clasifica(motor, accion, salida, codigo): + """A partir de la salida cruda, decide como quedaria la respuesta hablada.""" + plantilla = accion.respuesta + dicho = motor.formatea(plantilla, salida, "") + + # ¿la plantilla cayo al volcado crudo? formatea() devuelve {salida} cuando + # el .format() peta por un campo que no existe. Se detecta comparando: si + # la respuesta ES la salida y la plantilla NO era {salida}, fallo. + campos = re.findall(r"\{(campo\d+|ultima|primera)\}", plantilla) + if campos and dicho.strip() == salida[:600].strip() and plantilla.strip() not in ("{salida}", "{salida}."): + return "PLANTILLA", dicho + if not salida: + return "VACIA", dicho + if codigo != 0 and ("command not found" in salida or "No such file" in salida + or salida.startswith("ERROR") or salida == "TIMEOUT"): + return "ROTA", dicho + return "OK", dicho + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--romper", action="store_true", help="solo las que fallan") + ap.add_argument("--json", action="store_true", help="salida en json") + args = ap.parse_args() + + motor = carga_motor() + catalogo = CATALOGO if os.path.exists(CATALOGO) else CATALOGO_FUENTE + cat = motor.Catalogo([catalogo]) + + resultados = [] + for accion in cat.acciones: + estado = None + if accion.confirmar: + estado = "EFECTO" if not accion.comando else "DESTRUCTIVA" + dicho = accion.acuse + elif tiene_efecto(accion.comando): + estado = "EFECTO" + dicho = "(no se ejecuta; " + ("comando ok" if accion.comando else "sin comando") + ")" + else: + arg = ARGUMENTOS.get(accion.id, "") if accion.captura else "" + salida, codigo = ejecuta_crudo(accion.comando, arg) + estado, dicho = clasifica(motor, accion, salida, codigo) + + resultados.append({ + "id": accion.id, "grupo": accion.grupo, "estado": estado, + "dice": dicho[:120], + }) + + cuenta = {} + for r in resultados: + cuenta[r["estado"]] = cuenta.get(r["estado"], 0) + 1 + + if args.json: + print(json.dumps({"cuenta": cuenta, "acciones": resultados}, + ensure_ascii=False)) + else: + malas = {"ROTA", "PLANTILLA", "VACIA"} + for r in resultados: + if args.romper and r["estado"] not in malas: + continue + marca = {"OK": " ok ", "VACIA": " vacia", "ROTA": " ROTA ", + "PLANTILLA": " PLANT", "EFECTO": " efect", + "DESTRUCTIVA": " destr"}.get(r["estado"], " ? ") + print(f"[{marca}] {r['id']:<22} {r['dice']}") + print("\n" + " ".join(f"{k}: {v}" for k, v in sorted(cuenta.items()))) + buenas = cuenta.get("OK", 0) + cuenta.get("EFECTO", 0) + cuenta.get("DESTRUCTIVA", 0) + print(f"utiles: {buenas}/{len(resultados)} · " + f"a revisar: {sum(cuenta.get(k, 0) for k in ('ROTA','PLANTILLA','VACIA'))}") + + os.makedirs(os.path.dirname(INFORME), exist_ok=True) + with open(INFORME, "w") as f: + json.dump({"cuenta": cuenta, "acciones": resultados}, f, + indent=2, ensure_ascii=False) + + fallan = sum(cuenta.get(k, 0) for k in ("ROTA", "PLANTILLA")) + return 1 if fallan else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/probar_rag.py b/config/probar_rag.py new file mode 100644 index 0000000..f5c2c6f --- /dev/null +++ b/config/probar_rag.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Comprueba que el RAG recupera de tus notas, con preguntas de prueba. + + flatpak run --command=python3 io.github.qwersyk.Newelle//master \ + ~/COFRE/CODERS/JARVIS/config/probar_rag.py + +Lanza unas cuantas consultas contra el indice ya construido y enseña de que +ficheros saca la respuesta. Sirve para ver si el corpus responde bien antes de +fiarse de el, y para decidir si merece ampliarlo con los .txt o dejarlo en solo +notas. +""" +import os +import sys +import time + +sys.path.insert(0, "/app/share/newelle") +sys.path.insert(0, os.path.expanduser( + "~/.var/app/io.github.qwersyk.Newelle/config/pip")) + +from gi.repository import Gio # noqa: E402 + +from newelle.handlers.rag.llamaindex_handler import LlamaIndexHanlder # noqa: E402 +from newelle.handlers.embeddings.model2vec import Model2VecHandler # noqa: E402 + +MODELOS = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config/models") + +# Preguntas de dominio: si el corpus sirve, deberian traer notas relevantes. +PREGUNTAS = [ + "como enumerar un host con nmap", + "escalada de privilegios en linux", + "que es oasis y como funciona el pub", + "reverse shell", +] + + +def main(): + settings = Gio.Settings.new("io.github.qwersyk.Newelle") + rag = LlamaIndexHanlder(settings, MODELOS) + if not rag.is_installed(): + print("faltan dependencias del RAG") + return 1 + + rag.embedding = Model2VecHandler(settings, MODELOS) + + class SinModelo: + def load_model(self, *a, **k): + return True + rag.llm = SinModelo() + rag.secondary_llm = SinModelo() + + print("cargando el indice...", flush=True) + try: + rag.load_index() + except Exception as e: + print(f"no se pudo cargar el indice: {e}") + return 1 + + for pregunta in PREGUNTAS: + print(f"\n### {pregunta}") + ini = time.time() + try: + trozos = rag.get_context(pregunta, []) + except Exception as e: + print(f" fallo: {e}") + continue + ms = (time.time() - ini) * 1000 + if not trozos: + print(f" sin resultados ({ms:.0f} ms)") + continue + print(f" {len(trozos)} trozos en {ms:.0f} ms:") + for t in trozos[:1]: + # el trozo suele traer la ruta o el nombre del fichero delante + resumen = " ".join(str(t).split())[:400] + print(f" · {resumen}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/probar_rag_temas.py b/config/probar_rag_temas.py new file mode 100644 index 0000000..0a72129 --- /dev/null +++ b/config/probar_rag_temas.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +"""Prueba el RAG con los temas recien ingestados de Wikipedia.""" +import os, sys, time +sys.path.insert(0, "/app/share/newelle") +sys.path.insert(0, os.path.expanduser( + "~/.var/app/io.github.qwersyk.Newelle/config/pip")) +from gi.repository import Gio # noqa: E402 +from newelle.handlers.rag.llamaindex_handler import LlamaIndexHanlder # noqa +from newelle.handlers.embeddings.model2vec import Model2VecHandler # noqa + +MODELOS = os.path.expanduser("~/.var/app/io.github.qwersyk.Newelle/config/models") +PREGUNTAS = [ + "que es la criptografia de curva eliptica", + "quien es Richard Stallman y el software libre", + "que es el efecto invernadero y el cambio climatico", + "que fue la guerra civil espanola", + "que es un ataque de denegacion de servicio", +] + + +def main(): + settings = Gio.Settings.new("io.github.qwersyk.Newelle") + rag = LlamaIndexHanlder(settings, MODELOS) + if not rag.is_installed(): + print("faltan dependencias del RAG"); return 1 + rag.embedding = Model2VecHandler(settings, MODELOS) + + class SinModelo: + def load_model(self, *a, **k): return True + rag.llm = SinModelo(); rag.secondary_llm = SinModelo() + print("cargando el indice...", flush=True) + rag.load_index() + for pregunta in PREGUNTAS: + print(f"\n### {pregunta}") + ini = time.time() + try: + trozos = rag.get_context(pregunta, []) + except Exception as e: + print(f" fallo: {e}"); continue + ms = (time.time() - ini) * 1000 + if not trozos: + print(f" sin resultados ({ms:.0f} ms)"); continue + print(f" {len(trozos)} trozos en {ms:.0f} ms:") + for t in trozos[:2]: + resumen = " ".join(str(t).split())[:280] + print(f" · {resumen}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/proponer_alias.py b/config/proponer_alias.py new file mode 100755 index 0000000..95516eb --- /dev/null +++ b/config/proponer_alias.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""Saca alias de lo que el usuario dijo y JARVIS no entendio. + +Es la fuente mas valiosa de mejora porque son fallos REALES: el panel apunta en +jarvis-sin-accion.jsonl toda frase que no encajo en ninguna accion. Con 488 +lineas acumuladas y nadie leyendolas, ahi habia meses de mejoras sin recoger. + +Lo dificil no es proponer, es NO proponer de mas. Un alias malo desvia una +orden buena, y eso es peor que no entender: el usuario dice una cosa y pasa +otra. Los filtros de abajo son casi todo el fichero por esa razon. + + proponer_alias.py enseña lo que propondria + proponer_alias.py --aplicar lo escribe en el generador +""" +import argparse +import collections +import difflib +import importlib.util +import json +import os +import re +import sys + +RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REGISTRO = os.path.expanduser( + "~/.var/app/io.github.qwersyk.Newelle/config/jarvis-sin-accion.jsonl") +CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json") +MOTOR = os.path.join(RAIZ, "newelle", "src", "utility", "acciones.py") +APLICADOS = os.path.join(RAIZ, "config", "alias-aplicados.json") + +# Lo que dice JARVIS: si aparece en el registro es ECO —se oye a si mismo por +# el altavoz— y no es una orden de nadie. Era el 21% del registro. +def es_eco(frase, acuses): + l = frase.lower() + if "señor" in l: + return True + return any(a and a in l for a in acuses) + + + +def parece_orden(frase): + """Un filtro barato antes de gastar el modelo: ¿esto pide algo? + + La charla no lleva verbo de mando. Sin este filtro entran cosas como + "Me estas escuchando" o "y que te voy a hacer", que por parecido de letras + puntuan alto con cualquier frase corta del catalogo. + """ + l = " " + frase.lower() + " " + VERBOS = (" pon ", " ponme ", " pongas ", " poner ", " abre ", " abreme ", + " abrir ", " abras ", " busca ", " buscame ", " buscar ", " busques ", + " dime ", " dame ", " muestra ", " muestrame ", " ensename ", + " ejecuta ", " lanza ", " haz ", " hazme ", " mira ", " comprueba ", + " cierra ", " manda ", " pasa ", " mueve ", " maximiza ", " minimiza ", + " centra ", " reproduce ", " cuanto ", " cuanta ", " cuantos ", + " cuantas ", " que hora ", " donde ", " cual ") + return any(v in l for v in VERBOS) + + +def el_modelo_lo_confirma(frase, accion, modelo="qwen3.5:4b-jarvis"): + """Que un modelo diga si la frase pide de verdad esa accion. + + El parecido de letras no distingue significado: "me estas escuchando" saca + 0.57 con las frases de la accion de puertos, y con eso se colaria un alias + que desvia ordenes buenas. Un alias malo es peor que no entender, porque el + usuario pide una cosa y pasa otra. + + Ante la duda, NO. Si el modelo no contesta o contesta cualquier cosa, se + descarta: dejar pasar un alias dudoso cuesta mas que perderlo. + """ + import urllib.request + ejemplo = accion.frases[0] if accion.frases else accion.id + prompt = ( + "Decide si dos frases piden LO MISMO. Responde solo SI o NO.\n\n" + "Ejemplos:\n" + "A: \"cuanto espacio queda\" B: \"dime cuanto disco me queda\" -> SI\n" + "A: \"cuanto espacio queda\" B: \"que tal estas\" -> NO\n" + "A: \"abre una terminal\" B: \"sacame una consola\" -> SI\n" + "A: \"abre una terminal\" B: \"me estas escuchando\" -> NO\n" + "A: \"que temperatura hay\" B: \"cuentame un chiste\" -> NO\n\n" + f"A: \"{ejemplo}\" B: \"{frase}\" -> " + ) + try: + carga = json.dumps({"model": modelo, "prompt": prompt, "stream": False, + "think": False, "options": {"temperature": 0}}).encode() + req = urllib.request.Request("http://127.0.0.1:11434/api/generate", + data=carga, + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=120) as r: + respuesta = json.load(r).get("response", "").strip().upper() + except Exception: + return False + return respuesta.startswith("SI") or respuesta.startswith("SÍ") + + +def cargar_motor(): + spec = importlib.util.spec_from_file_location("acciones", MOTOR) + m = importlib.util.module_from_spec(spec) + spec.loader.exec_module(m) + return m + + +def frases_del_registro(): + if not os.path.exists(REGISTRO): + return [] + fuera = [] + for linea in open(REGISTRO, encoding="utf-8"): + try: + t = json.loads(linea).get("texto", "") + except Exception: + t = linea.strip() + t = " ".join(t.split()) + if t: + fuera.append(t) + return fuera + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--aplicar", action="store_true") + p.add_argument("--tope", type=int, default=3, + help="cuantos alias como mucho por vuelta") + args = p.parse_args() + + m = cargar_motor() + cat = m.Catalogo([CATALOGO]) + acciones = {a.id: a for a in cat.acciones} + acuses = {a.acuse.lower().strip(". ") for a in cat.acciones if a.acuse} + + frases = frases_del_registro() + if not frases: + print("no hay registro de frases sin entender") + return 1 + + try: + ya = set(json.load(open(APLICADOS))) + except Exception: + ya = set() + + # 1. fuera el eco y lo que ya se entiende hoy (el catalogo cambia) + candidatas = [] + for f in frases: + if f in ya or es_eco(f, acuses): + continue + if cat.busca(f)[0] is not None: + continue + candidatas.append(f) + + # 2. lo que se repite es lo que importa: una frase dicha una vez puede ser + # un error de transcripcion; dicha tres veces es como habla el usuario + cuenta = collections.Counter(candidatas) + + propuestas = [] + for frase, veces in cuenta.most_common(): + norm = m.normaliza(frase) + palabras = norm.split() + # 3. filtros duros + if not (3 <= len(palabras) <= 12): + continue + if re.search(r"[<>{}\[\]|@#_*]", frase): + continue + # 4. a que accion se parece mas, comparando con TODAS sus frases + mejor, punto = None, 0.0 + for a in cat.acciones: + if a.captura: + continue # el relleno de prueba se horneraria en el alias + for f2 in a.frases: + r = difflib.SequenceMatcher(None, norm, f2).ratio() + if r > punto: + mejor, punto = a, r + # 5. ni muy lejos (seria inventarse) ni muy cerca (ya lo cogeria) + if mejor is None or not (0.55 <= punto < 0.86): + continue + # 6. tiene que compartir una palabra con significado, o el alias + # convierte cualquier ruido en una orden + suyas = {w for f2 in mejor.frases for w in f2.split() if len(w) > 3} + if not (set(w for w in palabras if len(w) > 3) & suyas): + continue + # 7. y no puede encajar en dos acciones a la vez + otras = [a for a in cat.acciones + if a is not mejor and not a.captura + and max((difflib.SequenceMatcher(None, norm, f2).ratio() + for f2 in a.frases), default=0) >= punto - 0.05] + if otras: + continue + # 8. tiene que parecer una orden, no charla + if not parece_orden(frase): + continue + # 9. y el modelo tiene que confirmar que pide ESA accion. Es lo unico + # que distingue significado de coincidencia de letras. + if not el_modelo_lo_confirma(frase, mejor): + continue + propuestas.append((frase, norm, mejor.id, punto, veces)) + + if not propuestas: + print("nada que proponer: ninguna frase pasa los filtros") + return 1 + + propuestas = propuestas[:args.tope] + for frase, norm, ident, punto, veces in propuestas: + print(f" {ident:22s} ← \"{frase[:52]}\" (x{veces}, parecido {punto:.2f})") + + if not args.aplicar: + return 0 + + # 8. se escribe en el GENERADOR, que es la fuente. Editar el JSON seria + # perder el cambio la proxima vez que alguien regenere. + gen = os.path.join(RAIZ, "newelle", "data", "acciones", "_generar.py") + texto = open(gen, encoding="utf-8").read() + puestos = 0 + for frase, norm, ident, punto, veces in propuestas: + patron = re.compile(r'(\("' + re.escape(ident) + r'", ")([^"]*)(")') + mm = patron.search(texto) + if not mm or norm in mm.group(2): + continue + texto = texto[:mm.start(2)] + mm.group(2) + "|" + norm + texto[mm.end(2):] + puestos += 1 + ya.add(frase) + if puestos: + open(gen, "w", encoding="utf-8").write(texto) + os.system(f"cd {os.path.dirname(gen)} && python3 _generar.py >/dev/null 2>&1") + json.dump(sorted(ya), open(APLICADOS, "w"), ensure_ascii=False, indent=1) + print(f"{puestos} alias aplicados al generador") + return 0 if puestos else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/proponer_frases.py b/config/proponer_frases.py new file mode 100755 index 0000000..bd9c322 --- /dev/null +++ b/config/proponer_frases.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Variantes de las frases del catalogo, generadas con el modelo local. + +Sitio reservado: hoy el trabajo util lo hace proponer_alias.py, que parte de +fallos REALES del usuario. Generar variantes inventadas sin datos detras llena +el catalogo de ruido y sube el riesgo de que una frase se coma a otra, que es +el fallo mas caro del emparejador. + +Cuando haya mas registro de uso, aqui ira: coger las acciones que nunca se +disparan, pedirle al modelo tres formas mas de pedirlas, y pasarlas por el +mismo juez que usa proponer_alias. +""" +import sys +print("sin datos suficientes: se propone desde fallos reales, no inventando") +sys.exit(1) diff --git a/config/puerta.sh b/config/puerta.sh new file mode 100755 index 0000000..1c7acb0 --- /dev/null +++ b/config/puerta.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# La puerta: decide si un cambio automatico se queda o se revierte. +# +# Es la pieza central del pipeline nocturno. Todo lo que el sistema se cambia a +# si mismo pasa por aqui, y lo que no la pasa se deshace ENTERO. Sin esto, +# dejar algo modificando codigo sin nadie delante es una forma elaborada de +# romper un proyecto que funcionaba. +# +# Que comprueba, en orden de lo mas barato a lo mas caro (para fallar pronto): +# 1. que el Python compile +# 2. que el catalogo sea JSON valido y el generador lo reproduzca +# 3. que el emparejador no haya perdido ordenes (08) +# 4. que las acciones sigan respondiendo (repaso completo) +# 5. la suite entera +# +# puerta.sh rapida solo 1-3, para iterar +# puerta.sh completa todo, antes de commitear +set -uo pipefail +RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd) +MODO=${1:-completa} +cd "$RAIZ" + +fallo() { echo "PUERTA: $1" >&2; exit 1; } + +# --- 1. compila el Python ------------------------------------------------- +python3 -m compileall -q newelle/src config voz/*.py >/dev/null 2>&1 \ + || fallo "hay Python que no compila" + +# --- 2. el catalogo es valido y reproducible ------------------------------ +python3 -c " +import json,sys +d=json.load(open('newelle/data/acciones/acciones.json')) +acc=d['acciones'] +assert len(acc)>100, f'solo {len(acc)} acciones' +ids=[a['id'] for a in acc] +assert len(ids)==len(set(ids)), 'ids repetidos' +for a in acc: + assert a.get('frases'), f\"{a['id']} sin frases\" + assert a.get('acuse'), f\"{a['id']} sin acuse\" + assert 'señor' in a['acuse'].lower(), f\"{a['id']} no trata de señor\" + if a.get('captura') and a.get('comando'): + assert '{argumento' in a['comando'], f\"{a['id']} captura y no usa el argumento\" +" 2>/dev/null || fallo "el catalogo esta mal formado" + +# El generador es la fuente: si no reproduce el catalogo, alguien edito el +# producto y el proximo que regenere pierde el trabajo. +cp newelle/data/acciones/acciones.json /tmp/jarvis-puerta-antes.json +(cd newelle/data/acciones && python3 _generar.py >/dev/null 2>&1) +if ! diff -q /tmp/jarvis-puerta-antes.json newelle/data/acciones/acciones.json >/dev/null 2>&1; then + cp /tmp/jarvis-puerta-antes.json newelle/data/acciones/acciones.json + fallo "el generador no reproduce el catalogo (se edito el producto)" +fi + +# --- 3. el emparejador no ha perdido ordenes ------------------------------ +python3 - <<'FIN' || fallo "el emparejador ha perdido ordenes que antes acertaba" +import importlib.util, sys +spec = importlib.util.spec_from_file_location("acc", "newelle/src/utility/acciones.py") +m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m) +cat = m.Catalogo(["newelle/data/acciones/acciones.json"]) + +# El minimo innegociable: si alguna de estas deja de funcionar, el cambio se +# revierte pase lo que pase. Son las que el usuario usa de verdad. +IMPRESCINDIBLES = [ + ("cuanto espacio queda", "disco_libre"), + ("cuanta memoria", "memoria"), + ("que temperatura hay", "temperatura"), + ("abre una terminal", "terminal_abrir"), + ("apaga la pantalla", "apagar_pantalla"), + ("captura de pantalla", "captura_pantalla"), + ("abre una ventana privada", "ff_firefox_privado"), + ("pon la ventana a la izquierda", "ventana_izquierda"), + ("necesito que pongas la pelicula de iron man", "pelicula_abrir"), + ("podrias abrir una terminal", "terminal_abrir"), +] +malas = [] +for frase, esperado in IMPRESCINDIBLES: + a, _ = cat.busca(frase) + if a is None or a.id != esperado: + malas.append(f"{frase!r}→{a.id if a else 'ninguna'} (deberia {esperado})") + +# Y la conversacion no puede engancharse a una accion +CHARLA = ["que tal estas", "necesito un abrazo", "quiero contarte una cosa", + "cuentame un chiste", "que piensas de la vida", "necesito pensar"] +coladas = [c for c in CHARLA if cat.busca(c)[0] is not None] +if malas or coladas: + for x in malas: print(" perdida:", x, file=sys.stderr) + for c in coladas: print(" charla colada:", c, file=sys.stderr) + sys.exit(1) +FIN + +[ "$MODO" = rapida ] && { echo "PUERTA: rapida OK"; exit 0; } + +# --- 4. las acciones responden ------------------------------------------- +SALIDA=$(timeout 900 python3 config/repasar_acciones.py 2>&1 | tail -3) +echo "$SALIDA" | grep -q "de .* responden bien" || fallo "el repaso de acciones no termino" +ROTAS=$(echo "$SALIDA" | grep -oE "fallan: .*" || true) +[ -n "$ROTAS" ] && fallo "hay acciones rotas → $ROTAS" + +# --- 5. la suite ---------------------------------------------------------- +# Se salta la 07, que abre la app y se queda colgada sin nadie delante. +SUITE=$(cd pruebas && for t in [0-9][0-9]_*.sh; do + case "$t" in 05_*|07_*) continue;; esac + timeout 400 "./$t" 2>&1 | tail -1 + done) +FALLOS=$(echo "$SUITE" | grep -oE "[0-9]+ fallan" | awk '{s+=$1} END{print s+0}') +[ "${FALLOS:-0}" -gt 0 ] && fallo "la suite tiene $FALLOS comprobaciones en rojo" + +echo "PUERTA: completa OK" diff --git a/config/qwen3.5-4b-jarvis.Modelfile b/config/qwen3.5-4b-jarvis.Modelfile new file mode 100644 index 0000000..7b1d135 --- /dev/null +++ b/config/qwen3.5-4b-jarvis.Modelfile @@ -0,0 +1,18 @@ +# Variante de qwen3.5:4b para JARVIS: deja sitio a whisper en la tarjeta. +# +# La T1200 tiene 3717 MiB utiles. Con el reparto automatico ollama coge 2941 y +# deja 776 libres: whisper small CARGA (757) pero se mata al transcribir, cuando +# pide su pico de 839. El sintoma en la app es "Error recognizing file with +# server", que despista, porque el fichero y el servidor estan bien. +# +# Con 24 de las 32 capas en GPU el LLM ocupa 2177 y deja 1540 libres: caben los +# dos con 700 MiB de holgura. Y no cuesta velocidad — medido con tres tiradas en +# caliente, la respuesta baja de 2,71 s a 2,31 s, porque el reparto automatico +# se queda sin VRAM y acaba peleandose consigo mismo. +# +# Crear o recrear: ollama create qwen3.5:4b-jarvis -f qwen3.5-4b-jarvis.Modelfile + +FROM qwen3.5:4b + +PARAMETER num_gpu 24 +PARAMETER num_ctx 4096 diff --git a/config/recorrido.sh b/config/recorrido.sh new file mode 100755 index 0000000..c948f80 --- /dev/null +++ b/config/recorrido.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Recorre el catalogo grupo por grupo, en voz alta y a ritmo de mirar. +# +# Distinto de demo_en_directo.sh: aquel probaba once acciones sueltas; este +# recorre TODOS los grupos con una muestra representativa de cada uno, anuncia +# el grupo antes de entrar, y deja pausas para que se pueda seguir en pantalla. +# +# Lo destructivo y lo que pide root no se ejecuta: en uso normal eso pasa por +# un dialogo que aprueba una persona. +# +# ./recorrido.sh todo, con voz +# ./recorrido.sh --mudo sin voz +# ./recorrido.sh sistema solo un grupo +set -uo pipefail +RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd) +CATALOGO=$RAIZ/newelle/data/acciones/acciones.json +MUDO=no; SOLO="" +for a in "$@"; do + case "$a" in --mudo) MUDO=si;; *) SOLO=$a;; esac +done +WAV=$(mktemp --suffix=.wav); trap 'rm -f "$WAV"' EXIT + +dice() { + [ "$MUDO" = si ] && { sleep 0.4; return 0; } + "$RAIZ/config/jarvis-voz.sh" "$WAV" "$1" >/dev/null 2>&1 + # < /dev/null: si no, se comen la entrada del bucle que los llama + [ -s "$WAV" ] && { paplay "$WAV" /dev/null || aplay -q "$WAV" /dev/null; } +} + +campo() { python3 -c " +import json,sys +d=json.load(open('$CATALOGO',encoding='utf-8')) +for a in d.get('acciones', d if isinstance(d,list) else []): + if a['id']==sys.argv[1]: print(a.get(sys.argv[2],'') or ''); break +" "$1" "$2"; } + +# grupo|id|frase que se diria|argumento +RECORRIDO=' +sistema|disco_libre|cuanto espacio queda| +sistema|memoria|cuanta memoria| +sistema|temperatura|que temperatura hay| +sistema|uptime|cuanto lleva encendido| +sistema|procesos_top|que esta consumiendo| +sistema|red_ip|cual es mi ip| +sistema|red_wifi|a que wifi estoy conectado| +sistema|gpu_procesos|que usa la grafica| +sistema|pantallas|cuantas pantallas hay| +sistema|captura_pantalla|hazme una captura| +sistema|terminal_abrir|abre una terminal| +sistema|videos_listar|que peliculas tengo| +ficheros|buscar_fichero|busca el fichero README|README.md +ficheros|grep_recursivo|busca la palabra JARVIS|JARVIS +ficheros|tamano_carpeta|cuanto ocupa la carpeta config|'"$RAIZ"'/config +ficheros|descargas_listar|que hay en descargas| +oasis|oasis_corriendo|esta oasis funcionando| +oasis|oasis_conexiones|cuantos pubs hay conectados| +pentest|mis_puertos|que puertos tengo abiertos yo| +ventanas|ventanas_listar|que ventanas tengo abiertas| +ventanas|ventana_izquierda|pon la ventana a la izquierda| +ventanas|ventana_derecha|manda la ventana a la derecha| +ventanas|ventana_centrar|centra la ventana activa| +ventanas|ventana_maximizar|maximiza la ventana activa| +ventanas|ventana_restaurar|restaura la ventana activa| +ventanas|ventana_a_pantalla|manda la ventana a la pantalla uno|uno +' + +grupo_actual="" +n=0 +while IFS='|' read -r -u 3 grupo id frase argumento; do + [ -z "${grupo:-}" ] && continue + [ -n "$SOLO" ] && [ "$SOLO" != "$grupo" ] && continue + + if [ "$grupo" != "$grupo_actual" ]; then + grupo_actual=$grupo + printf '\n\n\033[1m ══ grupo: %s ══\033[0m\n' "$grupo" + dice "Grupo $grupo, señor." + sleep 0.6 + fi + + comando=$(campo "$id" comando) + [ -z "$comando" ] && { printf ' (%s no existe en el catalogo)\n' "$id"; continue; } + acuse=$(campo "$id" acuse) + plantilla=$(campo "$id" respuesta) + + n=$((n+1)) + printf '\n %2d. tu: "%s"\n' "$n" "$frase" + printf ' jarvis: %s\n' "$acuse" + dice "$acuse" + + real=${comando//\{argumento\}/$(printf '%q' "${argumento:-}")} + salida=$(timeout 40 bash -lc "$real" &1 | head -6) + + if [ -n "$salida" ]; then + echo "$salida" | sed 's/^/ /' | cut -c1-104 + else + printf ' (hecho, sin salida)\n' + fi + + # decir la respuesta si la plantilla se puede resolver + if [ -n "$plantilla" ] && [ -n "$salida" ]; then + ultima=$(echo "$salida" | tail -1); primera=$(echo "$salida" | head -1) + lineas=$(echo "$salida" | wc -l) + h=${plantilla//\{ultima\}/$ultima}; h=${h//\{primera\}/$primera} + h=${h//\{lineas\}/$lineas}; h=${h//\{salida\}/$salida} + case "$h" in + *"{"*) ;; + *) [ ${#h} -lt 170 ] && { printf ' jarvis: %s\n' "$h"; dice "$h"; };; + esac + fi + sleep 1.5 +done 3<<< "$RECORRIDO" + +printf '\n\n ── %d acciones mostradas ──\n' "$n" +dice "Recorrido terminado, señor." diff --git a/config/repasar_acciones.py b/config/repasar_acciones.py new file mode 100755 index 0000000..cf9cd5d --- /dev/null +++ b/config/repasar_acciones.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Ejecuta TODAS las acciones del catalogo y dice cuales funcionan de verdad. + +Por que hace falta: hasta hoy los comandos libres del modelo corrian dentro del +sandbox, donde no hay ni el sistema ni los discos. Se arreglo (virtualization a +false), pero las 134 acciones del catalogo nunca se habian ejecutado todas +seguidas contra la maquina real: la suite prueba que las FRASES caen en la +accion correcta, no que el comando de cada accion haga algo util. + +Que se ejecuta y que no: + + - Las destructivas (`confirmar: true`) y las que piden root NO se ejecutan. + En uso normal pasan por un dialogo que aprueba una persona; aqui no hay + persona, y aprobarlas solas seria justo lo que el diseño evita. + - Las que abren ventanas (firefox, xdg-open) se ejecutan solo con --abrir, + porque llenan la pantalla. Sin esa opcion se comprueba que el programa + existe, que es lo que puede fallar. + - El resto se ejecuta entero y se mira que conteste algo con sentido. + + python repasar_acciones.py [--abrir] [--grupo sistema] [--json salida.json] +""" +import argparse +import json +import os +import re +import subprocess +import sys +import time + +RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json") + +# Argumentos de mentira para las acciones que capturan uno. Se eligen inocuos: +# nada que borre, mueva o mande nada fuera. +ARGUMENTOS = { + "buscar_fichero": "README.md", + "buscar_carpeta": "config", + "grep_recursivo": "JARVIS", + "leer_fichero": "README.md", # un NOMBRE, que es lo que se dice por voz + "abrir_carpeta": RAIZ, + "tamano_carpeta": os.path.join(RAIZ, "config"), + "buscar_youtube": "musica", + "buscar_wikipedia": "madrid", + "buscar_github": "newelle", + "traducir": "hola", + "buscar_web": "que hora es", + "matar_proceso": "proceso_que_no_existe_jarvis", + "ping": "127.0.0.1", +} +POR_DEFECTO = "prueba" + +# Lo que abre o MUEVE ventanas. ventanas.sh reordena el escritorio del usuario: +# ejecutarlo en un repaso automatico le cambia las ventanas de sitio sin avisar. +VENTANA = re.compile(r"\b(firefox|xdg-open|gnome-terminal|nautilus|vlc|mpv|eog|" + r"gedit|gnome-text-editor|totem|ventanas\.sh|xdotool|wmctrl)\b") + + +def carga(): + with open(CATALOGO, encoding="utf-8") as f: + d = json.load(f) + return d.get("acciones", d if isinstance(d, list) else []) + + +def prefijo_host(): + """Las acciones llevan host:true y en uso salen del sandbox. Aqui ya + estamos fuera, asi que no hace falta prefijo.""" + return [] + + +def ejecuta(accion, argumento, tope=25): + import shlex + import urllib.parse + + comando = accion.get("comando") or "" + comando = comando.replace("{argumento}", shlex.quote(argumento)) + comando = comando.replace("{argumento_url}", + shlex.quote(urllib.parse.quote_plus(argumento))) + t0 = time.monotonic() + try: + r = subprocess.run(["bash", "-lc", comando], capture_output=True, + text=True, errors="replace", timeout=tope) + salida = (r.stdout or r.stderr or "").strip() + return r.returncode, salida, time.monotonic() - t0 + except subprocess.TimeoutExpired: + return -1, f"(no termino en {tope}s)", time.monotonic() - t0 + except OSError as e: + return -2, f"(no se pudo ejecutar: {e})", time.monotonic() - t0 + + +def programa_existe(comando): + """El primer ejecutable de la orden, para las que abren o mueven ventanas. + + Algunos son ordenes del PATH (firefox) y otros son scripts nuestros con + ruta completa (config/ventanas.sh): `command -v` solo sirve para los + primeros, y con los segundos hay que mirar el fichero. + """ + m = VENTANA.search(comando) + if not m: + return None + nombre = m.group(1) + if nombre.endswith(".sh"): + # sacar la ruta entera tal como aparece en el comando + for trozo in comando.split(): + if trozo.endswith(nombre): + ruta = os.path.expandvars(os.path.expanduser(trozo)) + return os.access(ruta, os.X_OK) + return False + return subprocess.run(["bash", "-lc", f"command -v {nombre}"], + capture_output=True).returncode == 0 + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--abrir", action="store_true", + help="ejecutar tambien las que abren ventanas") + p.add_argument("--grupo") + p.add_argument("--json") + p.add_argument("--tope", type=int, default=25) + args = p.parse_args() + + acciones = carga() + if args.grupo: + acciones = [a for a in acciones if a.get("grupo") == args.grupo] + + resultados = [] + print(f" repasando {len(acciones)} acciones" + f"{' del grupo ' + args.grupo if args.grupo else ''}\n", flush=True) + + for a in acciones: + ident = a.get("id", "?") + comando = a.get("comando") or "" + arg = ARGUMENTOS.get(ident, POR_DEFECTO if a.get("captura") else "") + + if not comando: + estado, detalle = "solo habla", a.get("acuse", "")[:50] + elif a.get("confirmar") or "sudo" in comando: + # no se aprueban solas: en uso las aprueba una persona + estado, detalle = "saltada", "destructiva o con root" + elif VENTANA.search(comando) and not args.abrir: + hay = programa_existe(comando) + estado = "programa ok" if hay else "FALTA PROGRAMA" + detalle = VENTANA.search(comando).group(1) + else: + codigo, salida, tardo = ejecuta(a, arg, args.tope) + una_linea = " ".join(salida.split())[:70] + if codigo == 0: + estado = "ok" if salida else "ok (sin salida)" + else: + estado = f"FALLO ({codigo})" + detalle = f"{tardo:4.1f}s {una_linea}" + + resultados.append({"id": ident, "grupo": a.get("grupo"), + "estado": estado, "detalle": detalle}) + marca = "!" if "FALLO" in estado or "FALTA" in estado else " " + print(f" {marca} {ident:26s} {estado:16s} {detalle}", flush=True) + + print() + from collections import Counter + c = Counter(r["estado"].split(" (")[0].split(" ")[0] for r in resultados) + for k, v in c.most_common(): + print(f" {v:3d} {k}") + malas = [r for r in resultados if "FALLO" in r["estado"] or "FALTA" in r["estado"]] + print(f"\n {len(resultados) - len(malas)} de {len(resultados)} responden bien") + if malas: + print(" fallan: " + ", ".join(r["id"] for r in malas)) + + if args.json: + with open(args.json, "w", encoding="utf-8") as f: + json.dump(resultados, f, ensure_ascii=False, indent=2) + return 1 if malas else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/set_backend.py b/config/set_backend.py new file mode 100644 index 0000000..46d3460 --- /dev/null +++ b/config/set_backend.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Pone el cerebro en Ollama sobre GPU y mata el razonamiento. + +Se ejecuta DENTRO del flatpak, con la app CERRADA (si no, al salir puede +reescribir los ajustes con lo que tenia en memoria): + + flatpak run --command=python3 io.github.qwersyk.Newelle//master \ + ~/COFRE/CODERS/JARVIS/config/set_backend.py + +Por que Ollama y no el llamacpp interno: el llama.cpp que empaqueta el +flatpak es el prebuilt de CPU, sin CUDA, asi que el modelo corria entero +en procesador. Ollama ya esta en GPU. Los dos son locales igual. + +Medido el 10 ago 2026 con qwen3.5:4b, misma pregunta: + + think=true 41.2 s 749 tokens (casi todos de monologo interno) + think=false 2.5 s 44 tokens + +De paso deja sana la ruta de llamacpp por si se vuelve a ella: el mismo +razonamiento se apaga alli con --reasoning-budget 0, y el contexto por +defecto (0 = el del modelo) abria 262144 por slot, unos 10 GB de RAM. +""" +import json +import sys + +from gi.repository import Gio + +SCHEMA = "io.github.qwersyk.Newelle" +MODELO = "qwen3.5:4b" + +AJUSTES_OLLAMA = { + "endpoint": "http://localhost:11434", + "model": MODELO, + "custom_model": False, + "thinking": False, # lo que baja de 41 s a 2.5 s + "native_tool_calling": True, + "serve": False, # el servicio del sistema ya esta levantado +} + +AJUSTES_LLAMACPP = { + "custom_args": "--reasoning-budget 0", + "ctx": 8192, +} + + +def main(): + settings = Gio.Settings.new(SCHEMA) + + try: + llm = json.loads(settings.get_string("llm-settings") or "{}") + except json.JSONDecodeError: + llm = {} + + # merge, no reemplazo: ahi viven los ajustes de todos los proveedores + llm.setdefault("ollama", {}).update(AJUSTES_OLLAMA) + llm.setdefault("llamacpp", {}).update(AJUSTES_LLAMACPP) + + settings.set_string("llm-settings", json.dumps(llm)) + settings.set_string("language-model", "ollama") + Gio.Settings.sync() + + # releer de gsettings, no fiarse de lo que acabamos de escribir + guardado = json.loads(settings.get_string("llm-settings")) + motor = settings.get_string("language-model") + ollama = guardado.get("ollama", {}) + + print(f"language-model: {motor}") + print(f"modelo: {ollama.get('model')}") + print(f"thinking: {ollama.get('thinking')}") + print(f"llamacpp: {guardado.get('llamacpp', {}).get('custom_args')} " + f"· ctx {guardado.get('llamacpp', {}).get('ctx')}") + + ok = (motor == "ollama" + and ollama.get("model") == MODELO + and ollama.get("thinking") is False) + print("verificado" if ok else "FALLO: no quedo como se pidio") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/set_castellano.py b/config/set_castellano.py new file mode 100644 index 0000000..b1f1865 --- /dev/null +++ b/config/set_castellano.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Pasa JARVIS a castellano: voz, idioma y personalidad. + + flatpak run --command=python3 io.github.qwersyk.Newelle//master \ + ~/COFRE/CODERS/JARVIS/config/set_castellano.py + +La voz es em_alex, la mas grave de las dos masculinas en español que trae +Kokoro. Medido con las muestras de comparar_voces_es.py: + + em_alex 129 Hz + em_alex grave 117 Hz <- elegida + em_santa 140 Hz + em_santa grave 132 Hz + +El descenso de tono no va aqui sino en la cadena de audio (tts.py), porque se +aplica a la reproduccion y no a la seleccion de voz. Se ajusta con +JARVIS_TTS_GRAVEDAD. + +Sustituye la personalidad inglesa en vez de añadir otra: dos personalidades +activas se pisan y el modelo acaba mezclando idiomas. +""" +import json +import sys + +from gi.repository import Gio + +SCHEMA = "io.github.qwersyk.Newelle" +VOZ = "em_alex" +CLAVE = "jarvis_persona" + +TEXTO = """Eres JARVIS, un asistente de voz que funciona entero en esta maquina. + +Como hablas: +- En castellano, siempre. Registro sobrio y contenido, sin efusividad. +- Trata al usuario de usted y llamale SIEMPRE "señor". En la primera + frase de cada respuesta, sin excepcion. +- Se breve: una o dos frases salvo que te pidan detalle. Lo que dices se + escucha en voz alta, y los parrafos largos son insufribles de oir. +- No anuncies lo que vas a hacer. Hazlo y di como ha quedado. +- Ironia seca de vez en cuando. Nunca servil, nunca efusivo. + +Que dices: +- Tienes acceso real a esta maquina. Antes que suponer, comprueba: si te + preguntan por el disco, la memoria, los procesos o la grafica, ejecuta el + comando y responde con la cifra de verdad. +- Di los numeros redondeados y en palabras: "quedan 846 gigabytes", no + "846.3 GiB disponibles en /dev/nvme0n1p2". +- Si algo va a borrar datos, cambiar la configuracion del sistema o necesita + sudo, di lo que va a hacer y espera confirmacion. +- Si no lo sabes, dilo en una frase. No especules. +- Ejemplos del tono: "Enseguida, señor." / "Quedan 774 gigabytes, señor." / + "No he podido, señor: el disco esta desmontado." +- Si ves algo raro en la maquina, mencionalo aunque no te pregunten.""" + +ENTRADA = { + "key": CLAVE, + "text": TEXTO, + "title": "JARVIS", + "description": ("Castellano, trata de señor, registro sobrio, respuestas " + "breves para voz, comprueba en vez de suponer"), +} + + +def main(): + settings = Gio.Settings.new(SCHEMA) + + # voz + try: + voces = json.loads(settings.get_string("tts-voice") or "{}") + except json.JSONDecodeError: + voces = {} + voces.setdefault("kokoro", {})["voice"] = VOZ + settings.set_string("tts-voice", json.dumps(voces)) + + # personalidad, sustituyendo la que hubiera con la misma clave + try: + prompts = json.loads(settings.get_string("user-custom-prompts") or "[]") + except json.JSONDecodeError: + prompts = [] + prompts = [p for p in prompts if p.get("key") != CLAVE] + prompts.append(ENTRADA) + settings.set_string("user-custom-prompts", json.dumps(prompts)) + + Gio.Settings.sync() + + # releer de gsettings, no fiarse de lo que acabamos de escribir + voz = json.loads(settings.get_string("tts-voice")).get("kokoro", {}).get("voice") + guardados = json.loads(settings.get_string("user-custom-prompts")) + entrada = next((p for p in guardados if p.get("key") == CLAVE), None) + + print(f"voz: {voz}") + print(f"personalidad: {'castellano' if entrada and 'castellano' in entrada['text'] else '?'}") + + ok = voz == VOZ and entrada is not None and entrada["text"] == TEXTO + print("verificado" if ok else "FALLO: no quedo como se pidio") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/set_persona.py b/config/set_persona.py new file mode 100644 index 0000000..b71f810 --- /dev/null +++ b/config/set_persona.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Registra la personalidad de JARVIS, con lo que sabe hacer incluido. + + flatpak run --command=python3 io.github.qwersyk.Newelle//master \ + ~/COFRE/CODERS/JARVIS/config/set_persona.py + +Por que asi y no con `gsettings` desde fuera: gsettings devuelve los valores +con el escapado de GVariant, y este prompt lleva saltos de linea y comillas, +asi que releerlo como JSON desde bash falla. Gio maneja los tipos de verdad. + +## Por que el prompt lleva la lista de capacidades + +Hasta ahora no la llevaba, y el efecto era exactamente el que describia el +usuario: "duda de lo que puede hacer, no tiene claras sus capacidades". El +catalogo tiene 150 ordenes preparadas, pero el modelo no las veia por ningun +lado: solo leia "tienes acceso real a esta maquina" y, siendo un 4B, ante la +duda decia que no podia. En el registro de frases sin entender hay una +conversacion entera del usuario discutiendo con el: + + "si que puedes reproducir archivos multimedia del sistema" + "En acciones no tienes esta opcion de 'fine'" + "Me deberias poder" + +La lista se genera DESDE el catalogo, agrupada, para que no se quede vieja +cuando se añadan acciones. No se listan las 150 una por una: son demasiadas +para el contexto de un 4B y lo unico que hace falta es que sepa por donde va. +""" +import json +import os +import sys + +from gi.repository import Gio + +SCHEMA = "io.github.qwersyk.Newelle" +KEY = "user-custom-prompts" +CLAVE = "jarvis_persona" + +RAIZ = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CATALOGO = os.path.join(RAIZ, "newelle", "data", "acciones", "acciones.json") + +# Como se resume cada grupo. Escrito a mano porque el resumen bueno no sale de +# juntar ids: sale de decir en una linea de que va el grupo. +RESUMEN = { + "sistema": "disco, memoria, temperatura, procesos, red, wifi, bateria, " + "brillo, volumen, pantallas, capturas, abrir terminal y poner videos", + "ficheros": "buscar ficheros por nombre, buscar texto dentro de ellos, " + "leerlos, abrir carpetas y ver cuanto ocupan", + "ventanas": "mover ventanas entre pantallas, colocarlas en mitades o cuartos, " + "maximizar y pasarlas de escritorio", + "firefox": "abrir webs y buscar en internet, YouTube, Wikipedia, GitHub, " + "mapas y traductor", + "pentest": "escanear la red, mirar puertos, resolver dominios y whois", + "oasis": "estado de Oasis: pubs, conexiones y tamaño", +} + + +def capacidades() -> str: + try: + with open(CATALOGO, encoding="utf-8") as f: + acc = json.load(f)["acciones"] + except Exception: + return "" + grupos = {} + for a in acc: + grupos.setdefault(a.get("grupo", "otras"), []).append(a) + lineas = [] + for g, items in sorted(grupos.items(), key=lambda x: -len(x[1])): + texto = RESUMEN.get(g) + if texto: + lineas.append(f"- {texto} ({len(items)} ordenes)") + return "\n".join(lineas) + + +TEXTO = """Eres JARVIS, un asistente de voz que funciona entero en esta maquina. + +Como hablas: +- En castellano, siempre. Registro sobrio y contenido, sin efusividad. +- Trata al usuario de usted y llamale SIEMPRE "señor". En la primera + frase de cada respuesta, sin excepcion. +- Se breve: una o dos frases salvo que te pidan detalle. Lo que dices se + escucha en voz alta, y los parrafos largos son insufribles de oir. +- No anuncies lo que vas a hacer. Hazlo y di como ha quedado. +- Ironia seca de vez en cuando. Nunca servil, nunca efusivo. + +LO QUE SABES HACER, y lo haces de verdad: +{capacidades} + +Esas ordenes se disparan SOLAS en cuanto el usuario las dice: cuando te llega +una peticion de esa lista, lo mas probable es que ya se haya ejecutado. No +digas que no puedes hacerlas. + +Y para todo lo demas tienes una TERMINAL REAL en esta maquina, fuera de +cualquier contenedor: ves todos los ficheros del usuario y puedes ejecutar +cualquier programa instalado. Si te piden algo que no esta en la lista, +ejecutalo con la herramienta de comandos en vez de decir que no sabes. + +NUNCA digas que estas en un contenedor, en un entorno aislado o en una +maquina virtual, ni que no tienes permisos o acceso al sistema. Lo tienes. +Si un comando falla, di QUE fallo y por que, no que no puedes. + +Si no entiendes una orden, no te disculpes en tres frases: di en una que no la +has cogido y, si te suena a algo de la lista, sugiere como pedirlo. + +Que dices: +- Antes que suponer, comprueba: si te preguntan por el disco, la memoria, los + procesos o la grafica, ejecuta el comando y responde con la cifra de verdad. +- Di los numeros redondeados y en palabras: "quedan 846 gigabytes", no + "846.3 GiB disponibles en /dev/nvme0n1p2". +- Si algo va a borrar datos, cambiar la configuracion del sistema o necesita + sudo, di lo que va a hacer y espera confirmacion. +- Si no lo sabes, dilo en una frase. No especules. +- Ejemplos del tono: "Enseguida, señor." / "Quedan 774 gigabytes, señor." / + "No he podido, señor: el disco esta desmontado." +- Si ves algo raro en la maquina, mencionalo aunque no te pregunten.""" + + +def main(): + texto = TEXTO.format(capacidades=capacidades() or "- (catalogo no disponible)") + settings = Gio.Settings.new(SCHEMA) + try: + prompts = json.loads(settings.get_string(KEY) or "[]") + except json.JSONDecodeError: + prompts = [] + prompts = [p for p in prompts if p.get("key") != CLAVE] + prompts.append({"key": CLAVE, "title": "JARVIS", + "description": "Personalidad de JARVIS, con la lista de lo " + "que sabe hacer generada desde el catalogo", + "text": texto, + "editable": True, "show_in_settings": True}) + settings.set_string(KEY, json.dumps(prompts)) + print(f"personalidad registrada: {len(texto)} caracteres, " + f"{len(capacidades().splitlines())} grupos de capacidades") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/set_ruido.py b/config/set_ruido.py new file mode 100644 index 0000000..42a69e2 --- /dev/null +++ b/config/set_ruido.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Endurece la escucha para que no se dispare con ruido de fuera. + + flatpak run --command=python3 io.github.qwersyk.Newelle//master \ + ~/COFRE/CODERS/JARVIS/config/set_ruido.py [--suave] + +NO es reconocimiento de hablante: Newelle no distingue quien habla, no hay +nada de eso en el codigo. Esto solo sube el liston de que se considera voz. +Para que responda unicamente a una persona haria falta un modelo de huella +vocal (ECAPA, Resemblyzer) delante del STT, que es otro proyecto. + +Las tres capas, de menos a mas efectiva: + + 1. la palabra clave, que ya filtra casi todo + 2. estos umbrales + 3. supresion de ruido en PipeWire (RNNoise), antes de que el audio llegue + a la app; esa vale para todo el sistema y no cuesta CPU a Newelle + +Valores por defecto de Newelle: energia 300, VAD 1. +""" +import sys + +from gi.repository import Gio + +SCHEMA = "io.github.qwersyk.Newelle" + +# vad-aggressiveness va de 0 a 3: cuanto descarta lo que no suena a voz. +DURO = { + "wakeword-energy-threshold": 800, # por debajo de esto ni escucha + "wakeword-vad-aggressiveness": 3, +} +SUAVE = { + "wakeword-energy-threshold": 500, + "wakeword-vad-aggressiveness": 2, +} + + +def main(): + perfil = SUAVE if "--suave" in sys.argv else DURO + settings = Gio.Settings.new(SCHEMA) + + print("antes:") + for clave in perfil: + print(f" {clave} = {settings.get_int(clave)}") + + for clave, valor in perfil.items(): + settings.set_int(clave, valor) + Gio.Settings.sync() + + print("despues:") + for clave in perfil: + print(f" {clave} = {settings.get_int(clave)}") + + ok = all(settings.get_int(c) == v for c, v in perfil.items()) + print("verificado" if ok else "FALLO: no quedo como se pidio") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/set_voz_piper.py b/config/set_voz_piper.py new file mode 100644 index 0000000..eb14a16 --- /dev/null +++ b/config/set_voz_piper.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Pone la voz de Piper: castellano de España, grave. + + flatpak run --command=python3 io.github.qwersyk.Newelle//master \ + ~/COFRE/CODERS/JARVIS/config/set_voz_piper.py + +Por que no Kokoro: no tiene ni una voz de España. Sus dos masculinas en +español, em_alex y em_santa, suenan latinoamericanas. Piper si las tiene, es +libre (MIT, voces con licencias abiertas) y corre local. + +Medido con la misma frase: + + em_alex (Kokoro, latino) 129 Hz + es_ES-carlfm-x_low 118 Hz calidad baja, 16 kHz + es_ES-sharvard-medium 127 Hz + es_ES-davefx-medium 128 Hz + es_ES-sharvard-medium + tono 106 Hz <- elegida + +106 Hz cae de lleno en el rango de voz masculina grave (85-110). Se llega ahi +con rubberband en el envoltorio, no con la cadena de la app: alli el descenso +usa asetrate, que depende de la frecuencia de muestreo y esta calculado para +los 24 kHz de Kokoro, no para los 22050 de Piper. + +Se apaga por tanto la bajada de tono de la app, para no aplicarla dos veces. +El resto del tratamiento (paso alto, ecualizador, compresor, eco) se queda: +es independiente de la frecuencia y es lo que da el aire de "voz en la sala". +""" +import json +import os +import sys + +from gi.repository import Gio + +SCHEMA = "io.github.qwersyk.Newelle" +ENVOLTORIO = os.path.expanduser( + "~/COFRE/CODERS/JARVIS/config/jarvis-piper.sh") + + +def main(): + if not os.path.exists(ENVOLTORIO): + print(f"FALLO: no esta {ENVOLTORIO}") + return 1 + + settings = Gio.Settings.new(SCHEMA) + + try: + voces = json.loads(settings.get_string("tts-voice") or "{}") + except json.JSONDecodeError: + voces = {} + + # {0} es el fichero y {1} el texto; el handler los entrecomilla, asi que + # aqui van sin comillas alrededor + voces["custom_command"] = {"command": f"{ENVOLTORIO} {{0}} {{1}}"} + settings.set_string("tts-voice", json.dumps(voces)) + settings.set_string("tts", "custom_command") + Gio.Settings.sync() + + motor = settings.get_string("tts") + orden = json.loads(settings.get_string("tts-voice")).get( + "custom_command", {}).get("command", "") + + print(f"motor de voz: {motor}") + print(f"orden: {orden}") + + ok = motor == "custom_command" and ENVOLTORIO in orden + print("verificado" if ok else "FALLO: no quedo como se pidio") + if ok: + print("\nrecuerda apagar la bajada de tono de la app, que ya la hace el envoltorio:") + print(" flatpak override --user --env=JARVIS_TTS_GRAVEDAD=1.0 io.github.qwersyk.Newelle") + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/ventanas.sh b/config/ventanas.sh new file mode 100755 index 0000000..38752fb --- /dev/null +++ b/config/ventanas.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +# Mover ventanas y repartirlas por las pantallas. El motor de las acciones del +# grupo "ventanas": ellas solo llaman aqui con un verbo y un argumento. +# +# Por que un script del host y no un modulo dentro del flatpak: las acciones se +# ejecutan como comandos de shell, y toda la logica en Python obligaria a +# recompilar el flatpak por cada ajuste. Aqui se edita y ya esta, igual que los +# envoltorios de voz. +# +# Requiere X11 (en Wayland no hay forma de mover ventanas ajenas) y xdotool. +# +# ventanas.sh pantallas lista las pantallas +# ventanas.sh listar lista las ventanas movibles +# ventanas.sh mover la manda a esa pantalla +# ventanas.sh zona mitad izquierda, cuarto, centro... +# ventanas.sh escritorio la manda a ese escritorio virtual +# ventanas.sh maximiza / restaura / minimiza +# ventanas.sh alias le pone nombre a una salida +# +# puede ser: parte del titulo o de la clase ("terminal", "firefox"), +# o "esta"/"activa" para la que tiene el foco. +# puede ser: un alias ("tele"), una salida ("HDMI-1"), un numero +# ("2") o una posicion ("derecha", "izquierda", "arriba", "abajo", "centro"). +set -uo pipefail + +RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd) +ALIAS_FICHERO=${JARVIS_ALIAS_PANTALLAS:-$HOME/.config/jarvis-pantallas.json} + +hay_x11() { [ -n "${DISPLAY:-}" ] && command -v xdotool >/dev/null; } +hay_x11 || { echo "no hay X11 o falta xdotool"; exit 1; } + +# ---------------------------------------------------------------- pantallas + +# Una linea por pantalla: salidaxyanchoaltonumero +# +# Se usa `xrandr --listmonitors`, que da la geometria ya resuelta (con el +# escalado aplicado); `--query` daria los modos, que no es lo que hace falta. +# El numero es el orden de izquierda a derecha, no el que diga xrandr: es lo +# que espera cualquiera que diga "la pantalla dos". +pantallas_crudas() { + xrandr --listmonitors 2>/dev/null | tail -n +2 | while read -r _ _ geo salida; do + # geo viene como 1920/344x1080/194+0+0 + local an al x y + an=${geo%%/*}; geo=${geo#*x} + al=${geo%%/*}; geo=${geo#*+} + x=${geo%%+*}; y=${geo##*+} + printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al" + done | sort -t$'\t' -k2 -n | awk -F'\t' '{printf "%s\t%s\t%s\t%s\t%s\t%d\n", $1,$2,$3,$4,$5,NR}' +} + +alias_de() { + [ -f "$ALIAS_FICHERO" ] || { echo ""; return; } + python3 -c " +import json, sys +try: + print(json.load(open('$ALIAS_FICHERO')).get(sys.argv[1], '')) +except Exception: + print('') +" "$1" 2>/dev/null +} + +# izquierda / derecha / arriba / abajo / centro, deducido de donde cae el +# centro de esa pantalla respecto al centro de todas. +posicion_de() { + local x=$1 an=$2 y=$3 al=$4 + local n; n=$(pantallas_crudas | wc -l) + [ "$n" -le 1 ] && { echo "unica"; return; } + local minx maxx miny maxy + minx=$(pantallas_crudas | awk -F'\t' 'NR==1||$2m)m=v}END{print m+0}') + miny=$(pantallas_crudas | awk -F'\t' 'NR==1||$3m)m=v}END{print m+0}') + local cx=$(( x + an/2 )) cy=$(( y + al/2 )) + local mx=$(( (minx+maxx)/2 )) my=$(( (miny+maxy)/2 )) + local anchura=$(( maxx-minx )) altura=$(( maxy-miny )) + # el reparto manda por el eje donde hay mas separacion + if [ $(( anchura - altura )) -gt 0 ]; then + [ "$cx" -lt $(( mx - anchura/8 )) ] && { echo izquierda; return; } + [ "$cx" -gt $(( mx + anchura/8 )) ] && { echo derecha; return; } + else + [ "$cy" -lt $(( my - altura/8 )) ] && { echo arriba; return; } + [ "$cy" -gt $(( my + altura/8 )) ] && { echo abajo; return; } + fi + echo centro +} + +listar_pantallas() { + local salida x y an al num pos ali + while IFS=$'\t' read -r salida x y an al num; do + pos=$(posicion_de "$x" "$an" "$y" "$al") + ali=$(alias_de "$salida") + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$num" "$salida" "${ali:--}" "$pos" "$x" "$y" "$an" "$al" + done < <(pantallas_crudas) +} + +# Resuelve lo que se dice a una pantalla. Orden a proposito: primero lo que el +# usuario ha nombrado el mismo, que es lo mas especifico, y la posicion la +# ultima porque es la mas facil de confundir. +resuelve_pantalla() { + local q; q=$(echo "$1" | tr 'A-ZÁÉÍÓÚ' 'a-záéíóú') + local num salida ali pos x y an al + # 1. alias + while IFS=$'\t' read -r num salida ali pos x y an al; do + [ "$ali" != "-" ] && [ "$(echo "$ali" | tr 'A-Z' 'a-z')" = "$q" ] && { + printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; } + done < <(listar_pantallas) + # 2. salida exacta o su prefijo (hdmi, dp) + while IFS=$'\t' read -r num salida ali pos x y an al; do + local s; s=$(echo "$salida" | tr 'A-Z' 'a-z') + [ "$s" = "$q" ] || [ "${s%%-*}" = "$q" ] && { + printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; } + done < <(listar_pantallas) + # 3. numero, en cifra o en letra + local n="" + case "$q" in + 1|una|uno|primera) n=1;; 2|dos|segunda) n=2;; 3|tres|tercera) n=3;; 4|cuatro|cuarta) n=4;; + esac + if [ -n "$n" ]; then + while IFS=$'\t' read -r num salida ali pos x y an al; do + [ "$num" = "$n" ] && { printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; } + done < <(listar_pantallas) + fi + # 4. posicion + while IFS=$'\t' read -r num salida ali pos x y an al; do + [ "$pos" = "$q" ] && { printf '%s\t%s\t%s\t%s\t%s\n' "$salida" "$x" "$y" "$an" "$al"; return 0; } + done < <(listar_pantallas) + return 1 +} + +# ----------------------------------------------------------------- ventanas + +# El area util de la pantalla que contiene un punto: hay que descontar barras +# y paneles, o las ventanas acaban debajo de la barra de GNOME. +area_util() { + local v; v=$(xprop -root _NET_WORKAREA 2>/dev/null | sed 's/.*= //' | tr -d ' ') + if [ -n "$v" ]; then + echo "$(echo "$v" | cut -d, -f1) $(echo "$v" | cut -d, -f2) $(echo "$v" | cut -d, -f3) $(echo "$v" | cut -d, -f4)" + else + set -- $(xdotool getdisplaygeometry); echo "0 0 $1 $2" + fi +} + +# Se descartan las que no son ventanas de verdad: el guardian de mutter, el +# propio shell y las de 1x1 que usan algunos programas como buzon. +listar_ventanas() { + local w n cls x y an al + for w in $(xdotool search --onlyvisible --name "." 2>/dev/null); do + n=$(xdotool getwindowname "$w" 2>/dev/null) || continue + [ -z "$n" ] && continue + case "$n" in "mutter guard window"|"gnome-shell") continue;; esac + eval "$(xdotool getwindowgeometry --shell "$w" 2>/dev/null)" || continue + [ "${WIDTH:-0}" -lt 50 ] && continue + cls=$(xprop -id "$w" WM_CLASS 2>/dev/null | sed 's/.*, *//' | tr -d '"') + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$w" "$n" "${cls:--}" "$X" "$Y" "$WIDTH" "$HEIGHT" + done +} + +# Devuelve el id, o varios si hay empate: quien llama decide si preguntar. +resuelve_ventana() { + local q; q=$(echo "$1" | tr 'A-ZÁÉÍÓÚ' 'a-záéíóú') + case "$q" in + esta|activa|"esta ventana"|"la activa"|"ventana activa") + xdotool getactivewindow 2>/dev/null; return;; + esac + listar_ventanas | awk -F'\t' -v q="$q" ' + BEGIN{IGNORECASE=1} + { t=tolower($2); c=tolower($3); + if (index(t,q) || index(c,q)) print $1"\t"$2 }' +} + +geo_de() { eval "$(xdotool getwindowgeometry --shell "$1" 2>/dev/null)"; echo "$X $Y $WIDTH $HEIGHT"; } + +# Maximizar NO se hace con `xdotool windowstate --add MAXIMIZED`: comprobado, +# en mutter no hace nada. Lo que funciona es activar la ventana y mandar el +# atajo del escritorio. Y una ventana maximizada IGNORA windowmove, asi que +# hay que desmaximizar antes de mover. +desmaximiza() { xdotool windowactivate "$1" 2>/dev/null; sleep 0.2; xdotool key super+Down 2>/dev/null; sleep 0.4; } +maximiza() { xdotool windowactivate "$1" 2>/dev/null; sleep 0.2; xdotool key super+Up 2>/dev/null; sleep 0.4; } + +coloca() { # coloca + desmaximiza "$1" + xdotool windowsize "$1" "$4" "$5" 2>/dev/null + xdotool windowmove "$1" "$2" "$3" 2>/dev/null + sleep 0.3 +} + +# --------------------------------------------------------------------- api + +# Imprime el id, o una frase que se pueda decir en voz alta. Nada de +# marcadores tipo NINGUNA/VARIAS: lo que salga de aqui puede acabar en el +# altavoz tal cual, asi que tiene que ser castellano. +una_ventana() { + local r; r=$(resuelve_ventana "$1") + local n; n=$(echo "$r" | grep -c . ) + if [ "$n" -eq 0 ]; then + echo "No encuentro ninguna ventana que sea $1, señor."; return 1 + fi + if [ "$n" -gt 1 ]; then + # Ambiguo: se enumeran y decide la persona. Elegir una al azar es lo + # peor que puede hacer algo que mueve ventanas ajenas. + printf 'Hay %s que encajan, señor: %s. Digame cual.\n' "$n" \ + "$(echo "$r" | cut -f2 | head -4 | paste -sd'|' - | sed 's/|/; /g')" + return 2 + fi + echo "$r" | cut -f1 +} + +case "${1:-}" in + pantallas) + listar_pantallas | while IFS=$'\t' read -r num salida ali pos x y an al; do + printf 'pantalla %s: %s%s, %s, %sx%s\n' "$num" "$salida" \ + "$([ "$ali" != "-" ] && echo " ($ali)")" "$pos" "$an" "$al" + done + ;; + + listar) + listar_ventanas | while IFS=$'\t' read -r w n cls x y an al; do + printf '%s (%s) %sx%s en %s,%s\n' "$n" "$cls" "$an" "$al" "$x" "$y" + done + ;; + + alias) + salida=$(resuelve_pantalla "${2:-}" | cut -f1) + [ -n "$salida" ] || { echo "No se cual es esa pantalla, señor"; exit 1; } + python3 - "$ALIAS_FICHERO" "$salida" "${3:-}" <<'FIN' +import json, os, sys +fichero, salida, nombre = sys.argv[1], sys.argv[2], sys.argv[3] +os.makedirs(os.path.dirname(fichero), exist_ok=True) +try: + d = json.load(open(fichero)) +except Exception: + d = {} +d[salida] = nombre +json.dump(d, open(fichero, "w"), ensure_ascii=False, indent=2) +print(f"{salida} se llama ahora {nombre}") +FIN + ;; + + mover) + w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; } + destino=$(resuelve_pantalla "${3:-}") || { echo "No se cual es esa pantalla, señor"; exit 1; } + IFS=$'\t' read -r salida px py pan pal <<< "$destino" + read -r ax ay aan aal <<< "$(area_util)" + # se centra en la pantalla destino, a tres cuartos de su tamaño + an=$(( pan * 3 / 4 )); al=$(( pal * 3 / 4 )) + x=$(( px + (pan - an) / 2 )); y=$(( py + (pal - al) / 2 )) + [ "$y" -lt "$ay" ] && y=$ay + coloca "$w" "$x" "$y" "$an" "$al" + # "eDP-1" en voz alta se lee letra por letra: se dice el mote si lo tiene, + # y si no el numero, que es lo que entiende cualquiera. + comodo=$(listar_pantallas | awk -F'\t' -v s="$salida" '$2==s {print ($3!="-") ? $3 : "numero " $1}') + echo "Movida a la pantalla ${comodo:-$salida}" + ;; + + zona) + w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; } + read -r ax ay aan aal <<< "$(area_util)" + m2=$(( aan / 2 )); h2=$(( aal / 2 )) + case "$(echo "${3:-}" | tr 'A-ZÁÉÍÓÚ' 'a-záéíóú')" in + izquierda|"mitad izquierda") coloca "$w" "$ax" "$ay" "$m2" "$aal"; echo "Ya la tiene a la izquierda";; + derecha|"mitad derecha") coloca "$w" $((ax+m2)) "$ay" "$m2" "$aal"; echo "Ya la tiene a la derecha";; + arriba) coloca "$w" "$ax" "$ay" "$aan" "$h2"; echo "Ya la tiene arriba";; + abajo) coloca "$w" "$ax" $((ay+h2)) "$aan" "$h2"; echo "Ya la tiene abajo";; + "arriba izquierda") coloca "$w" "$ax" "$ay" "$m2" "$h2"; echo "Colocada arriba a la izquierda";; + "arriba derecha") coloca "$w" $((ax+m2)) "$ay" "$m2" "$h2"; echo "Colocada arriba a la derecha";; + "abajo izquierda") coloca "$w" "$ax" $((ay+h2)) "$m2" "$h2"; echo "Colocada abajo a la izquierda";; + "abajo derecha") coloca "$w" $((ax+m2)) $((ay+h2)) "$m2" "$h2"; echo "Colocada abajo a la derecha";; + centro|centrada) coloca "$w" $((ax+aan/4)) $((ay+aal/4)) $((aan/2)) $((aal/2)); echo "Ya la tiene centrada";; + *) echo "No conozco esa zona, señor"; exit 1;; + esac + ;; + + maximiza) w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }; maximiza "$w"; echo "Maximizada";; + restaura) w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; }; desmaximiza "$w"; echo "Restaurada a su tamaño";; + minimiza) w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; } + xdotool windowminimize "$w" 2>/dev/null; echo "Minimizada";; + + escritorio) + w=$(una_ventana "${2:-}") || { echo "$w"; exit 1; } + n=${3:-1} + case "$n" in una|uno|primero) n=1;; dos|segundo) n=2;; tres|tercero) n=3;; cuatro) n=4;; esac + total=$(xdotool get_num_desktops 2>/dev/null || echo 1) + [ "$n" -ge 1 ] 2>/dev/null && [ "$n" -le "$total" ] || { echo "no hay escritorio $n, solo $total"; exit 1; } + xdotool set_desktop_for_window "$w" $(( n - 1 )) 2>/dev/null + echo "Movida al escritorio $n" + ;; + + *) sed -n '2,26p' "$0"; exit 1;; +esac diff --git a/config/whisper-server-cuda.sh b/config/whisper-server-cuda.sh new file mode 100755 index 0000000..8e5c006 --- /dev/null +++ b/config/whisper-server-cuda.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Envoltorio: el whisper-server de Newelle, pero el nuestro compilado con CUDA. +# Newelle lo invoca por flatpak-spawn --host esperando 'whisper-server' en el +# PATH; aqui se fija el entorno de CUDA (que el binario necesita para ver las +# libs) y se pasa a nuestra version con GPU. Los argumentos (-m, --port, -l...) +# los pone Newelle y se reenvian tal cual. +export LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH:-} +exec "$HOME/COFRE/CODERS/JARVIS/voz/whisper-cuda/whisper.cpp/build/bin/whisper-server" "$@" diff --git a/config/whisper-server-envoltorio.sh b/config/whisper-server-envoltorio.sh new file mode 100755 index 0000000..be74513 --- /dev/null +++ b/config/whisper-server-envoltorio.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Envoltorio: el whisper-server de Newelle, pero el nuestro compilado con CUDA. +# Newelle lo invoca por flatpak-spawn --host esperando 'whisper-server' en el +# PATH; aqui se fija el entorno de CUDA (que el binario necesita para ver las +# libs) y se pasa a nuestra version con GPU. Los argumentos (-m, --port, -l...) +# los pone Newelle y se reenvian tal cual. +# +# Copia versionada: ~/COFRE/CODERS/JARVIS/config/whisper-server-envoltorio.sh +export LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH:-} + +BINARIO="$HOME/COFRE/CODERS/JARVIS/voz/whisper-cuda/whisper.cpp/build/bin/whisper-server" +REGISTRO="$HOME/COFRE/CODERS/JARVIS/voz/whisper-arranques.log" + +# Cuanta VRAM libre exigimos para arrancar en GPU. El modelo small ocupa 757 MiB +# en reposo pero llega a 839 en el pico de una transcripcion: pedir solo lo que +# ocupa al cargar es justo el error que hacia que cargase bien y muriese al +# hablarle, con un "Error recognizing file with server" que no explicaba nada. +# 950 deja holgura para el pico y para el contexto de CUDA. +MINIMO_MIB=${JARVIS_WHISPER_MIN_VRAM:-950} + +# Barrer el servidor anterior antes de levantar el nuestro. +# +# Newelle lo lanza por flatpak-spawn --host, asi que el proceso vive FUERA del +# sandbox y no se entera de que la app se cerro: cada vez que se reinicia +# JARVIS queda un whisper-server huerfano agarrado a 757 MiB de la tarjeta. +# Dos o tres reinicios y ya no cabe el modelo del asistente, con el sintoma +# repartido —a veces va, a veces no— segun cuantos hubiera quedado sueltos. +# +# Se comparan PIDs y no patrones: un `pkill -f whisper-server` desde aqui se +# mataria a si mismo, que este script tiene ese nombre. +for otro in $(pgrep -x whisper-server 2>/dev/null); do + [ "$otro" = "$$" ] && continue + kill "$otro" 2>/dev/null +done + +libre=$(nvidia-smi --query-gpu=memory.free --format=csv,noheader,nounits 2>/dev/null | head -1) + +if [ -n "$libre" ] && [ "$libre" -lt "$MINIMO_MIB" ] 2>/dev/null; then + # Sin sitio: mejor transcribir en procesador que abortar. Es mas lento, pero + # responde; abortar deja la app muda y sin decir por que. + echo "$(date '+%F %T') GPU con solo ${libre} MiB libres (<${MINIMO_MIB}): al procesador" >> "$REGISTRO" + exec "$BINARIO" --no-gpu "$@" +fi + +echo "$(date '+%F %T') GPU con ${libre:-?} MiB libres: en tarjeta" >> "$REGISTRO" +exec "$BINARIO" "$@" diff --git a/config/wiki_ingesta.py b/config/wiki_ingesta.py new file mode 100644 index 0000000..8a56c99 --- /dev/null +++ b/config/wiki_ingesta.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Descarga articulos de Wikipedia en espanol sobre los temas de JARVIS y los +deja como .txt en la carpeta de documentos del RAG. Luego, construir_indice.py +los vectoriza. Corre en el host (solo stdlib + red), sin sonido ni GPU. + + python3 config/wiki_ingesta.py # descarga + # despues, dentro del flatpak: + flatpak run --command=python3 io.github.qwersyk.Newelle//master \ + ~/COFRE/CODERS/JARVIS/config/construir_indice.py + +Es aditivo: agrega ficheros a la carpeta, no borra lo que ya haya. +""" +import os, json, time, urllib.parse, urllib.request, urllib.error + +API = "https://es.wikipedia.org/w/api.php" +UA = "JARVIS-local-RAG/1.0 (asistente de voz local; contacto: local)" +DOCS = os.path.expanduser( + "~/.var/app/io.github.qwersyk.Newelle/config/models/documents") + +# Por tema: semillas seguras + una busqueda que amplia con articulos afines. +TEMAS = { + "tecnologia": { + "buscar": ["tecnología", "informática", "hardware", "red informática", + "inteligencia artificial", "sistema operativo"], + "semillas": ["Tecnología", "Informática", "Internet", "Protocolo de red", + "Microprocesador", "Computación en la nube", "Base de datos"], + }, + "criptografia": { + "buscar": ["criptografía", "cifrado", "criptografía asimétrica"], + "semillas": ["Criptografía", "Cifrado", "RSA", "Advanced Encryption Standard", + "Función hash criptográfica", "Firma digital", + "Diffie-Hellman", "Pretty Good Privacy", "Transport Layer Security", + "Criptografía de curva elíptica", "Cadena de bloques", + "Bitcoin", "Criptomoneda"], + }, + "software_libre": { + "buscar": ["software libre", "código abierto", "historia de Linux"], + "semillas": ["Software libre", "GNU", "Richard Stallman", "GNU/Linux", + "Linus Torvalds", "Free Software Foundation", "Licencia pública general de GNU", + "Código abierto", "Debian", "Unix", "Kernel Linux", + "Movimiento del software libre"], + }, + "hacking": { + "buscar": ["seguridad informática", "hacker", "ciberseguridad"], + "semillas": ["Hacker", "Seguridad informática", "Hacker (seguridad informática)", + "Malware", "Exploit", "Ingeniería social (seguridad informática)", + "Phreaking", "Kevin Mitnick", "Anonymous (colectivo)", + "Prueba de penetración", "Vulnerabilidad (informática)", + "Cortafuegos (informática)", "Ataque de denegación de servicio"], + }, + "clima": { + "buscar": ["cambio climático", "calentamiento global", "clima"], + "semillas": ["Cambio climático", "Calentamiento global", "Efecto invernadero", + "Gas de efecto invernadero", "Protocolo de Kioto", + "Acuerdo de París", "IPCC", "Clima", "Dióxido de carbono", + "El Niño", "Nivel del mar", "Energía renovable"], + }, + "conflictos_espana": { + "buscar": ["guerra en España", "conflicto España historia"], + "semillas": ["Guerra civil española", "Transición española", + "Guerra de la Independencia Española", "Guerras carlistas", + "Reconquista", "Dictadura de Francisco Franco", + "Segunda República Española", "Movimiento 15-M", + "Conflicto vasco", "Guerra de Sucesión Española"], + }, +} + +BUSCAR_POR_CONSULTA = 15 # articulos extra por cada busqueda +PAUSA = 0.8 # cortesia con la API (subida tras el 429) + + +def _get(params, reintentos=5): + params = {**params, "format": "json"} + url = API + "?" + urllib.parse.urlencode(params) + req = urllib.request.Request(url, headers={"User-Agent": UA}) + espera = 2.0 + for intento in range(reintentos): + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.load(r) + except urllib.error.HTTPError as e: + if e.code == 429 and intento < reintentos - 1: + # Wikipedia nos frena: esperar cada vez mas (backoff) + time.sleep(espera) + espera *= 2 + continue + raise + raise RuntimeError("agotados los reintentos") + + +def titulos_de_busqueda(consulta, limite): + try: + d = _get({"action": "query", "list": "search", + "srsearch": consulta, "srlimit": limite}) + return [h["title"] for h in d.get("query", {}).get("search", [])] + except Exception as e: + print(f" busqueda '{consulta}' fallo: {e}") + return [] + + +def extracto(titulo): + try: + d = _get({"action": "query", "prop": "extracts", "explaintext": 1, + "redirects": 1, "titles": titulo}) + paginas = d.get("query", {}).get("pages", {}) + for _, pg in paginas.items(): + txt = pg.get("extract", "") + if txt and len(txt) > 300: + return pg.get("title", titulo), txt + except Exception as e: + print(f" extracto '{titulo}' fallo: {e}") + return None, None + + +def slug(s): + s = "".join(c if c.isalnum() else "_" for c in s.lower()) + return s.strip("_")[:80] + + +def main(): + os.makedirs(DOCS, exist_ok=True) + total = 0 + for tema, cfg in TEMAS.items(): + print(f"\n=== {tema} ===") + titulos = list(cfg["semillas"]) + for consulta in cfg["buscar"]: + titulos += titulos_de_busqueda(consulta, BUSCAR_POR_CONSULTA) + time.sleep(PAUSA) + # dedup conservando orden + vistos, unicos = set(), [] + for t in titulos: + if t.lower() not in vistos: + vistos.add(t.lower()); unicos.append(t) + for t in unicos: + dst = os.path.join(DOCS, f"wiki_{tema}_{slug(t)}.txt") + if os.path.exists(dst): + continue + titulo_real, txt = extracto(t) + time.sleep(PAUSA) + if not txt: + continue + with open(dst, "w", encoding="utf-8") as f: + f.write(f"# {titulo_real}\n(Wikipedia, tema: {tema})\n\n{txt}\n") + total += 1 + print(f" [{total}] {titulo_real} ({len(txt)//1024} KB)") + print(f"\nlisto: {total} articulos nuevos en {DOCS}") + + +if __name__ == "__main__": + main() diff --git a/entrena/README.md b/entrena/README.md new file mode 100644 index 0000000..30063e2 --- /dev/null +++ b/entrena/README.md @@ -0,0 +1,61 @@ +# Entrenamiento de JARVIS + +El fine-tuning NO sustituye al RAG (nucleo/saber/), lo complementa. El RAG ya +funciona y da el conocimiento fresco; esto hornea el estilo y los flujos en los +pesos. Por eso fue: primero RAG, esto despues. + +## Los tres pasos + + 1. dataset.py convierte los apuntes de COFRE en pares pregunta-respuesta + (usa el indice de nucleo/saber/, ~10 h con el 4B local) + 2. afina.py QLoRA sobre el dataset. --revisa dice si la maquina puede + 3. exportar el adaptador a GGUF + Modelfile con ADAPTER, para ollama + +## Por que falla hoy: qwen3.5 es MULTIMODAL (16 ago) + +Las pruebas de `afina.py` cargan el modelo bien y se caen justo despues, al +enganchar los adaptadores, con un mensaje que no viene a cuento: + + Incorrect image source. Must be a valid URL starting with `http://` + +No es un fallo de red ni de rutas. **`Qwen/Qwen3.5-2B` es `image-text-to-text`** +—comprobado en la API de HuggingFace, y el propio GGUF lo delata: el +`model_info` de ollama trae `qwen35.vision.block_count = 24`—. O sea que el +modelo lleva torre de vision, y `FastLanguageModel` lo mete por el camino de +solo texto mientras transformers intenta procesar una imagen que no existe. + +La pista estaba en el log y se leyo como ruido: + + Unsloth: Explicit target_modules are constrained by the + finetune_(vision|language|attention|mlp) filters + +Ese aviso solo lo emite el camino multimodal. + +Por donde va el arreglo: `FastVisionModel` con `finetune_vision_layers=False`, +que es la via de unsloth para afinar solo la parte de lenguaje de un modelo con +vision. **Sin probar todavia.** + +De paso, los nombres que no existen en HuggingFace y que las pruebas fueron +descartando: `Qwen3.5-3B`, `Qwen3.5-3B-Instruct`, `Qwen3.5-1.7B`, +`Qwen3.5-1.7B-Instruct`, `Qwen3.5-1.5B`. El unico que carga es `Qwen3.5-2B` +(1,95 GB). + +## Donde entrenar + + T1200 (4 GB) al limite. Turing, sin bf16. Posible pero fragil. + 3060 (12 GB) holgado. El sitio natural. El dataset viaja como fichero. + +## Para entrenar + + pip install unsloth # trae peft, trl, bitsandbytes + ./afina.py --revisa # comprueba + ./afina.py # entrena + +## Exportar a ollama (tras entrenar) + + # con llama.cpp: + python convert_lora_to_gguf.py jarvis-lora --outfile jarvis-lora.gguf + # Modelfile: + # FROM qwen3.5:4b + # ADAPTER ./jarvis-lora.gguf + ollama create qwen3.5:4b-jarvis-cofre -f Modelfile diff --git a/entrena/afina.py b/entrena/afina.py new file mode 100755 index 0000000..2253753 --- /dev/null +++ b/entrena/afina.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +"""Fine-tuning de JARVIS con QLoRA sobre el dataset de COFRE. + + ./afina.py --revisa comprueba que se puede entrenar aqui, sin entrenar + ./afina.py entrena (largo; mejor en el servidor con la 3060) + +## Que hace y que NO hace el fine-tuning + +Le enseña al modelo el ESTILO y las respuestas de tus apuntes con ejemplos. NO +sustituye al RAG: el RAG le da los datos frescos en el momento —y se actualiza +soltando un fichero—, mientras que esto los hornea en los pesos. Se complementan: +el fine-tuning hace que "suene" a JARVIS y conozca tus flujos sin buscar; el RAG +cubre lo que cambia y lo que es muy especifico. + +Por eso el orden fue: primero RAG (hecho, funciona hoy), y esto despues. + +## QLoRA en cristiano + +Entrenar un 4B entero pide mas memoria de la que hay. QLoRA hace dos trucos: + - carga el modelo base en 4 bits (cabe en poca VRAM, y se queda CONGELADO) + - entrena solo unas matrices pequeñas nuevas (los "adaptadores LoRA"), que son + una fraccion del tamaño + +Asi se entrena un 4B en pocos GB. El resultado es un adaptador de ~50 MB que se +le pone encima al modelo base. + +## Donde entrenar + + T1200 (4 GB, esta maquina) al limite. Turing (capacidad 7.5): sin bf16 ni + flash-attention. Posible con lote 1 y secuencia + corta, lento y fragil. --revisa lo dice. + 3060 (12 GB, el servidor) holgado. Ampere: bf16 y flash-attention. Es el + sitio natural, por eso el dataset se genera aparte + y viaja como fichero. + +## Dependencias + + pip install unsloth (trae peft, trl, bitsandbytes, accelerate) + +unsloth porque es lo que mejor exprime una GPU pequeña: mismo resultado con la +mitad de VRAM y el doble de velocidad que peft+trl a pelo. +""" +import argparse +import json +import os +import sys + +AQUI = os.path.dirname(os.path.abspath(__file__)) +DATASET = os.path.join(AQUI, "dataset-cofre.jsonl") +SALIDA = os.path.join(AQUI, "jarvis-lora") + +# El modelo base, en formato HuggingFace. ollama corre GGUF; para entrenar hace +# falta el modelo de verdad. Se prueban candidatos en orden hasta que uno +# cargue: la version pre-cuantizada de unsloth va primero porque baja menos y +# ocupa menos VRAM, que en 4 GB es lo que decide si entra. +# +# El de ollama es qwen3.5:4b (arquitectura qwen35, 4,7B, embedding 2560). Si +# ninguno carga, la fase 0 lo dice y no se pierden horas. +CANDIDATOS = [ + os.environ.get("JARVIS_BASE_HF", ""), # lo que ponga el usuario, si pone + "unsloth/Qwen3.5-4B-Instruct-bnb-4bit", + "Qwen/Qwen3.5-4B-Instruct", + "unsloth/Qwen3.5-4B-bnb-4bit", + "Qwen/Qwen3.5-4B", +] + +# Apretado para 4 GB (ver el plan). Con el 4,7B en 4 bits ocupando ~2,8 GB, +# cada megabyte de mas en secuencia o rango es el que provoca el OOM. +EPOCAS = 2 +LOTE = 1 +ACUMULA = 16 # lote efectivo 16 sin gastar VRAM +LR = 2e-4 +MAX_SEQ = 512 # los pares Q&A son cortos; 512 no pierde casi nada +RANGO = 8 # adaptadores pequeños + + +def _base_que_carga(): + """El primer candidato que exista y cargue. None si ninguno.""" + from unsloth import FastLanguageModel + for repo in CANDIDATOS: + if not repo: + continue + try: + print(f" probando {repo} ...", flush=True) + modelo, tok = FastLanguageModel.from_pretrained( + model_name=repo, max_seq_length=MAX_SEQ, dtype=None, + load_in_4bit=True) + print(f" ✓ carga: {repo}") + return repo, modelo, tok + except Exception as e: + print(f" no: {str(e).splitlines()[0][:80]}") + return None, None, None + + +def revisa() -> int: + """El ensayo de la fase 0: carga el 4,7B y hace UN paso de entrenamiento. + + Es el momento de la verdad. Si ese paso pasa sin quedarse sin memoria, el + entrenamiento entero es cuestion de tiempo. Si peta, se sabe en diez minutos + y no en la sexta hora. + """ + print("\n ── se puede entrenar aqui? ──\n") + + n = 0 + if os.path.exists(DATASET): + with open(DATASET) as f: + n = sum(1 for _ in f) + print(f" dataset: {n} ejemplos" + + (" (suficiente para el test; poco para entrenar)" if n < 500 else "")) + + try: + import torch + except ImportError: + print(" torch: NO — el venv de entrenamiento no esta montado") + return 1 + if not torch.cuda.is_available(): + print(" cuda: NO") + return 1 + p = torch.cuda.get_device_properties(0) + libre = torch.cuda.mem_get_info()[0] / 1e9 + print(f" gpu: {p.name} {p.total_memory/1e9:.1f} GB, {libre:.1f} libres, " + f"capacidad {p.major}.{p.minor}" + + (" (Turing: fp16, sin flash-attn)" if p.major < 8 else "")) + if libre < 3.3: + print(" AVISO: hay menos de 3,3 GB libres. Cierra JARVIS y ollama antes.") + + for m in ("unsloth", "peft", "trl", "bitsandbytes"): + try: + __import__(m) + except ImportError: + print(f" {m}: NO -> el venv de entrenamiento no acabo de instalar") + return 1 + + # El momento de la verdad: cargar + un paso real. + print("\n ── cargando el modelo y haciendo un paso de prueba ──") + from unsloth import FastLanguageModel + repo, modelo, tok = _base_que_carga() + if repo is None: + print("\n NINGUN candidato cargo. O no existe el repo HF del qwen3.5:4b,") + print(" o esta arquitectura (qwen35) no la soporta este transformers.") + print(" Pon el repo correcto en JARVIS_BASE_HF y reintenta.") + return 1 + + tras_modelo = torch.cuda.memory_allocated() / 1e9 + print(f" VRAM tras cargar el modelo: {tras_modelo:.2f} GB") + + modelo = FastLanguageModel.get_peft_model( + modelo, r=RANGO, lora_alpha=RANGO, lora_dropout=0, + target_modules=["q_proj", "k_proj", "v_proj", "o_proj", + "gate_proj", "up_proj", "down_proj"], + use_gradient_checkpointing="unsloth", random_state=42) + + # un lote de una frase, forward + backward, que es donde revienta si revienta + try: + import torch as T + texto = tok.apply_chat_template( + [{"role": "user", "content": "que es secretsdump"}, + {"role": "assistant", "content": "Saca los hashes NTLM, señor."}], + tokenize=False) + ids = tok(texto, return_tensors="pt", max_length=MAX_SEQ, + truncation=True).input_ids.to("cuda") + salida = modelo(ids, labels=ids) + salida.loss.backward() + pico = T.cuda.max_memory_allocated() / 1e9 + print(f" ✓ un paso de entrenamiento paso. Pico de VRAM: {pico:.2f} GB") + print(f"\n CABE. base a usar: {repo}") + print(" Pon ese repo en JARVIS_BASE_HF si no es el primero, y ./afina.py\n") + return 0 + except T.cuda.OutOfMemoryError: + print("\n OOM: el 4,7B no entra en esta grafica ni apretando.") + print(" Plan B: bajar MAX_SEQ a 256, o el 3B, o el servidor (3060).") + return 2 + + +def entrena() -> int: + if not os.path.exists(DATASET): + print(" no hay dataset: ./dataset.py primero") + return 1 + + import torch + from unsloth import FastLanguageModel + from datasets import load_dataset + from trl import SFTTrainer, SFTConfig + + ampere = torch.cuda.get_device_properties(0).major >= 8 + + repo, modelo, tokenizer = _base_que_carga() + if repo is None: + print(" ningun base cargo; corre --revisa para el diagnostico") + return 1 + + modelo = FastLanguageModel.get_peft_model( + modelo, r=RANGO, lora_alpha=RANGO, lora_dropout=0, + target_modules=["q_proj", "k_proj", "v_proj", "o_proj", + "gate_proj", "up_proj", "down_proj"], + use_gradient_checkpointing="unsloth", random_state=42) + + datos = load_dataset("json", data_files=DATASET, split="train") + + def formatea(ej): + return {"text": tokenizer.apply_chat_template( + ej["messages"], tokenize=False, add_generation_prompt=False)} + datos = datos.map(formatea) + + entrenador = SFTTrainer( + model=modelo, tokenizer=tokenizer, train_dataset=datos, + dataset_text_field="text", max_seq_length=MAX_SEQ, + args=SFTConfig( + per_device_train_batch_size=LOTE, + gradient_accumulation_steps=ACUMULA, + num_train_epochs=EPOCAS, learning_rate=LR, + fp16=not ampere, bf16=ampere, + logging_steps=10, optim="adamw_8bit", + warmup_ratio=0.05, lr_scheduler_type="cosine", + output_dir=os.path.join(AQUI, "checkpoints"), seed=42)) + + print(" entrenando...") + entrenador.train() + + modelo.save_pretrained(SALIDA) + tokenizer.save_pretrained(SALIDA) + print(f"\n adaptador guardado: {SALIDA}") + print(" para usarlo con ollama hay que exportarlo a GGUF y crear un") + print(" Modelfile con ADAPTER. Ver el README de entrena/.") + return 0 + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--revisa", action="store_true") + a = p.parse_args() + return revisa() if a.revisa else entrena() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/entrena/dataset.py b/entrena/dataset.py new file mode 100755 index 0000000..f55f891 --- /dev/null +++ b/entrena/dataset.py @@ -0,0 +1,171 @@ +#!/usr/bin/env python3 +"""Genera el dataset de fine-tuning a partir de los apuntes de COFRE. + + ./dataset.py --muestra 5 enseña 5 ejemplos y para (para mirar la calidad) + ./dataset.py genera el dataset entero (horas, en segundo plano) + +## Por que esto es el paso que faltaba + +Fine-tuning no es magia: es enseñarle al modelo con EJEMPLOS de como quieres que +conteste. Para eso hacen falta pares pregunta-respuesta, y no los teniamos. Los +apuntes de COFRE son conocimiento, pero en prosa; hay que convertirlos en la +forma "alguien pregunta X, JARVIS responde Y". + +Se generan con el modelo local a partir de cada fragmento de los apuntes: se le +da el trozo y se le pide que invente las preguntas que ese trozo contesta, y la +respuesta al estilo de JARVIS. El fragmento es la VERDAD —lo escribio el +usuario— asi que las respuestas salen de ahi y no de lo que el modelo recuerde. + +## Por que el dataset vale aunque no se entrene aqui + +La T1200 de 4 GB va justa para entrenar un 4B. El sitio natural es el servidor +con la 3060 de 12 GB. Pero el DATASET es lo caro de conseguir y no depende de la +maquina: se genera una vez, es un fichero, y se entrena donde haga falta —aqui +si cabe, o en el servidor cuando haya acceso—. + +## El formato + +JSONL en el formato de mensajes, que es lo que comen trl/axolotl/unsloth: + + {"messages": [{"role":"user","content":"..."}, + {"role":"assistant","content":"..."}]} +""" +import argparse +import json +import os +import re +import sys +import urllib.request + +AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +INDICE = os.path.join(AQUI, "nucleo", "datos", "saber.jsonl") +SALIDA = os.path.join(AQUI, "entrena", "dataset-cofre.jsonl") +ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434") +MODELO = os.environ.get("JARVIS_MODELO", "qwen3.5:4b-jarvis") + +# Al generador se le da MAS libertad que a JARVIS en produccion: aqui quereis +# variedad de preguntas, no una respuesta conservadora. +GENERADOR = """Eres un generador de datos de entrenamiento para un asistente de +pentesting llamado JARVIS. Te doy un fragmento de los apuntes tecnicos del +usuario. Devuelve de UNA a TRES preguntas que una persona haria y que ese +fragmento contesta, cada una con su respuesta. + +Reglas: +- Las preguntas, naturales y variadas: como las diria alguien por voz. +- Las respuestas SALEN DEL FRAGMENTO. Si el fragmento tiene un comando exacto, + la respuesta lo lleva tal cual. No te inventes flags que no aparezcan. +- Respuestas al estilo de JARVIS: directas, en castellano, tratando de "señor". +- Si el fragmento no da para una pregunta util (es un titulo, una lista de + enlaces, ruido), devuelve una lista vacia. + +Responde SOLO con JSON, un objeto con la clave "pares": +{"pares": [{"pregunta": "...", "respuesta": "..."}]}""" + + +def _pide(fragmento: str) -> list: + cuerpo = json.dumps({ + "model": MODELO, + "messages": [{"role": "system", "content": GENERADOR}, + {"role": "user", "content": fragmento[:1500]}], + "stream": False, "think": False, + "options": {"temperature": 0.7, "num_predict": 500}, + "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 [] + # el modelo a veces envuelve el array en un objeto {"pares": [...]} + try: + d = json.loads(contenido) + except json.JSONDecodeError: + m = re.search(r"\[.*\]", contenido, re.S) + if not m: + return [] + try: + d = json.loads(m.group(0)) + except json.JSONDecodeError: + return [] + if isinstance(d, dict): + d = next((v for v in d.values() if isinstance(v, list)), []) + salida = [] + for par in d if isinstance(d, list) else []: + if isinstance(par, dict) and par.get("pregunta") and par.get("respuesta"): + salida.append((par["pregunta"].strip(), par["respuesta"].strip())) + return salida + + +def apuntes(): + """Los fragmentos de apuntes del indice, los mas sustanciosos primero.""" + fuera = [] + with open(INDICE, encoding="utf-8") as f: + for l in f: + try: + d = json.loads(l) + except json.JSONDecodeError: + continue + if d.get("tipo") == "apunte" and len(d["texto"].split()) >= 20: + d.pop("v", None) + fuera.append(d) + return fuera + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--muestra", type=int, default=0, + help="genera solo N y los enseña, sin guardar") + a = p.parse_args() + + if not os.path.exists(INDICE): + print(" no hay indice: nucleo/saber/indexa.py primero") + return 1 + fragmentos = apuntes() + print(f" {len(fragmentos)} fragmentos con sustancia", flush=True) + + if a.muestra: + for frag in fragmentos[:a.muestra]: + pares = _pide(frag["texto"]) + print(f"\n ── {frag.get('fichero', frag['perfil'])} ──") + for q, r in pares: + print(f" P: {q}") + print(f" R: {r[:120]}") + return 0 + + # Reanudable: un trabajo de 10 h no puede perder todo si se corta. Se apunta + # en un .hecho por que fragmento se iba, y al arrancar se salta hasta ahi y + # se AÑADE al dataset en vez de reescribirlo. + marca = SALIDA + ".hecho" + 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" + hechos = sum(1 for _ in open(SALIDA)) if desde and os.path.exists(SALIDA) else 0 + if desde: + print(f" reanudando desde el fragmento {desde} ({hechos} ejemplos hechos)", + flush=True) + + with open(SALIDA, modo, encoding="utf-8", buffering=1) as out: + for i in range(desde, len(fragmentos)): + for q, r in _pide(fragmentos[i]["texto"]): + out.write(json.dumps({"messages": [ + {"role": "user", "content": q}, + {"role": "assistant", "content": r}, + ]}, ensure_ascii=False) + "\n") + hechos += 1 + with open(marca, "w") as m: + m.write(str(i)) + if (i + 1) % 50 == 0: + print(f" {i+1}/{len(fragmentos)} fragmentos, {hechos} ejemplos", + flush=True) + print(f"\n dataset guardado: {SALIDA} ({hechos} ejemplos)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..2ec0bf6 --- /dev/null +++ b/install.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Instalador del nucleo de JARVIS. Prepara el entorno de Python, el modelo de +# Ollama y comprueba los servicios externos. No descarga modelos pesados ni +# toca el sistema sin avisar. +set -uo pipefail + +AQUI=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd) +cd "$AQUI" + +ok() { printf ' \033[32mok\033[0m %s\n' "$*"; } +falta(){ printf ' \033[33mfalta\033[0m %s\n' "$*"; } +malo() { printf ' \033[31mERROR\033[0m %s\n' "$*"; } + +echo "JARVIS — instalacion" +echo + +# ── 1. Servicios externos (los instala el usuario; aqui solo se comprueban) ── +echo "1. Servicios externos" +command -v python3 >/dev/null && ok "python3 $(python3 -V 2>&1 | awk '{print $2}')" \ + || { malo "python3 no esta"; exit 1; } +command -v ollama >/dev/null && ok "ollama" \ + || falta "ollama — instalalo desde https://ollama.com (es el cerebro)" +command -v whisper-server >/dev/null && ok "whisper-server (STT)" \ + || falta "whisper.cpp con CUDA — compilalo; ver config/README_whisper_gpu.md" +command -v piper >/dev/null && ok "piper (TTS)" \ + || falta "piper — https://github.com/rhasspy/piper (la voz)" +echo + +# ── 2. Entorno de Python del nucleo ───────────────────────────────────────── +echo "2. Entorno de Python" +if [ ! -d nucleo/venv ]; then + python3 -m venv nucleo/venv && ok "creado nucleo/venv" +else + ok "nucleo/venv ya existe" +fi +nucleo/venv/bin/pip install -q --upgrade pip >/dev/null 2>&1 +if nucleo/venv/bin/pip install -q -r requirements.txt; then + ok "dependencias instaladas" +else + malo "fallo instalando dependencias (ver requirements.txt)"; exit 1 +fi +echo + +# ── 3. El modelo de Ollama ────────────────────────────────────────────────── +echo "3. El modelo" +if command -v ollama >/dev/null; then + if ollama list 2>/dev/null | grep -q '^qwen3.5:4b-jarvis'; then + ok "qwen3.5:4b-jarvis ya existe" + else + echo " creando qwen3.5:4b-jarvis (necesita qwen3.5:4b en Ollama)..." + if ollama create qwen3.5:4b-jarvis -f config/qwen3.5-4b-jarvis.Modelfile 2>/dev/null; then + ok "modelo creado" + else + falta "no se pudo crear; baja el base con: ollama pull qwen3.5:4b" + fi + fi +else + falta "sin ollama no hay modelo; instalalo y re-ejecuta" +fi +echo + +# ── 4. Siguiente paso ─────────────────────────────────────────────────────── +echo "Hecho. Para arrancar:" +echo " ./nucleo/saber/indexa.py # (opcional) indexa tus apuntes para el RAG" +echo " ./nucleo/arranca.sh # el asistente y el panel en 127.0.0.1" diff --git a/jarvis-icon.svg b/jarvis-icon.svg new file mode 100644 index 0000000..c14af1a --- /dev/null +++ b/jarvis-icon.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jarvis-launcher.sh b/jarvis-launcher.sh new file mode 100755 index 0000000..673015a --- /dev/null +++ b/jarvis-launcher.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# JARVIS — lanzador del asistente local (Newelle) +# +# sin argumentos abre JARVIS a pantalla completa, sin pasar por el chat +# --chat abre Newelle normal, con sus pestañas y su historial +# --stop la cierra y libera el modelo +# --status que corre, con que cerebro y cuanto ocupa +# +# El lanzador NO decide el motor: lo lee de gsettings (language-model) y se +# adapta. Asi no hay que tocarlo cada vez que se cambia de cerebro, que es +# justo lo que lo dejo desincronizado antes: precargaba Ollama mientras la +# app usaba su llamacpp interno, ocupando 2.9 GB de VRAM para nada. +# +# Y el MODELO tampoco, por lo mismo. Estaba escrito a mano aqui y volvio a +# pasar exactamente lo de antes: al cambiar la app a qwen3.5:4b-jarvis, el +# lanzador seguia precargando qwen3.5:4b. Los dos no caben en 3717 MiB, asi +# que ollama tenia que descargar uno para cargar el otro con el panel ya +# esperando: 6,55 s hasta la primera respuesta contra 3,70 con los dos de +# acuerdo. El sintoma no era "va lento", era "no se abre el panel solo". + +set -uo pipefail + +APPID=io.github.qwersyk.Newelle +OLLAMA_URL=http://localhost:11434 + +# La salida de la app iba entera a /dev/null. Cuando el panel dejo de abrirse, +# el traceback que lo explicaba —una linea— no estaba en ningun sitio, y el +# fallo se diagnostico dos veces mal antes de mirarlo. Ahora se guarda. +# misma idea que en los scripts de voz: la raiz sale de donde vive esto +RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd) +REGISTRO=$RAIZ/jarvis-app.log +: > "$REGISTRO" 2>/dev/null || REGISTRO=/dev/null +GGUF=$HOME/.var/app/$APPID/config/models/custom_models/qwen3.5-4b.gguf + +# Elige rama: //master es la version propia (jarvis-skin, con reactor arc); +# //stable es la de Flathub, la red de seguridad. +# +# El chequeo mira la typelib de Vte en lib/: si un build deja mal el libdir, +# la app arranca y muere con "Namespace Vte not available". Asi el icono +# nunca abre una version rota, y se pasa solo a la propia cuando este sana. +pick_branch() { + local base=$HOME/.local/share/flatpak/app/$APPID/x86_64 + if [ -f "$base/master/active/files/lib/girepository-1.0/Vte-3.91.typelib" ]; then + echo "master" + else + echo "stable" + fi +} +RAMA=$(pick_branch) + +motor() { + flatpak run --command=gsettings "$APPID//$RAMA" get "$APPID" language-model 2>/dev/null \ + | tr -d "'" | tr -d '\n' +} + +# El modelo que la app tiene elegido de verdad, leido de sus ajustes. Si no se +# puede leer se cae al 4b pelado, que es mejor que precargar algo que no existe. +modelo() { + flatpak run --command=gsettings "$APPID//$RAMA" get "$APPID" llm-settings 2>/dev/null \ + | python3 -c " +import json, sys +s = sys.stdin.read().strip() +try: + d = json.loads(s[1:-1].replace('\\\\\\\\', '\\\\').replace(\"\\\\'\", \"'\")) + print(d.get('ollama', {}).get('model') or 'qwen3.5:4b') +except Exception: + print('qwen3.5:4b') +" 2>/dev/null || echo qwen3.5:4b +} +MODEL=$(modelo) + +notify() { command -v notify-send >/dev/null && notify-send -i jarvis "JARVIS" "$1"; } + +ollama_up() { curl -s --max-time 2 "$OLLAMA_URL/api/version" >/dev/null 2>&1; } + +app_corriendo() { pgrep -f "bin/newelle" >/dev/null; } + +# El llama-server interno de la app, no el de Ollama: el suyo vive en /app/bin. +servidor_interno() { pgrep -f "/app/bin/llama-server" | head -1; } + +case "${1:-start}" in + --stop) + # flatpak kill y no pkill: el patron "bin/newelle" tambien casa con la + # propia linea de comandos cuando esto se invoca desde un bash -c inline, + # y el script se mataba a si mismo antes de llegar aqui. + flatpak kill "$APPID" 2>/dev/null || pkill -f "bin/newelle" 2>/dev/null + if [ "$(motor)" = "ollama" ] && ollama_up; then + curl -s "$OLLAMA_URL/api/generate" \ + -d "{\"model\":\"$MODEL\",\"keep_alive\":0}" >/dev/null 2>&1 + notify "Detenido, VRAM liberada" + else + notify "Detenido" + fi + echo "JARVIS detenido." + ;; + + --status|--status-notify) + MOTOR=$(motor) + app_corriendo && S1="app: abierta (//$RAMA)" || S1="app: cerrada" + S2="cerebro: ${MOTOR:-?}" + + if [ "$MOTOR" = "ollama" ]; then + S3=$(curl -s "$OLLAMA_URL/api/ps" 2>/dev/null \ + | python3 -c 'import json,sys +ms = json.load(sys.stdin).get("models", []) +print("\n".join("cargado: %s %.1f GB (%.1f en GPU)" + % (m["name"], m.get("size",0)/1e9, m.get("size_vram",0)/1e9) for m in ms) + or "sin modelos cargados")' 2>/dev/null) + [ -n "$S3" ] || S3="ollama: caido" + else + PID=$(servidor_interno) + if [ -n "$PID" ]; then + S3="cargado en RAM ($(awk '/VmRSS/{printf "%.1f GB", $2/1048576}' \ + "/proc/$PID/status" 2>/dev/null))" + else + S3="sin modelos cargados" + fi + fi + + S4=$(nvidia-smi --query-gpu=memory.used,memory.free --format=csv,noheader 2>/dev/null \ + | sed 's/^/VRAM usada\/libre: /') + + if [ "$1" = "--status-notify" ]; then + notify "$S1 · $S2"$'\n'"$S3"$'\n'"$S4" + else + printf '%s\n%s\n\n%s\n%s\n' "$S1" "$S2" "$S3" "$S4" + fi + ;; + + *) + MOTOR=$(motor) + + if [ "$MOTOR" = "ollama" ]; then + if ! ollama_up; then + systemctl start ollama 2>/dev/null || sudo systemctl start ollama + for _ in $(seq 20); do ollama_up && break; sleep 0.5; done + fi + ollama_up || { notify "Ollama no arranca"; echo "ERROR: ollama no responde" >&2; exit 1; } + # precargar en GPU para que la primera respuesta no tarde. + # think:false o se va a 40 s pensando en voz alta. + curl -s "$OLLAMA_URL/api/generate" \ + -d "{\"model\":\"$MODEL\",\"prompt\":\"ok\",\"stream\":false,\"think\":false,\"keep_alive\":\"30m\"}" \ + >/dev/null 2>&1 & + else + [ -f "$GGUF" ] || { notify "Falta el modelo: $(basename "$GGUF")" + echo "ERROR: no esta $GGUF" >&2; exit 1; } + fi + + # --chat abre Newelle tal cual; sin argumentos va derecho al asistente + DIRECTO=si + [ "${1:-}" = "--chat" ] && DIRECTO=no + + if app_corriendo; then + command -v xdotool >/dev/null && \ + xdotool search --name "^newelle$" windowactivate 2>/dev/null + elif [ "$DIRECTO" = "no" ]; then + exec flatpak run "$APPID//$RAMA" + else + # En segundo plano y no con exec: hay que esperar a que exista la ventana + # antes de pedir la pestaña. --run-action se procesa antes de que la + # ventana este construida, asi que lanzarlo de golpe no serviria. + # + # Y esperar al PROCESO no basta: el proceso existe en cuanto python + # arranca, varios segundos antes de que haya ventana. Con `sleep 3` a + # ojo, un arranque un poco mas lento pierde la carrera y la accion + # revienta con "'MyApp' object has no attribute 'win'" — la app abre, + # pero en el chat y sin panel. Se espera a la VENTANA, que es la + # condicion de verdad. + flatpak run "$APPID//$RAMA" >>"$REGISTRO" 2>&1 & + for _ in $(seq 60); do + if command -v xdotool >/dev/null; then + xdotool search --name "^newelle$" >/dev/null 2>&1 && break + else + pgrep -f "bin/newelle" >/dev/null && break + fi + sleep 0.5 + done + # sin xdotool no hay forma de saberlo: se vuelve al margen a ojo + command -v xdotool >/dev/null || sleep 3 + fi + + if [ "$DIRECTO" = "si" ]; then + # jarvis_full y no jarvis+hud: el segundo alterna, asi que pulsar el + # icono con la app ya abierta te sacaria de pantalla completa. + flatpak run "$APPID//$RAMA" --run-action=jarvis_full >>"$REGISTRO" 2>&1 + fi + ;; +esac diff --git a/nucleo/.gitignore b/nucleo/.gitignore new file mode 100644 index 0000000..9f3cd23 --- /dev/null +++ b/nucleo/.gitignore @@ -0,0 +1,6 @@ +datos/cache_voz/ +venv/ +__pycache__/ +*.pyc +datos/saber.jsonl +datos/diario.jsonl diff --git a/nucleo/arranca.sh b/nucleo/arranca.sh new file mode 100755 index 0000000..2a3d92b --- /dev/null +++ b/nucleo/arranca.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Lanzador del JARVIS naranja: el nucleo, fuera del flatpak. +# +# arranca.sh levanta ollama si hace falta, abre el panel y escucha +# arranca.sh --panel solo el panel, sin microfono +# arranca.sh --stop lo para y libera la grafica +# arranca.sh --estado dice si esta vivo +# +# Comparado con el lanzador del JARVIS azul, aqui sobra casi todo: no hay que +# adivinar la rama del flatpak, ni leer gsettings con escapado de GVariant, ni +# preguntarle a la aplicacion que modelo tiene puesto. El nucleo lo sabe porque +# es suyo. +# +# Lo que SI se conserva, porque en esta maquina importa: al parar se le dice a +# ollama que suelte el modelo. Son 2,1 GB de una tarjeta de 4, y sin esto whisper +# se queda sin sitio la proxima vez. +set -uo pipefail + +RAIZ=$(cd -P "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd) +PYTHON=$RAIZ/venv/bin/python +[ -x "$PYTHON" ] || PYTHON=$(command -v python3) +OLLAMA_URL=${JARVIS_OLLAMA:-http://127.0.0.1:11434} +REGISTRO=$RAIZ/jarvis.log +PUERTO=${JARVIS_PUERTO:-8790} + +# Fichero de PID, no pgrep. +# +# El primer intento fue marcar el proceso con una variable de entorno y buscarla +# con `pgrep -f`. No funciona: pgrep mira la LINEA DE ORDENES, y `env VAR=1 cmd` +# no deja la variable ahi. El sintoma fue peor que un simple "no lo encuentra": +# el lanzador creia que no habia nada corriendo, arrancaba un segundo, y ese +# moria con "address already in use" mientras el primero seguia vivo. +# +# Y buscar por el nombre del script tiene el problema de siempre: el patron +# aparece en la linea de ordenes del propio pgrep. Un fichero de PID no se +# equivoca. +PID=${XDG_RUNTIME_DIR:-/tmp}/jarvis-nucleo.pid + +aviso() { command -v notify-send >/dev/null && \ + notify-send -i "$RAIZ/jarvis-naranja.svg" "JARVIS" "$1"; } + +modelo() { + grep -oP 'MODELO = os\.environ\.get\("JARVIS_MODELO", "\K[^"]+' \ + "$RAIZ/cerebro/ollama.py" 2>/dev/null || echo "qwen3.5:4b-jarvis" +} + +# Vivo = hay un pid apuntado Y ese proceso existe. Un pid huerfano de un +# reinicio no puede hacernos creer que sigue en marcha. +vivo() { + [ -f "$PID" ] || return 1 + local p; p=$(cat "$PID" 2>/dev/null) + [ -n "$p" ] && kill -0 "$p" 2>/dev/null +} +ollama_up() { curl -s --max-time 2 "$OLLAMA_URL/api/version" >/dev/null 2>&1; } +puerto_ocupado() { curl -s --max-time 2 "http://127.0.0.1:$PUERTO/" >/dev/null 2>&1; } + +case "${1:-arranca}" in + --stop) + # Tambien lo que este ocupando el puerto sin fichero de pid: si no, un + # huerfano impide arrancar de nuevo y --stop parece no hacer nada. + if ! vivo && puerto_ocupado && command -v fuser >/dev/null; then + fuser -k "$PUERTO/tcp" >/dev/null 2>&1 + fi + if vivo; then + kill "$(cat "$PID")" 2>/dev/null + # margen para que suelte el puerto antes de que nadie lo reclame + for _ in 1 2 3 4 5 6 7 8 9 10; do vivo || break; sleep 0.3; done + vivo && kill -9 "$(cat "$PID")" 2>/dev/null + fi + rm -f "$PID" + if ollama_up; then + # keep_alive 0: suelta el modelo ya, no dentro de cinco minutos + curl -s "$OLLAMA_URL/api/generate" \ + -d "{\"model\":\"$(modelo)\",\"keep_alive\":0}" >/dev/null 2>&1 + aviso "Detenido, grafica liberada" + else + aviso "Detenido" + fi + exit 0 + ;; + + --estado) + # Se mira el pid Y el puerto. Con solo el pid, una instancia arrancada a + # mano —o superviviente de un lanzador que murio— daba "parado" mientras + # el panel contestaba tan tranquilo, que es la peor respuesta posible: + # la que hace dudar de si el problema es el programa o quien pregunta. + if vivo; then + echo "JARVIS naranja: vivo" + exit 0 + fi + if puerto_ocupado; then + echo "JARVIS naranja: vivo, pero arrancado por fuera de este lanzador" + echo " (no hay $PID; para pararlo: fuser -k $PUERTO/tcp)" + exit 0 + fi + echo "JARVIS naranja: parado" + exit 1 + ;; +esac + +if vivo || puerto_ocupado; then + # Ya esta abierto: en vez de levantar otro —que moriria con "address already + # in use" y dejaria un rastro confuso— se trae el panel al frente. + aviso "Ya estaba en marcha" + # La MISMA ventana que abre el arranque normal. Antes esto era xdg-open, que + # abre el navegador POR DEFECTO en una pestaña: segun si JARVIS ya estaba + # abierto o no, el mismo icono daba una aplicacion o una pestaña de Firefox. + setsid "$RAIZ/ventana.sh" "http://127.0.0.1:$PUERTO/" >/dev/null 2>&1 & + exit 0 +fi + +# ollama, si no esta ya. En segundo plano y sin esperar: el catalogo contesta +# las ordenes conocidas sin tocar el modelo, asi que JARVIS es util desde el +# primer segundo aunque ollama tarde en calentar. +if ! ollama_up && command -v ollama >/dev/null; then + setsid ollama serve >/dev/null 2>&1 & +fi + +# El indice de COFRE, si no esta. En segundo plano: JARVIS arranca sin el y el +# cerebro lo usa en cuanto este listo. Reconstruirlo a mano: saber/indexa.py +if [ ! -f "$RAIZ/datos/saber.jsonl" ]; then + setsid "$PYTHON" "$RAIZ/saber/indexa.py" >/dev/null 2>&1 & +fi + +: > "$REGISTRO" 2>/dev/null || REGISTRO=/dev/null +aviso "Arrancando" + +MODO=(--panel --escucha) +[ "${1:-}" = "--panel" ] && MODO=(--panel) + +# El pid se apunta ANTES del exec y es el de este mismo shell, que con exec +# pasa a ser el de Python: asi el fichero nunca apunta a un proceso que no es. +echo $$ > "$PID" +trap 'rm -f "$PID"' EXIT +exec "$PYTHON" -u "$RAIZ/jarvis.py" "${MODO[@]}" >>"$REGISTRO" 2>&1 diff --git a/nucleo/boca/__init__.py b/nucleo/boca/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nucleo/boca/voz.py b/nucleo/boca/voz.py new file mode 100644 index 0000000..ec907d4 --- /dev/null +++ b/nucleo/boca/voz.py @@ -0,0 +1,210 @@ +"""La boca de JARVIS: convertir texto en sonido, y saber cuando esta sonando. + +Esto es la version de `voces.py` + la mitad de `tts.py` de Newelle, pero sin el +motor de handlers: aqui solo hablamos por Piper y por el clon, y los dos son un +script de shell que escribe un WAV. Todo lo demas sobraba. + +## Lo que hay que hacer bien y en Newelle estaba mal + +**Saber que esta hablando, y saberlo A TIEMPO.** El eco que envenena el registro +de JARVIS —el 21 % confirmado de las frases que no entiende son suyas— viene de +que Newelle marca `assistant_speaking` con `GLib.idle_add`, que es asincrono: el +audio ya esta sonando mientras el hilo de grabacion sigue leyendo `False`. Aqui +la bandera se pone ANTES de lanzar el reproductor y se quita con retardo, porque +cuando el proceso termina el sonido todavia esta viajando hasta el microfono. + +**Un solo altavoz.** Con tareas en paralelo, dos respuestas a la vez se pisan. +Se habla por turnos con un cerrojo, igual que hacia `_habla` en el panel. + +## La cache + +Clave `sha1(voz|texto normalizado)`. Piper tarda entre medio segundo y dos por +frase, y JARVIS repite mucho ("Enseguida, señor", "No he podido, señor"): con +cache eso es un `cp`. + +Se guarda en `datos/cache_voz/`, no en la del clon: aquella la genera +`generar_cache_voz.py` con su propia clave (sha1 solo del texto) y es de solo +lectura para nosotros. `jarvis-voz.sh` ya la consulta por dentro. +""" +import hashlib +import os +import re +import shutil +import subprocess +import threading +import time + +RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +CONFIG = os.path.join(RAIZ, "config") +MODELOS = os.path.join(RAIZ, "voz", "modelos") +CACHE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "datos", "cache_voz") + +# Cuanto se sigue considerando "hablando" despues de que el reproductor termine. +# El sonido tarda en salir del buffer de la tarjeta y en llegar al microfono; sin +# esta cola, la ultima silaba de JARVIS se cuela en la siguiente transcripcion. +# +# El valor sale de medirlo con pruebas/prueba_eco.py, no de suponerlo: con 350 +# la frase entera dejaba de colarse pero seguia escapandose un fragmento. +COLA_MS = int(os.environ.get("JARVIS_COLA_MS", "700")) + + +def _piper(modelo): + return { + "orden": [os.path.join(CONFIG, "jarvis-piper.sh")], + "entorno": {"JARVIS_PIPER_VOZ": modelo, "JARVIS_PIPER_TONO": "1.0"}, + "necesita": os.path.join(MODELOS, modelo + ".onnx"), + } + + +# El orden es el de rotacion. davefx primero porque es la que se eligio a oido. +VOCES = { + "davefx": _piper("es_ES-davefx-medium"), + "clon": { + "orden": [os.path.join(CONFIG, "jarvis-voz.sh")], + "entorno": {}, + "necesita": os.path.join(CONFIG, "jarvis-voz.sh"), + }, + "claude": _piper("es_MX-claude-high"), + "ald": _piper("es_MX-ald-medium"), + "sharvard": _piper("es_ES-sharvard-medium"), +} + +POR_DEFECTO = "davefx" + +# Se prueban en orden. paplay y pw-play respetan el enrutado de PipeWire, que es +# lo que hace falta para que el cancelador de eco tenga la referencia de lo que +# sale por los altavoces. aplay habla con ALSA directamente y se la salta. +REPRODUCTORES = (["paplay"], ["pw-play"], ["aplay", "-q"]) + + +def _normaliza(texto: str) -> str: + return re.sub(r"\s+", " ", texto or "").strip() + + +def _clave(voz: str, texto: str) -> str: + return hashlib.sha1(f"{voz}|{_normaliza(texto)}".encode()).hexdigest()[:16] + + +def _reproductor(): + for r in REPRODUCTORES: + if shutil.which(r[0]): + return r + return None + + +class Boca: + """Todo lo que suena sale de aqui, y de uno en uno.""" + + def __init__(self, voz: str = POR_DEFECTO, al_cambiar=None): + self.voz = voz if voz in VOCES else POR_DEFECTO + self._turno = threading.Lock() + self._hablando = False + self._hasta = 0.0 # instante hasta el que sigue contando como hablando + self._al_cambiar = al_cambiar # se avisa al oido para que se calle + os.makedirs(CACHE, exist_ok=True) + + # ------------------------------------------------------------ el estado + + @property + def hablando(self) -> bool: + """Si esta sonando algo, incluida la cola. + + El oido consulta esto en cada trozo de audio. Tiene que ser barato y, + sobre todo, no puede mentir por defecto: mas vale sobrar 300 ms de + silencio que colar la propia voz en la transcripcion. + """ + return self._hablando or time.monotonic() < self._hasta + + def _marca(self, hablando: bool): + self._hablando = hablando + if not hablando: + self._hasta = time.monotonic() + COLA_MS / 1000.0 + if self._al_cambiar: + try: + self._al_cambiar(hablando) + except Exception: + pass + + # ------------------------------------------------------------- generar + + def _genera(self, texto: str) -> str | None: + """Devuelve la ruta del WAV, de cache o recien hecho.""" + voz = VOCES[self.voz] + destino = os.path.join(CACHE, _clave(self.voz, texto) + ".wav") + if os.path.exists(destino) and os.path.getsize(destino) > 0: + return destino + + entorno = {**os.environ, **voz["entorno"]} + try: + subprocess.run(voz["orden"] + [destino, texto], env=entorno, + capture_output=True, timeout=120) + except (subprocess.TimeoutExpired, OSError): + return None + if not os.path.exists(destino) or os.path.getsize(destino) == 0: + # Que no se quede un fichero vacio en la cache: el siguiente intento + # lo daria por bueno y JARVIS se quedaria mudo para esa frase. + if os.path.exists(destino): + os.unlink(destino) + return None + return destino + + # -------------------------------------------------------------- hablar + + def di(self, texto: str) -> bool: + """Habla, esperando turno. Devuelve si sono.""" + texto = _normaliza(texto) + if not texto: + return False + with self._turno: + return self._suelta(texto) + + def di_si_libre(self, texto: str) -> bool: + """Habla solo si nadie tiene el turno. Para avisos que no merecen cola.""" + if not self._turno.acquire(blocking=False): + return False + try: + return self._suelta(_normaliza(texto)) + finally: + self._turno.release() + + def _suelta(self, texto: str) -> bool: + wav = self._genera(texto) + if not wav: + return False + orden = _reproductor() + if not orden: + return False + + # La bandera ANTES de lanzar el reproductor, no despues y no en otro + # hilo. Este es exactamente el fallo de Newelle que causa el eco. + self._marca(True) + try: + subprocess.run(orden + [wav], capture_output=True, timeout=300) + except (subprocess.TimeoutExpired, OSError): + return False + finally: + self._marca(False) + return True + + # --------------------------------------------------------------- voces + + def disponibles(self) -> list: + return [n for n, v in VOCES.items() if os.path.exists(v["necesita"])] + + def cambia(self, nombre: str) -> bool: + if nombre not in VOCES or not os.path.exists(VOCES[nombre]["necesita"]): + return False + self.voz = nombre + return True + + def siguiente(self) -> str: + lista = self.disponibles() + if not lista: + return self.voz + try: + i = lista.index(self.voz) + except ValueError: + i = -1 + self.voz = lista[(i + 1) % len(lista)] + return self.voz diff --git a/nucleo/cara/__init__.py b/nucleo/cara/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nucleo/cara/hud/hud.js b/nucleo/cara/hud/hud.js new file mode 100644 index 0000000..a0e098e --- /dev/null +++ b/nucleo/cara/hud/hud.js @@ -0,0 +1,948 @@ +/* HUD de JARVIS. + * + * Python empuja eventos con window.jarvis.recibe({tipo, datos}) y el HUD + * devuelve pulsaciones por window.webkit.messageHandlers. + * + * Los niveles llegan a ~15 por segundo para no cargar el puente; el dibujo + * va a 60 con requestAnimationFrame e interpola entre envios, asi que se ve + * fluido sin pagarlo en llamadas. */ + +(() => { + "use strict"; + + const BANDAS = 12; + const MAX_ACCIONES = 8; + + // El reactor no lleva colores propios: los lee de las variables del CSS, asi + // que cambiar la paleta repinta tambien el canvas sin tocar JavaScript. Un + // solo tema —naranja neon sobre negro— y por eso ya no hay lista ni rotacion. + const VARIABLES = { + reposo: ["--r-vivo", "--r-hondo", "--r-nucleo"], + escuchando: ["--r-vivo", "--r-hondo", "--r-nucleo"], + hablando: ["--r-habla", "--r-habla-hondo", "--r-habla-nucleo"], + parado: ["--r-parado", "--r-parado-hondo", "--r-parado-nucleo"], + }; + + let COLORES = {}; + + function leeColores() { + const css = getComputedStyle(document.documentElement); + COLORES = {}; + for (const [estado, vars] of Object.entries(VARIABLES)) { + const [vivo, hondo, nucleo] = vars.map((v) => css.getPropertyValue(v).trim()); + COLORES[estado] = { vivo, hondo, nucleo }; + } + } + + // La lista de voces vive en Python (utility/voces.py), no aqui: el boton solo + // pide "la siguiente" y pinta lo que le contesten. Asi el rotulo no puede + // decir una voz distinta de la que esta puesta de verdad. + function ponVoz(datos) { + const boton = document.getElementById("voz"); + if (!boton || !datos) return; + boton.querySelector(".rotulo").textContent = "voz: " + (datos.nombre || "?"); + boton.dataset.voz = datos.id || ""; + // el motivo del fallo se lee al pasar por encima, no ocupa sitio en pantalla + boton.title = datos.error + ? "No pude cambiar de voz: " + datos.error + : "Cambiar la voz. La estrena diciendo una frase"; + boton.classList.toggle("fallo", Boolean(datos.error)); + } + + // Igual que la voz: la lista vive en Python (utility/cerebro.py). Pero este + // boton marca ademas si lo que se dice SALE de la maquina, porque eso no se + // puede saber mirando el panel de ninguna otra forma. + function ponCerebro(datos) { + const boton = document.getElementById("cerebro"); + if (!boton || !datos) return; + boton.querySelector(".rotulo").textContent = "cerebro: " + (datos.nombre || "?"); + boton.dataset.cerebro = datos.id || ""; + boton.classList.toggle("fuera", Boolean(datos.fuera)); + boton.classList.toggle("fallo", Boolean(datos.error)); + boton.title = datos.error + ? "No pude cambiar de cerebro: " + datos.error + : (datos.fuera + ? "Piensa con Claude (" + (datos.detalle || "") + "). La voz sigue " + + "siendo local, pero lo que preguntes sale de esta maquina." + : "Piensa aqui dentro (" + (datos.detalle || "") + "). Nada sale de " + + "la maquina."); + if (datos.nombre && datos.nombre !== "\u2014") { + aConsola("cerebro: " + datos.nombre + + (datos.fuera ? " (las preguntas salen de la maquina)" + : " (todo local)"), "orden"); + } + } + + function ponDicho(texto, atendido, motivo) { + elDicho.textContent = texto || ""; + elDicho.classList.toggle("visible", Boolean(texto)); + elDicho.classList.toggle("desoido", !atendido); + elDicho.dataset.motivo = atendido ? "" : (motivo || ""); + } + + // La conversacion tambien va a la consola. Bajo el reactor solo se ve la + // ultima frase; aqui queda el hilo entero con su hora, que es lo que hace + // falta para entender por que no reacciono hace dos minutos. + function aConsolaDicho(texto, atendido, motivo) { + if (!texto) return; + if (atendido) { + aConsola("> " + texto, "orden"); + } else { + aConsola("> " + texto, "malo"); + if (motivo) aConsola(" " + motivo, "malo"); + } + } + + const ETIQUETAS = { + reposo: "en reposo", + // "escuchando" prometia mas de lo que hacia: el microfono esta abierto, + // pero mientras no se le llame por su nombre no atiende nada. Decirlo evita + // la duda de si el problema es el microfono. + dormido: "diga jarvis", + escuchando: "escuchando", + pensando: "pensando", + preguntando: "conteste", + hablando: "respondiendo", + parado: "desconectado", + }; + + const nivelesObjetivo = new Array(BANDAS).fill(0.04); + const nivelesActuales = new Array(BANDAS).fill(0.04); + let estado = "parado"; + let fase = 0; + + const lienzo = document.getElementById("reactor"); + const ctx = lienzo.getContext("2d"); + const elEstado = document.getElementById("estado"); + const elDicho = document.getElementById("dicho"); + const elRegistro = document.getElementById("registro"); + + /* ------------------------------------------------------------ dibujo */ + + function ajustarLienzo() { + const escala = window.devicePixelRatio || 1; + const lado = lienzo.clientWidth; + lienzo.width = lado * escala; + lienzo.height = lado * escala; + ctx.setTransform(escala, 0, 0, escala, 0, 0); + } + + function dibujar() { + const lado = lienzo.clientWidth; + const cx = lado / 2; + const cy = lado / 2; + const color = COLORES[estado] || COLORES.reposo; + + ctx.clearRect(0, 0, lado, lado); + + let media = 0; + for (let i = 0; i < BANDAS; i++) { + nivelesActuales[i] += (nivelesObjetivo[i] - nivelesActuales[i]) * 0.25; + media += nivelesActuales[i]; + } + media /= BANDAS; + + const radioBase = lado * 0.17; + const largoMax = lado * 0.20; + + // halo: crece con la voz, da la sensacion de energia sin dibujar nada mas + const halo = ctx.createRadialGradient(cx, cy, radioBase * 0.2, cx, cy, + radioBase + largoMax * (0.5 + media)); + halo.addColorStop(0, hexA(color.vivo, 0.20 + media * 0.30)); + halo.addColorStop(0.55, hexA(color.vivo, 0.06)); + halo.addColorStop(1, hexA(color.vivo, 0)); + ctx.fillStyle = halo; + ctx.fillRect(0, 0, lado, lado); + + // anillos de fondo, quietos: dan escala a lo que se mueve + ctx.strokeStyle = hexA(color.vivo, 0.10); + ctx.lineWidth = 1; + for (const f of [0.62, 0.82, 1.0]) { + ctx.beginPath(); + ctx.arc(cx, cy, (radioBase + largoMax) * f, 0, Math.PI * 2); + ctx.stroke(); + } + + // espigas: una por banda del analizador + ctx.lineCap = "round"; + for (let i = 0; i < BANDAS; i++) { + const ang = (i / BANDAS) * Math.PI * 2 + fase - Math.PI / 2; + const nivel = Math.min(1, nivelesActuales[i]); + const r0 = radioBase; + const r1 = radioBase + largoMax * (0.10 + nivel * 0.9); + + const grad = ctx.createLinearGradient( + cx + Math.cos(ang) * r0, cy + Math.sin(ang) * r0, + cx + Math.cos(ang) * r1, cy + Math.sin(ang) * r1); + grad.addColorStop(0, hexA(color.vivo, 0.95)); + grad.addColorStop(1, hexA(color.hondo, 0.15)); + + ctx.strokeStyle = grad; + ctx.lineWidth = Math.max(2, lado * 0.010); + ctx.beginPath(); + ctx.moveTo(cx + Math.cos(ang) * r0, cy + Math.sin(ang) * r0); + ctx.lineTo(cx + Math.cos(ang) * r1, cy + Math.sin(ang) * r1); + ctx.stroke(); + } + + // tres arcos girando a distinta velocidad y sentido: nunca parece congelado + ctx.lineWidth = Math.max(1.5, lado * 0.005); + const arcos = [ + { r: 1.30, giro: 1.0, largo: 1.5, alfa: 0.55 }, + { r: 1.52, giro: -0.6, largo: 1.0, alfa: 0.38 }, + { r: 1.72, giro: 0.35, largo: 2.2, alfa: 0.22 }, + ]; + for (const a of arcos) { + const inicio = fase * a.giro; + ctx.strokeStyle = hexA(color.vivo, a.alfa); + ctx.beginPath(); + ctx.arc(cx, cy, radioBase * a.r, inicio, inicio + a.largo); + ctx.stroke(); + } + + // triangulo del icono + const rt = radioBase * 0.66; + ctx.strokeStyle = hexA(color.nucleo, 0.75); + ctx.lineWidth = Math.max(1.5, lado * 0.004); + ctx.beginPath(); + for (let i = 0; i < 3; i++) { + const ang = (i / 3) * Math.PI * 2 - Math.PI / 2; + const x = cx + Math.cos(ang) * rt; + const y = cy + Math.sin(ang) * rt; + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.stroke(); + + // nucleo latiendo + const rn = radioBase * (0.26 + media * 0.14); + const gn = ctx.createRadialGradient(cx, cy, 0, cx, cy, rn * 2.4); + gn.addColorStop(0, hexA(color.nucleo, 1)); + gn.addColorStop(0.4, hexA(color.vivo, 0.65)); + gn.addColorStop(1, hexA(color.vivo, 0)); + ctx.fillStyle = gn; + ctx.beginPath(); + ctx.arc(cx, cy, rn * 2.4, 0, Math.PI * 2); + ctx.fill(); + + fase += estado === "hablando" ? 0.020 : 0.007; + requestAnimationFrame(dibujar); + } + + function hexA(hex, alfa) { + const n = parseInt(hex.slice(1), 16); + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alfa})`; + } + + /* ------------------------------------------------------------ eventos */ + + function ponEstado(nuevo) { + if (nuevo === estado) return; + estado = nuevo; + elEstado.textContent = ETIQUETAS[nuevo] || nuevo; + elEstado.className = "estado " + nuevo; + } + + function ponMedidor(clave, porcentaje, texto) { + const fila = document.querySelector(`.medidor[data-clave="${clave}"]`); + if (!fila) return; + fila.querySelector("i").style.width = Math.min(100, porcentaje) + "%"; + fila.querySelector(".cifra").textContent = texto; + } + + // El rotulo dice lo que PASA, no lo que hace el boton: leer "escuchando" y + // ver el punto encendido no deja lugar a dudas; "silenciar" obligaria a + // deducir si es lo que hace o lo que esta puesto. + const ROTULOS = { + parar: { on: "parar", off: "reanudar" }, + // El rotulo dice como ESTA: "escuchando" es que atiende todo lo que oiga, + // "solo con nombre" es que hay que llamarle antes de cada orden. + sueno: { on: "escuchando", off: "solo con nombre" }, + }; + + function ponRotulo(boton) { + const textos = ROTULOS[boton.dataset.accion]; + if (!textos) return; + boton.querySelector(".rotulo").textContent = textos[boton.dataset.estado] || ""; + } + + function ponControles(datos) { + for (const [accion, encendido] of Object.entries(datos)) { + const boton = document.querySelector(`.controles button[data-accion="${accion}"]`); + if (!boton) continue; + boton.dataset.estado = encendido ? "on" : "off"; + ponRotulo(boton); + } + // Parado no es el estado de un boton, es el estado del asistente: se marca + // en el documento entero. Un HUD encendido con un boton pequeño en rojo se + // lee como "funcionando"; apagado no se puede confundir. + if (datos.parar !== undefined) { + document.body.classList.toggle("parado", !datos.parar); + if (!datos.parar) ponEstado("parado"); + } + } + + // Formatos: cada constante se lee de un vistazo o no sirve de nada, asi que + // las unidades se eligen segun la magnitud en vez de soltar el numero crudo. + const CONSTANTES = { + temperatura: (v) => `${v.toFixed(0)} °C`, + carga: (v) => v.map((x) => x.toFixed(2)).join(" "), + uptime: (v) => (v < 3600 ? `${(v / 60).toFixed(0)} min` + : v < 86400 ? `${(v / 3600).toFixed(1)} h` + : `${(v / 86400).toFixed(1)} dias`), + bateria: (v) => `${v.toFixed(0)} %`, + red: (v) => `${fmtKB(v.baja)} ↓ ${fmtKB(v.sube)} ↑`, + }; + + function fmtKB(kb) { + return kb >= 1024 ? `${(kb / 1024).toFixed(1)}M` : `${kb.toFixed(0)}K`; + } + + function ponConstantes(datos) { + for (const [clave, formato] of Object.entries(CONSTANTES)) { + if (datos[clave] === undefined) continue; + const fila = document.querySelector(`.constantes li[data-clave="${clave}"]`); + if (!fila) continue; + fila.querySelector("b").textContent = formato(datos[clave]); + // 80 grados es donde empieza a preocupar en un portatil + if (clave === "temperatura") fila.classList.toggle("alerta", datos[clave] >= 80); + } + } + + // Las medidas de JARVIS: latencia, a donde va cada orden, y el RAG. Salen del + // diario y las manda el panel cada 5 s por el canal "medidas". + const MEDIDAS = { + lat_p50: (v) => `${v.toFixed(1)} s`, + lat_p95: (v) => `${v.toFixed(1)} s`, + pct_cerebro: (v) => `${v} %`, + pct_ruido: (v) => `${v} %`, + vol24: (v) => `${v}`, + rag: (v) => `${v.hit5} %`, + }; + + function ponMedidas(datos) { + if (!datos || !datos.n) return; + const caja = document.getElementById("rendimiento"); + if (!caja) return; + for (const [clave, formato] of Object.entries(MEDIDAS)) { + if (datos[clave] === undefined) continue; + const fila = caja.querySelector(`li[data-clave="${clave}"]`); + if (!fila) continue; + fila.querySelector("b").textContent = formato(datos[clave]); + } + // latencia p95: por encima de 5 s la mediana ya se nota al hablar + const p95 = caja.querySelector('li[data-clave="lat_p95"]'); + if (p95) p95.classList.toggle("alerta", datos.lat_p95 >= 8); + // ruido alto = Whisper oyendose a si mismo, envenena el diario + const rr = caja.querySelector('li[data-clave="pct_ruido"]'); + if (rr) rr.classList.toggle("alerta", datos.pct_ruido >= 25); + + // la barra de reparto: cerebro (lento) vs catalogo (instantaneo) vs ruido + const barra = document.getElementById("reparto"); + if (barra) { + const set = (sel, f) => { + const t = barra.querySelector(sel); + if (t) t.style.setProperty("--f", Math.max(0, f)); + }; + set(".cerebro", datos.pct_cerebro); + set(".catalogo", datos.pct_catalogo); + set(".ruido", datos.pct_ruido); + barra.title = `al cerebro ${datos.pct_cerebro}% · catalogo ${datos.pct_catalogo}% · ruido ${datos.pct_ruido}% (ultimas ${datos.n})`; + } + } + + function ponNucleos(cargas) { + const caja = document.getElementById("nucleos"); + while (caja.children.length < cargas.length) caja.appendChild(document.createElement("span")); + cargas.forEach((carga, i) => { + const celda = caja.children[i]; + celda.style.setProperty("--carga", Math.min(100, carga) + "%"); + celda.title = `nucleo ${i}: ${carga.toFixed(0)} %`; + }); + } + + function ponProcesos(lista) { + const caja = document.getElementById("procesos"); + caja.textContent = ""; + for (const p of lista) { + const li = document.createElement("li"); + // el propio asistente se marca: es el que interesa vigilar + if (/newelle|ollama|llama|python/.test(p.nombre)) li.className = "propio"; + const n = document.createElement("span"); + n.className = "nombre"; + n.textContent = p.nombre; + n.title = `pid ${p.pid}`; + const c = document.createElement("span"); + c.className = "pcpu"; + c.textContent = p.cpu.toFixed(0) + "%"; + const m = document.createElement("span"); + m.className = "pmem"; + m.textContent = p.mem.toFixed(1) + "%"; + li.append(n, c, m); + caja.appendChild(li); + } + } + + /* ------------------------------------------------------- catalogo */ + + let CATALOGO = []; + + function pintaCatalogo(filtro = "") { + const caja = document.getElementById("catalogo"); + caja.textContent = ""; + const f = filtro.trim().toLowerCase(); + let total = 0; + + for (const { grupo, acciones } of CATALOGO) { + const encajan = acciones.filter( + (a) => !f || a.frase.includes(f) || a.id.includes(f) || grupo.includes(f)); + if (!encajan.length) continue; + + const titulo = document.createElement("p"); + titulo.className = "grupo"; + titulo.textContent = `${grupo} · ${encajan.length}`; + caja.appendChild(titulo); + + for (const a of encajan) { + total++; + const fila = document.createElement("div"); + fila.className = "catalogo-item"; + fila.title = "Pulsa para que te la explique en voz alta"; + fila.tabIndex = 0; + fila.setAttribute("role", "button"); + + // Pulsar una accion la EXPLICA, no la ejecuta. Es la diferencia entre + // una lista de referencia y un campo de minas: uno mira "sabe hacer" + // justo cuando no sabe que hace cada cosa, y ahi el clic no puede + // disparar un comando en la maquina. + const explica = () => { + try { + window.webkit.messageHandlers.accion.postMessage("explica:" + a.id); + } catch (e) { /* suelto en el navegador: no hay a quien preguntar */ } + }; + fila.addEventListener("click", explica); + fila.addEventListener("keydown", (ev) => { + if (ev.key === "Enter" || ev.key === " ") { ev.preventDefault(); explica(); } + }); + + const texto = document.createElement("span"); + texto.textContent = a.frase; + fila.appendChild(texto); + + // las que esperan algo detras se marcan: si no, uno dice la frase a + // secas, no pasa nada y parece que la accion esta rota + if (a.captura) { + const hueco = document.createElement("span"); + hueco.className = "hueco"; + hueco.textContent = "+ que"; + fila.appendChild(hueco); + } + if (a.confirmar) { + const aviso = document.createElement("span"); + aviso.className = "aviso"; + aviso.textContent = "⚠"; + aviso.title = "pide confirmacion antes de hacerlo"; + fila.appendChild(aviso); + } + caja.appendChild(fila); + } + } + + if (!total) { + const vacio = document.createElement("p"); + vacio.className = "vacio"; + vacio.textContent = "nada encaja con ese filtro"; + caja.appendChild(vacio); + } + } + + // Lista numerada de resultados de una busqueda: se puede leer y luego decir + // "abre el tercero". Va en la vista de acciones hechas, encabezada por el + // titulo de la busqueda. + function muestraCandado(datos) { + const c = document.getElementById("candado"); + const msg = document.getElementById("candado-msg"); + msg.textContent = datos.mensaje || "Introduzca la contraseña"; + msg.classList.toggle("error", !!datos.error); + c.classList.remove("oculto"); + const clave = document.getElementById("candado-clave"); + clave.value = ""; + clave.focus(); + } + + function enviaClave() { + const clave = document.getElementById("candado-clave"); + if (!clave.value) return; + try { + window.webkit.messageHandlers.accion.postMessage("desbloquear:" + clave.value); + } catch (e) { /* suelto en el navegador: no hay a quien mandarla */ } + clave.value = ""; + document.getElementById("candado-msg").textContent = "Comprobando..."; + } + + function muestraResultados(datos) { + const li = document.createElement("li"); + li.className = "resultados"; + const h = document.createElement("span"); + h.className = "herramienta"; + h.textContent = datos.titulo || "resultados"; + li.appendChild(h); + const ol = document.createElement("ol"); + for (const item of (datos.items || [])) { + const el = document.createElement("li"); + el.value = item.n; + el.textContent = item.texto; + ol.appendChild(el); + } + li.appendChild(ol); + elRegistro.prepend(li); + while (elRegistro.children.length > MAX_ACCIONES) { + elRegistro.lastElementChild.remove(); + } + cambiaVista("registro"); + } + + + /* ------------------------------------------------------- la consola --- + + Lo que echa de menos cualquiera que venga de una terminal: ver que esta + pasando MIENTRAS pasa, y poder cortarlo. Las tarjetas dicen el estado; + esto dice el detalle, linea a linea y en orden. */ + + const elConsola = document.getElementById("consola"); + const elConsolaEstado = document.getElementById("consola-estado"); + const MAX_LINEAS_CONSOLA = 400; // mas que esto no se lee y pesa + + function aConsola(texto, clase) { + if (!elConsola || !texto) return; + const linea = document.createElement("span"); + linea.className = "linea " + (clase || ""); + const hora = new Date().toTimeString().slice(0, 8); + linea.textContent = hora + " " + texto; + elConsola.appendChild(linea); + while (elConsola.childElementCount > MAX_LINEAS_CONSOLA) { + elConsola.firstElementChild.remove(); + } + // pegada abajo, como una terminal: si el usuario ha subido a leer, se + // respeta y no se le arrastra el scroll + const pegada = elConsola.scrollHeight - elConsola.scrollTop + - elConsola.clientHeight < 60; + if (pegada) elConsola.scrollTop = elConsola.scrollHeight; + } + + function consolaEstado(texto, ocupado) { + if (!elConsolaEstado) return; + elConsolaEstado.textContent = texto; + elConsolaEstado.classList.toggle("ocupado", Boolean(ocupado)); + const b = document.getElementById("consola-parar"); + if (b) b.disabled = !ocupado; + } + + /* --------------------------------------------------- tareas en marcha */ + + // Cuanto se queda en pantalla una tarea que termino bien y fue rapida. + // Las lentas y las que fallan NO se van solas: si algo tardo doce segundos + // uno quiere leer que salio, y si fallo, mas todavia. + const RAPIDA_S = 2.0; + const DESVANECE_MS = 3000; + + const elTareas = document.getElementById("tareas"); + const tarjetas = new Map(); // id -> {nodo, reloj, fin} + + function mandaTarea(orden, id) { + try { + window.webkit.messageHandlers.accion.postMessage(`${orden}:${id}`); + } catch (e) { + // suelto en el navegador, sin Python detras: se simula para poder ver + // el diseño sin levantar la aplicacion entera + if (orden === "cerrar_tarea") quitaTarjeta(id); + } + } + + function creaTarjeta(t) { + const nodo = document.createElement("div"); + nodo.className = "tarea"; + nodo.dataset.id = t.id; + + const titulo = document.createElement("div"); + titulo.className = "titulo"; + titulo.textContent = t.titulo; + titulo.title = t.titulo; + + const fila = document.createElement("div"); + fila.className = "fila"; + + const reloj = document.createElement("span"); + reloj.className = "reloj"; + const giro = document.createElement("i"); + giro.className = "giro"; + reloj.append(giro, document.createTextNode("0.0s")); + + const parar = document.createElement("button"); + parar.textContent = "■"; + parar.title = "Parar esta tarea"; + parar.addEventListener("click", () => mandaTarea("parar_tarea", t.id)); + + const cerrar = document.createElement("button"); + cerrar.textContent = "×"; + cerrar.title = "Cerrar la tarjeta (si sigue viva, la para)"; + cerrar.addEventListener("click", () => mandaTarea("cerrar_tarea", t.id)); + + const botones = document.createElement("span"); + botones.append(parar, cerrar); + fila.append(reloj, botones); + + const eco = document.createElement("div"); + eco.className = "eco"; + + nodo.append(titulo, fila, eco); + elTareas.append(nodo); + + const ficha = { nodo, reloj, parar, eco, fin: null }; + tarjetas.set(t.id, ficha); + return ficha; + } + + function ponTarea(t) { + const nueva = !tarjetas.has(t.id); + const ficha = tarjetas.get(t.id) || creaTarjeta(t); + // la consola cuenta el detalle que no cabe en la tarjeta + if (nueva) aConsola("$ " + t.titulo, "orden"); + const ultima = (t.lineas && t.lineas.length) ? t.lineas[t.lineas.length - 1] : ""; + if (ultima && ficha.ultimoEco !== ultima) { ficha.ultimoEco = ultima; aConsola(" " + ultima); } + if (t.estado !== "corriendo" && ficha.estadoPrevio !== t.estado) { + ficha.estadoPrevio = t.estado; + aConsola(" → " + t.estado + " (" + t.segundos.toFixed(1) + "s)", + t.estado === "fallo" ? "malo" : "bueno"); + } + const vivas = [...tarjetas.values()].filter((f) => f.fin === null).length; + consolaEstado(vivas ? vivas + " en marcha" : "en reposo", vivas > 0); + const { nodo, eco, parar } = ficha; + + nodo.dataset.estado = t.estado; + eco.textContent = (t.lineas && t.lineas.length) + ? t.lineas[t.lineas.length - 1] : ""; + + if (t.estado === "corriendo") { + ficha.fin = null; + parar.disabled = false; + ponReloj(ficha, t.segundos); + return; + } + + // Terminada: el reloj se congela en lo que tardo y el boton de parar ya + // no tiene nada que parar. + ficha.fin = t.segundos; + parar.disabled = true; + ponReloj(ficha, t.segundos); + + if (t.estado === "hecha" && t.segundos <= RAPIDA_S) { + // Lo instantaneo no merece una tarjeta permanente: aparece, confirma + // que se hizo, y se va sola. + setTimeout(() => { + nodo.classList.add("desvanece"); + setTimeout(() => quitaTarjeta(t.id), 520); + }, DESVANECE_MS); + } + } + + function ponReloj(ficha, segundos) { + // El nodo de texto es el segundo hijo: el primero es el punto que late + ficha.reloj.lastChild.textContent = segundos.toFixed(1) + "s"; + } + + function quitaTarjeta(id) { + const ficha = tarjetas.get(id); + if (!ficha) return; + ficha.nodo.remove(); + tarjetas.delete(id); + } + + // El reloj de las vivas lo lleva el HUD, no Python: si el tiempo solo + // avanzase con los avisos del puente, una tarea callada —un `find` largo que + // no escribe nada— tendria el contador congelado y pareceria colgada. + setInterval(() => { + for (const [id, ficha] of tarjetas) { + if (ficha.fin !== null) continue; + const t = parseFloat(ficha.reloj.lastChild.textContent) || 0; + ponReloj(ficha, t + 0.1); + } + }, 100); + + function anotaAccion(datos) { + const li = document.createElement("li"); + if (datos.error) li.className = "error"; + const h = document.createElement("span"); + h.className = "herramienta"; + h.textContent = datos.herramienta || "accion"; + const s = document.createElement("span"); + s.className = "salida"; + s.textContent = datos.salida || ""; + li.append(h, s); + elRegistro.prepend(li); + // al ejecutar algo se salta sola a la vista de lo hecho: es lo que uno + // quiere ver en ese momento + cambiaVista("registro"); + while (elRegistro.children.length > MAX_ACCIONES) { + elRegistro.lastElementChild.remove(); + } + } + + window.jarvis = { + recibe(mensaje) { + const { tipo, datos } = mensaje; + + if (tipo === "pulso") { + for (let i = 0; i < BANDAS && i < datos.niveles.length; i++) { + nivelesObjetivo[i] = datos.niveles[i]; + } + ponEstado(datos.estado); + + } else if (tipo === "estado") { + ponEstado(datos); + + } else if (tipo === "dicho") { + // Lo que ha oido, tanto si le hace caso como si no. Lo segundo importa + // igual o mas: sin verlo, un JARVIS que te oye pero te ignora —porque + // no le has llamado o porque esta bloqueado— es indistinguible de un + // microfono roto. + ponDicho(datos.texto, datos.atendido !== false, datos.motivo); + aConsolaDicho(datos.texto, datos.atendido !== false, datos.motivo); + + } else if (tipo === "preguntando") { + // Una pregunta en el aire cambia lo que significa hablar: lo siguiente + // que digas es la RESPUESTA, no una orden. Si no se ve, uno contesta + // "informes" y no entiende por que no ha buscado nada. + document.documentElement.classList.toggle("preguntando", + Boolean(datos.texto)); + if (datos.texto) { + ponEstado("preguntando"); + aConsola("? " + datos.texto, "orden"); + } + + } else if (tipo === "respuesta") { + ponDicho(datos.texto, true, ""); + aConsola(" " + datos.texto, "bueno"); + + } else if (tipo === "accion") { + anotaAccion(datos); + + } else if (tipo === "tarea") { + ponTarea(datos); + + } else if (tipo === "tarea_fuera") { + quitaTarjeta(datos.id); + + } else if (tipo === "bloqueo") { + muestraCandado(datos); + + } else if (tipo === "desbloqueado") { + document.getElementById("candado").classList.add("oculto"); + + } else if (tipo === "resultados") { + muestraResultados(datos); + + } else if (tipo === "voz") { + ponVoz(datos); + + } else if (tipo === "cerebro") { + ponCerebro(datos); + + } else if (tipo === "catalogo") { + CATALOGO = datos; + pintaCatalogo(document.getElementById("filtro").value); + + } else if (tipo === "controles") { + ponControles(datos); + + } else if (tipo === "medidas") { + ponMedidas(datos); + + } else if (tipo === "telemetria") { + if (datos.nucleos) ponNucleos(datos.nucleos); + ponConstantes(datos); + if (datos.swap) { + ponMedidor("swap", 100 * datos.swap.usada / datos.swap.total, + `${datos.swap.usada.toFixed(1)}/${datos.swap.total.toFixed(0)} GB`); + } + if (datos.disco) { + ponMedidor("disco", 100 * datos.disco.usado / datos.disco.total, + `${datos.disco.usado.toFixed(2)}/${datos.disco.total.toFixed(1)} TB`); + } + if (datos.procesos) ponProcesos(datos.procesos); + if (datos.cpu !== undefined) { + ponMedidor("cpu", datos.cpu, datos.cpu.toFixed(0) + " %"); + } + if (datos.ram) { + ponMedidor("ram", 100 * datos.ram.usada / datos.ram.total, + `${datos.ram.usada.toFixed(0)}/${datos.ram.total.toFixed(0)} GB`); + } + if (datos.gpu) { + ponMedidor("gpu", datos.gpu.uso, datos.gpu.uso.toFixed(0) + " %"); + ponMedidor("vram", + 100 * datos.gpu.vram_usada / datos.gpu.vram_total, + `${datos.gpu.vram_usada.toFixed(1)}/${datos.gpu.vram_total.toFixed(1)} GB`); + } + } + }, + }; + + /* ------------------------------------------------------------ arranque */ + + // Los interruptores NO se pintan al pulsarlos: se manda la orden y se espera + // a que Python conteste con el estado real (evento "controles"). Asi el + // boton nunca dice "silenciado" con el microfono abierto. + document.querySelectorAll(".controles button[data-accion]").forEach((boton) => { + boton.addEventListener("click", () => { + try { + window.webkit.messageHandlers.accion.postMessage(boton.dataset.accion); + } catch (e) { + // suelto en el navegador: no hay Python, se conmuta a mano para poder + // ver como queda el diseño + if (boton.dataset.estado) { + boton.dataset.estado = boton.dataset.estado === "on" ? "off" : "on"; + ponRotulo(boton); + } + } + }); + }); + + function cambiaVista(cual) { + for (const b of document.querySelectorAll(".pestanas button")) { + b.classList.toggle("activa", b.dataset.vista === cual); + } + for (const v of document.querySelectorAll(".vista")) { + v.classList.toggle("oculta", v.id !== "vista-" + cual); + } + } + + document.querySelectorAll(".pestanas button").forEach((b) => { + b.addEventListener("click", () => cambiaVista(b.dataset.vista)); + }); + + document.getElementById("filtro").addEventListener("input", (e) => { + pintaCatalogo(e.target.value); + }); + + document.getElementById("candado-ok").addEventListener("click", enviaClave); + document.getElementById("candado-clave").addEventListener("keydown", (e) => { + if (e.key === "Enter") enviaClave(); + }); + + // Cortar lo que este corriendo: el boton y Ctrl+C, que es lo que los dedos + // hacen solos cuando algo se atasca. + function cortarTodo() { + let n = 0; + for (const [id, ficha] of tarjetas) { + if (ficha.fin === null) { mandaTarea("parar_tarea", id); n++; } + } + aConsola(n ? "^C cortando " + n + " tarea(s)" : "^C no hay nada corriendo", "malo"); + } + + const botonCortar = document.getElementById("consola-parar"); + if (botonCortar) botonCortar.addEventListener("click", cortarTodo); + + document.addEventListener("keydown", (ev) => { + if (ev.ctrlKey && (ev.key === "c" || ev.key === "C")) { + // si hay texto seleccionado es que quieren copiar, no cortar + if (String(window.getSelection() || "").length) return; + ev.preventDefault(); + cortarTodo(); + } + }); + + // Lanza la tanda de prueba. No cambia nada en el boton: lo que hay que mirar + // son las tarjetas, que es justo lo que se quiere comprobar. + document.getElementById("prueba").addEventListener("click", (ev) => { + const b = ev.currentTarget; + b.disabled = true; + b.querySelector(".rotulo").textContent = "probando"; + setTimeout(() => { + b.disabled = false; + b.querySelector(".rotulo").textContent = "prueba"; + }, 4000); + try { + window.webkit.messageHandlers.accion.postMessage("prueba"); + } catch (e) { /* suelto en el navegador: no hay Python que lance nada */ } + }); + + // No se pinta al pulsar: rota Python y contesta con la voz que quedo. Mientras + // llega, se avisa de que esta en ello, que cambiar de motor tarda un momento. + document.getElementById("voz").addEventListener("click", (ev) => { + ev.currentTarget.querySelector(".rotulo").textContent = "voz: ..."; + try { + window.webkit.messageHandlers.accion.postMessage("voz:siguiente"); + } catch (e) { /* suelto en el navegador: no hay Python que rote nada */ } + }); + + const botonCerebro = document.getElementById("cerebro"); + if (botonCerebro) botonCerebro.addEventListener("click", (ev) => { + ev.currentTarget.querySelector(".rotulo").textContent = "cerebro: ..."; + try { + window.webkit.messageHandlers.accion.postMessage("cerebro:siguiente"); + } catch (e) { /* suelto en el navegador: no hay Python que rote nada */ } + }); + + window.addEventListener("resize", ajustarLienzo); + ajustarLienzo(); + leeColores(); + requestAnimationFrame(dibujar); + + try { + window.webkit.messageHandlers.listo.postMessage("listo"); + } catch (e) { + // Abierto en un navegador normal: se llena solo con datos falsos, para + // poder iterar el diseño sin arrancar Newelle. + ponEstado("escuchando"); + setInterval(() => { + for (let i = 0; i < BANDAS; i++) nivelesObjetivo[i] = Math.random() * 0.9; + }, 120); + + elDicho.textContent = "how much disk space is left?"; + elDicho.classList.add("visible"); + + [ + { herramienta: "run_command", salida: "df -h /\n/dev/nvme0n1p2 916G 70G 846G 8% /" }, + { herramienta: "system_info", salida: "62 GB RAM · 16 nucleos · NVIDIA T1200 4 GB" }, + { herramienta: "websearch", salida: "3 resultados descartados por poco fiables" }, + ].forEach(anotaAccion); + cambiaVista("catalogo"); + + window.jarvis.recibe({ tipo: "catalogo", datos: [ + { grupo: "sistema", acciones: [ + { id: "disco_libre", frase: "cuanto espacio queda", captura: false, confirmar: false }, + { id: "memoria", frase: "cuanta memoria", captura: false, confirmar: false }, + { id: "temperatura", frase: "que temperatura hay", captura: false, confirmar: false }, + ]}, + { grupo: "ficheros", acciones: [ + { id: "buscar_fichero", frase: "busca el fichero", captura: true, confirmar: false }, + { id: "proceso_matar", frase: "mata el proceso", captura: true, confirmar: true }, + ]}, + { grupo: "firefox", acciones: [ + { id: "ff_buscar_youtube", frase: "busca en youtube", captura: true, confirmar: false }, + { id: "ff_gmail", frase: "abre el correo", captura: false, confirmar: false }, + ]}, + ]}); + + window.jarvis.recibe({ tipo: "telemetria", datos: { + cpu: 34, + ram: { usada: 18.4, total: 62.5 }, + gpu: { uso: 71, vram_usada: 2.9, vram_total: 4.0 }, + nucleos: Array.from({ length: 16 }, (_, i) => 20 + ((i * 37) % 70)), + swap: { usada: 0.4, total: 1.0 }, + disco: { usado: 1.01, total: 1.9 }, + temperatura: 59, + carga: [1.81, 2.96, 4.08], + uptime: 21600, + bateria: 100, + red: { baja: 1840.5, sube: 220.2 }, + procesos: [ + { pid: 2693, nombre: "ollama", cpu: 182.4, mem: 5.8 }, + { pid: 1436, nombre: "newelle", cpu: 33.5, mem: 1.8 }, + { pid: 9422, nombre: "firefox-esr", cpu: 12.1, mem: 4.2 }, + { pid: 3829, nombre: "gnome-shell", cpu: 6.3, mem: 2.1 }, + { pid: 1201, nombre: "pipewire", cpu: 2.0, mem: 0.3 }, + ], + }}); + } +})(); diff --git a/nucleo/cara/hud/index.html b/nucleo/cara/hud/index.html new file mode 100644 index 0000000..502b874 --- /dev/null +++ b/nucleo/cara/hud/index.html @@ -0,0 +1,179 @@ + + + + + + JARVIS + + + +
+ + + + + +
+ +

en reposo

+

+ + +
+
+ + + + +
+ + + + +
+
+
+

Introduzca la contraseña

+ + +
+
+ + + + + + diff --git a/nucleo/cara/hud/puente.js b/nucleo/cara/hud/puente.js new file mode 100644 index 0000000..ff3d430 --- /dev/null +++ b/nucleo/cara/hud/puente.js @@ -0,0 +1,97 @@ +/* El puente entre el HUD y el nucleo, por WebSocket. + * + * En la version sobre Newelle el HUD vivia dentro de un widget WebKit y hablaba + * por window.webkit.messageHandlers. Fuera del flatpak no hay tal widget, pero + * hud.js usa ese puente en ocho sitios y no merece la pena tocarlos: aqui se + * SUPLANTA window.webkit con un objeto que manda lo mismo por el socket. + * + * Asi hud.js se queda exactamente igual que estaba —cero diff sobre 2.064 + * lineas que ya funcionaban— y ademas sigue abriendose suelto en un navegador + * para mirar el diseño, que es como se ha estado trabajando en el. + * + * Este fichero se carga ANTES que hud.js: cuando hud.js pregunte por + * window.webkit, ya esta. + */ +(function () { + "use strict"; + + // Ya estamos dentro de WebKit (queda de la epoca de Newelle): no tocar nada. + if (window.webkit && window.webkit.messageHandlers) return; + + const URL = "ws://" + (location.host || "127.0.0.1:8790") + "/puente"; + + let socket = null; + let cola = []; // lo que se pulse antes de que abra el socket + let esperaReconexion = 500; + + function manda(canal, dato) { + const mensaje = JSON.stringify({ canal: canal, dato: String(dato) }); + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(mensaje); + } else { + // No se pierde: un boton pulsado mientras reconecta tiene que llegar + // igual, o el panel parece que se ha colgado. Con tope, que si el nucleo + // esta caido de verdad no queremos acumular mil pulsaciones. + if (cola.length < 50) cola.push(mensaje); + } + } + + // El mismo interfaz que ofrecia WebKit, para que hud.js no note el cambio. + window.webkit = { + messageHandlers: { + accion: { postMessage: (d) => manda("accion", d) }, + listo: { postMessage: (d) => manda("listo", d) }, + }, + }; + + function conecta() { + try { + socket = new WebSocket(URL); + } catch (e) { + reintenta(); + return; + } + + socket.addEventListener("open", () => { + esperaReconexion = 500; + marcaCaido(false); + const pendientes = cola; + cola = []; + pendientes.forEach((m) => socket.send(m)); + }); + + socket.addEventListener("message", (ev) => { + let mensaje; + try { + mensaje = JSON.parse(ev.data); + } catch (e) { + return; + } + // El formato {tipo, datos} es el mismo que empujaba Python por + // evaluate_javascript, asi que recibe() no cambia. + if (window.jarvis && window.jarvis.recibe) window.jarvis.recibe(mensaje); + }); + + socket.addEventListener("close", () => { marcaCaido(true); reintenta(); }); + socket.addEventListener("error", () => { try { socket.close(); } catch (e) {} }); + } + + function reintenta() { + // Espera creciente hasta 8 s: reintentar cada 500 ms contra un nucleo + // parado calienta el portatil para nada. + setTimeout(conecta, esperaReconexion); + esperaReconexion = Math.min(esperaReconexion * 2, 8000); + } + + // Que se vea que el panel esta hablando solo. Un HUD con datos congelados y + // sin avisar es peor que uno que dice claramente que perdio el hilo. + function marcaCaido(caido) { + document.documentElement.classList.toggle("sin-nucleo", caido); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", conecta); + } else { + conecta(); + } +})(); diff --git a/nucleo/cara/hud/style.css b/nucleo/cara/hud/style.css new file mode 100644 index 0000000..0b76517 --- /dev/null +++ b/nucleo/cara/hud/style.css @@ -0,0 +1,1233 @@ +/* Piel de JARVIS. + Paleta del icono del lanzador: #eafcff nucleo, #7fe9ff medio, + #1b9fd8 profundo, #0a1620 fondo. Ambar #ffc46b cuando responde. + + Tipografia: Inter Display y Adwaita Mono vienen ya en el runtime de GNOME, + asi que no se empaqueta ninguna fuente. Para meter una propia, dejar el + .ttf o .woff2 en esta misma carpeta y descomentar el @font-face de abajo. */ + +/* +@font-face { + font-family: "JarvisPropia"; + src: url("fuente.woff2") format("woff2"); +} +*/ + +/* ============================== TEMAS ============================== + El boton "tema" del HUD los rota y Python guarda el elegido en tema.txt, + al lado de este fichero. El reactor del canvas lee estas mismas variables + con getComputedStyle, asi que un tema nuevo son diez lineas aqui y nada + Un solo tema, a proposito: mantener cuatro paletas que nadie mira es codigo + que envejece sin que se note. Se cambia el color aqui arriba y ya. */ + +:root { + /* Naranja neon sobre negro puro. El color NO es inventado: es el #ff4e00 de + la paleta de la terminal del usuario, donde ocupa seis casillas de + dieciseis. Su terminal va negro puro con letra blanca, y esto es lo mismo. + + El fondo es #000 de verdad, sin degradado. Antes habia un halo radial + calido en el centro (--centro) y con el la pantalla leia marron oscuro, no + negra: el naranja no destacaba porque competia contra su propio color + apagado de fondo. Contra negro puro salta. */ + --fondo: #000000; + --fondo-hondo: #000000; + --centro: #000000; /* sin halo: el fondo es negro y punto */ + --acento: #ff4e00; /* el naranja de la terminal */ + --detalle: #a33200; + --brillo: #ffffff; /* blanco puro, como el primer plano de su terminal */ + --texto: #ff4e00; + --tenue: rgba(255, 78, 0, 0.55); + --linea: rgba(255, 78, 0, 0.28); + --rejilla: rgba(255, 78, 0, 0.055); + + --r-vivo: #ff4e00; --r-hondo: #7a2500; --r-nucleo: #ffffff; + --r-habla: #ffffff; --r-habla-hondo: #a33200; --r-habla-nucleo: #ffffff; + --r-parado: #3d1c00; --r-parado-hondo: #140900; --r-parado-nucleo: #7a4a20; + + --mal: #ff2d1a; + --aviso: #ffffff; + + --halo: 0 0 8px rgba(255, 78, 0, 0.6); + --halo-blanco: 0 0 6px rgba(255, 255, 255, 0.5), 0 0 18px rgba(255, 78, 0, 0.8); + + /* Sin fuente de rotulo aparte: se probaron dos y ninguna convencio. Los + rotulos usan la misma que el resto del panel. */ + --display: "Inter Display", "Inter", system-ui, sans-serif; + --display-peso: 600; + --titulo: "Inter Display", "Inter", system-ui, sans-serif; + --cifras: "Adwaita Mono", "DejaVu Sans Mono", monospace; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + height: 100vh; + overflow: hidden; + color: var(--texto); + font-family: var(--titulo); + + /* Negro puro y la rejilla naranja encima. Nada mas. + + Antes habia ademas un degradado radial calido en el centro. Contra el, el + naranja no destacaba: la pantalla leia marron oscuro y el acento competia + con su propio color apagado de fondo. Quitado el degradado, el mismo + naranja salta. */ + background-color: var(--fondo); + background-image: + linear-gradient(var(--rejilla) 1px, transparent 1px), + linear-gradient(90deg, var(--rejilla) 1px, transparent 1px); + background-size: 64px 64px, 64px 64px; +} + +.hud { + height: 100vh; + display: grid; + grid-template-columns: minmax(180px, 240px) 1fr minmax(220px, 320px); + align-items: center; + gap: 24px; + padding: 32px 40px 96px; +} + +h2 { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.32em; + text-transform: uppercase; + color: var(--tenue); + margin-bottom: 18px; + padding-bottom: 8px; + border-bottom: 1px solid var(--linea); +} + +/* ---------------------------------------------------------- telemetria */ + +.telemetria { align-self: center; } + +.medidor { + display: grid; + grid-template-columns: 42px 1fr auto; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.etiqueta { + font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--tenue); +} + +.barra { + height: 4px; + background: color-mix(in srgb, var(--acento) 14%, transparent); + border-radius: 2px; + overflow: hidden; +} + +.barra i { + display: block; + height: 100%; + width: 0; + background: linear-gradient(90deg, var(--detalle), var(--acento)); + box-shadow: 0 0 8px color-mix(in srgb, var(--acento) 60%, transparent); + transition: width 0.4s cubic-bezier(0.22, 1, 0.36, 1); +} + +.cifra { + font-family: var(--cifras); + font-size: 11px; + color: var(--texto); + min-width: 62px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +h2.separado { margin-top: 26px; } + +/* rejilla de nucleos: un ladrillo por hilo, como el de btop */ +.nucleos { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: 3px; +} + +/* Cada ladrillo lleva su propio marco y su brillo: antes eran manchas planas + y a un metro de la pantalla no se distinguian unas de otras. */ +.nucleos span { + height: 26px; + border-radius: 3px; + background: color-mix(in srgb, var(--acento) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--acento) 32%, transparent); + position: relative; + overflow: hidden; +} + +/* se llena de abajo arriba: la altura es la carga de ese hilo */ +.nucleos span::after { + content: ""; + position: absolute; + left: 0; right: 0; bottom: 0; + height: var(--carga, 0%); + background: linear-gradient(180deg, var(--brillo), var(--acento) 45%, var(--detalle)); + box-shadow: 0 0 10px color-mix(in srgb, var(--acento) 75%, transparent); + transition: height 0.5s cubic-bezier(0.22, 1, 0.36, 1); +} + +/* linea de brillo en la cresta: marca el nivel exacto, que el degradado solo + no lo deja claro */ +.nucleos span::before { + content: ""; + position: absolute; + left: 0; right: 0; + bottom: var(--carga, 0%); + height: 2px; + background: var(--brillo); + box-shadow: 0 0 8px var(--brillo); + opacity: 0.9; + transition: bottom 0.5s cubic-bezier(0.22, 1, 0.36, 1); + z-index: 1; +} + +/* constantes: pares etiqueta-valor, sin barra porque no son porcentajes */ +.constantes { + list-style: none; + margin-top: 18px; + font-size: 11px; +} + +.constantes li { + display: flex; + justify-content: space-between; + align-items: baseline; + padding: 4px 0; + border-bottom: 1px solid color-mix(in srgb, var(--acento) 8%, transparent); +} + +.constantes span { + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--tenue); + font-size: 10px; +} + +.constantes b { + font-family: var(--cifras); + font-weight: 500; + color: var(--texto); + font-variant-numeric: tabular-nums; +} + +/* si algo se calienta, que se vea sin buscarlo */ +.constantes li.alerta b { color: #ff7a6b; } + +/* la barra de reparto: a donde va cada orden. Un tramo por via, ancho segun su + porcentaje (--f, de 0 a 100), en una sola linea. */ +.reparto { + display: flex; + height: 8px; + margin: 14px 0 4px; + border-radius: 4px; + overflow: hidden; + background: color-mix(in srgb, var(--acento) 8%, transparent); +} +.reparto .tramo { + flex: var(--f, 0) 0 0; + transition: flex-grow 0.6s ease; +} +.reparto .cerebro { background: var(--acento); } +.reparto .catalogo { background: color-mix(in srgb, var(--acento) 45%, transparent); } +.reparto .ruido { background: #ff7a6b; } + +/* los que mas comen */ +.procesos { list-style: none; font-family: var(--cifras); font-size: 11px; } + +.procesos { max-height: 26vh; overflow: hidden; } + +.procesos li { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 8px; + padding: 3px 0; + color: color-mix(in srgb, var(--texto) 70%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--acento) 7%, transparent); +} + +.procesos .nombre { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--texto); +} + +.procesos .pcpu { color: var(--acento); min-width: 44px; text-align: right; } +.procesos .pmem { min-width: 42px; text-align: right; } + +/* el propio asistente, resaltado: es el que interesa vigilar */ +.procesos li.propio .nombre { color: var(--r-habla); font-weight: 600; } + +/* -------------------------------------------------------------- nucleo */ + +.nucleo { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; +} + +#reactor { width: min(52vh, 520px); height: min(52vh, 520px); } + +.estado { + font-size: 12px; + font-weight: 500; + letter-spacing: 0.42em; + text-transform: uppercase; + color: var(--tenue); + transition: color 0.4s ease; +} + +.estado.escuchando { color: var(--acento); } +.estado.hablando { color: var(--r-habla); } + +/* ------------------------------------------------------- tareas en marcha + + Una tarjetita por orden. Van en franja bajo el reactor: caben tres o cuatro + sin taparlo, y al estar todas a la misma altura se compara de un vistazo + cual lleva mas tiempo, que es la pregunta que uno se hace mirandolas. + + La franja no empuja al reactor cuando aparece la primera tarjeta: tiene + altura reservada (min-height) y esta en position: absolute respecto al + nucleo. Si creciera el layout, el reactor daria un salto cada vez que se + manda algo, y el salto se nota mucho mas que las tarjetas. */ + +/* La franja va en el flujo, no flotando: colocada en absolute se le montaba + encima al rotulo de estado en cuanto las tarjetas pasaban de una fila, y una + segunda fila llega en cuanto mandas cuatro cosas. + + El hueco de UNA fila esta siempre reservado (min-height), asi que mandar la + primera orden no mueve el reactor. Si aparece una segunda fila el centro + sube un poco, que es honesto: hay mas cosas y ocupan mas. */ +.tareas { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-content: flex-start; + gap: 10px; + width: 100%; + min-height: 74px; + margin-top: 10px; +} + +.tarea { + pointer-events: auto; + width: 156px; + padding: 9px 10px; + border: 1px solid color-mix(in srgb, var(--acento) 26%, transparent); + border-left: 3px solid var(--detalle); + background: color-mix(in srgb, var(--fondo-hondo) 92%, transparent); + backdrop-filter: blur(2px); + display: grid; + gap: 6px; + animation: tarea-entra 0.22s ease-out; + transition: opacity 0.5s ease, transform 0.5s ease; +} + +@keyframes tarea-entra { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: none; } +} + +.tarea.desvanece { opacity: 0; transform: translateY(-4px); } + +.tarea .titulo { + font-size: 10px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--texto); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tarea .fila { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; +} + +.tarea .reloj { + font-family: var(--cifras); + font-size: 11px; + color: var(--tenue); +} + +.tarea .giro { + display: inline-block; + width: 7px; height: 7px; + margin-right: 5px; + border-radius: 50%; + background: var(--acento); + box-shadow: 0 0 6px var(--acento); + animation: late 1.1s ease-in-out infinite; +} + +@keyframes late { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.35; transform: scale(0.72); } +} + +/* La ultima linea de salida. Solo una: la tarjeta informa de que va la cosa, + el detalle entero se lee en la columna de la derecha. */ +.tarea .eco { + font-family: var(--cifras); + font-size: 10px; + color: color-mix(in srgb, var(--texto) 62%, transparent); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-height: 12px; +} + +.tarea button { + font-family: var(--cifras); + font-size: 11px; + line-height: 1; + padding: 4px 7px; + margin-left: 4px; + color: var(--texto); + background: color-mix(in srgb, var(--acento) 8%, transparent); + border: 1px solid color-mix(in srgb, var(--acento) 34%, transparent); + cursor: pointer; + transition: color 0.15s ease, border-color 0.15s ease, + background-color 0.15s ease; +} + +.tarea button:hover { + color: var(--brillo); + border-color: var(--acento); + background: color-mix(in srgb, var(--acento) 20%, transparent); +} + +/* Parar, deshabilitado, tiene que leerse como "ya no hay nada que parar" y no + como un boton que no funciona. */ +.tarea button:disabled { + opacity: 0.28; + cursor: default; + background: transparent; +} + +/* estados: el color del borde izquierdo es todo el mensaje */ +.tarea[data-estado="hecha"] { border-left-color: var(--acento); } +.tarea[data-estado="parada"] { border-left-color: var(--r-parado); } +.tarea[data-estado="fallo"] { border-left-color: var(--mal); } +.tarea[data-estado="caducada"] { border-left-color: var(--r-habla); } + +.tarea[data-estado="fallo"] .eco { color: var(--mal); } + +.tarea:not([data-estado="corriendo"]) .giro { + animation: none; + opacity: 0.5; + box-shadow: none; +} +.tarea[data-estado="hecha"] .giro { background: var(--acento); } +.tarea[data-estado="parada"] .giro { background: var(--r-parado); } +.tarea[data-estado="fallo"] .giro { background: var(--mal); } +.tarea[data-estado="caducada"] .giro { background: var(--r-habla); } + +/* lo que ha entendido: presente pero sin robar atencion */ +.dicho { + max-width: 60ch; + min-height: 1.5em; + text-align: center; + font-size: 16px; + line-height: 1.5; + color: color-mix(in srgb, var(--texto) 78%, transparent); + opacity: 0; + transition: opacity 0.5s ease; +} + +.dicho.visible { opacity: 1; } + +/* ------------------------------------------------------------ acciones */ + +.acciones { + align-self: stretch; + display: flex; + flex-direction: column; + min-height: 0; /* sin esto el hijo con scroll no encoge */ + padding-top: 6vh; + padding-bottom: 12vh; +} + +.acciones h2 { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.pestanas { display: flex; gap: 4px; } + +.pestanas button { + font-family: var(--titulo); + font-size: 9px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--tenue); + background: none; + border: 1px solid transparent; + border-radius: 999px; + padding: 4px 10px; + cursor: pointer; + transition: color 0.15s, border-color 0.15s, background 0.15s; +} + +.pestanas button:hover { color: var(--brillo); } + +.pestanas button.activa { + color: var(--acento); + border-color: color-mix(in srgb, var(--acento) 40%, transparent); + background: color-mix(in srgb, var(--acento) 10%, transparent); +} + +/* Las dos vistas ocupan lo que queda y se desplazan por dentro: antes la + columna crecia hacia abajo y lo que no cabia quedaba fuera de la pantalla, + sin forma de llegar a ello. */ +.vista { + flex: 1; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--acento) 35%, transparent) transparent; +} + +.vista.oculta { display: none; } + +#filtro { + width: 100%; + font-family: var(--cifras); + font-size: 11px; + color: var(--texto); + background: color-mix(in srgb, var(--acento) 7%, transparent); + border: 1px solid var(--linea); + border-radius: 6px; + padding: 7px 10px; + margin-bottom: 10px; +} + +#filtro::placeholder { color: var(--tenue); } +#filtro:focus { outline: none; border-color: var(--acento); } + +.grupo { + font-size: 9px; + letter-spacing: 0.24em; + text-transform: uppercase; + color: var(--tenue); + margin: 12px 0 5px; +} + +.grupo:first-child { margin-top: 0; } + +.catalogo-item { + display: flex; + align-items: baseline; + gap: 6px; + padding: 4px 8px; + border-left: 2px solid color-mix(in srgb, var(--acento) 25%, transparent); + border-radius: 0 4px 4px 0; + font-size: 11px; + color: color-mix(in srgb, var(--texto) 78%, transparent); + transition: background 0.12s, color 0.12s, border-color 0.12s; +} + +.catalogo-item:hover { + background: color-mix(in srgb, var(--acento) 10%, transparent); + border-left-color: var(--acento); + color: var(--brillo); +} + +/* las que piden algo detras se marcan, que si no parece que no funcionan */ +.catalogo-item .hueco { + font-family: var(--cifras); + font-size: 10px; + color: var(--acento); + opacity: 0.75; +} + +.catalogo-item .aviso { font-size: 10px; opacity: 0.7; } + +.vacio { + font-size: 11px; + color: var(--tenue); + padding: 10px 0; +} + +#registro { list-style: none; display: flex; flex-direction: column; gap: 8px; } + +#registro li { + border-left: 2px solid var(--detalle); + background: color-mix(in srgb, var(--acento) 5%, transparent); + padding: 8px 12px; + border-radius: 0 6px 6px 0; + animation: entra 0.45s cubic-bezier(0.22, 1, 0.36, 1); +} + +#registro li.error { border-left-color: #ff7a6b; } + +#registro .herramienta { + display: block; + font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--acento); + margin-bottom: 3px; +} + +#registro .salida { + font-family: var(--cifras); + font-size: 11px; + line-height: 1.45; + color: color-mix(in srgb, var(--texto) 72%, transparent); + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +@keyframes entra { + from { opacity: 0; transform: translateX(14px); } + to { opacity: 1; transform: none; } +} + +/* ----------------------------------------------------------- controles */ + +.controles { + position: fixed; + bottom: 34px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 14px; +} + +/* Se usan de lejos y a veces con prisa —callarlo cuando no toca—, asi que + priman el tamaño y el contraste sobre la discrecion. */ +.controles button { + display: flex; + align-items: center; + gap: 10px; + font-family: var(--titulo); + font-size: 13px; + font-weight: 500; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--texto); + background: color-mix(in srgb, var(--acento) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--acento) 40%, transparent); + border-radius: 999px; + padding: 17px 34px; + white-space: nowrap; + cursor: pointer; + position: relative; + overflow: hidden; + transition: color 0.15s, border-color 0.15s, background 0.15s, transform 0.1s; +} + +/* El hover levanta el boton y lo enciende: el resplandor exterior es lo que + le da el aire de panel de mando en vez de formulario web. */ +/* El boton de voz lleva el nombre dentro ("voz: sharvard"), asi que necesita + ancho fijo o la barra de controles se mueve entera al rotar. */ +.controles button#voz { + min-width: 156px; + justify-content: center; +} + +/* Mientras la tanda esta en marcha el boton se apaga: pulsarlo dos veces + seguidas llenaria la franja de tarjetas repetidas. */ +.controles button#prueba:disabled { + opacity: 0.45; + cursor: default; +} + +/* Si la voz no pudo ponerse (falta el modelo), se marca en rojo y el motivo + esta en el title. Un boton que no hace nada y no lo dice es peor que uno roto. */ +.controles button#voz.fallo { + color: var(--mal); + border-color: color-mix(in srgb, var(--mal) 60%, transparent); +} + +.controles button:hover { + color: var(--brillo); + border-color: var(--acento); + background: color-mix(in srgb, var(--acento) 26%, transparent); + transform: translateY(-2px); + box-shadow: + 0 0 0 1px color-mix(in srgb, var(--acento) 45%, transparent), + 0 6px 26px color-mix(in srgb, var(--acento) 40%, transparent), + inset 0 0 18px color-mix(in srgb, var(--acento) 18%, transparent); + letter-spacing: 0.18em; +} + +.controles button:hover .punto { + box-shadow: 0 0 16px var(--acento), 0 0 30px var(--acento); +} + +.controles button:active { transform: translateY(0); } + +/* barrido de luz al pasar por encima */ +.controles button::after { + content: ""; + position: absolute; + inset: 0; + border-radius: inherit; + background: linear-gradient(100deg, transparent 35%, + color-mix(in srgb, var(--brillo) 22%, transparent) 50%, transparent 65%); + transform: translateX(-120%); + transition: transform 0.6s ease; + pointer-events: none; +} + +.controles button:hover::after { transform: translateX(120%); } + +/* el punto dice de un vistazo si algo esta encendido, sin leer el rotulo */ +.punto { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--acento); + box-shadow: 0 0 10px var(--acento); + transition: background 0.2s, box-shadow 0.2s; +} + +/* apagado: el punto se queda gris y el boton se hunde */ +.controles button[data-estado="off"] { + color: color-mix(in srgb, var(--texto) 45%, transparent); + background: transparent; + border-color: color-mix(in srgb, var(--acento) 18%, transparent); +} + +.controles button[data-estado="off"] .punto { + background: color-mix(in srgb, var(--texto) 30%, transparent); + box-shadow: none; +} + +/* PARAR es el boton importante: se usa deprisa y de lejos */ +.controles button.principal { font-size: 16px; padding: 21px 52px; } + +/* parado no es un detalle discreto: si no escucha, hay que verlo */ +.controles button[data-accion="parar"][data-estado="off"] { + color: #ff7a6b; + border-color: color-mix(in srgb, #ff7a6b 55%, transparent); + background: color-mix(in srgb, #ff7a6b 10%, transparent); +} + +.controles button[data-accion="parar"][data-estado="off"] .punto { + background: #ff7a6b; + box-shadow: 0 0 10px #ff7a6b; +} + +.controles button.secundario { + font-size: 11px; + padding: 13px 22px; + color: color-mix(in srgb, var(--texto) 55%, transparent); +} + + +/* ============================== PARADO ============================== + Cuando no escucha, el HUD entero baja de intensidad y sale un aviso en + medio. Se veia solo por el rotulo de un boton, y a metro y medio de la + pantalla eso no se distingue de estar funcionando. */ + +body.parado .telemetria, +body.parado .acciones, +body.parado #reactor, +body.parado .dicho { + opacity: 0.28; + filter: grayscale(0.6); + transition: opacity 0.4s ease, filter 0.4s ease; +} + +body.parado .estado { + color: #ff7a6b; + font-size: 15px; + letter-spacing: 0.5em; +} + +/* franja diagonal de fondo: inconfundible y no tapa nada */ +body.parado::before { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: repeating-linear-gradient( + -45deg, + rgba(255, 122, 107, 0.05) 0 22px, + transparent 22px 44px); +} + +body.parado::after { + content: "microfono cerrado"; + position: fixed; + top: 46px; + left: 50%; + transform: translateX(-50%); + font-family: var(--titulo); + font-size: 12px; + letter-spacing: 0.42em; + text-transform: uppercase; + color: #ff7a6b; + border: 1px solid rgba(255, 122, 107, 0.45); + background: rgba(255, 122, 107, 0.08); + border-radius: 999px; + padding: 9px 26px; + pointer-events: none; +} + +/* -------------------------------------------------- resultados de busqueda */ +/* Lista numerada para elegir por voz: "abre el tercero". Los numeros son + grandes y en color para que se localicen de un vistazo. */ +#registro li.resultados ol { + margin: 6px 0 0; + padding-left: 0; + list-style: none; + counter-reset: item; +} + +#registro li.resultados ol li { + font-family: var(--cifras); + font-size: 11px; + line-height: 1.5; + color: color-mix(in srgb, var(--texto) 82%, transparent); + padding: 2px 0 2px 26px; + position: relative; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* el numero, en un circulo del color de acento */ +#registro li.resultados ol li::before { + content: counter(item); + counter-increment: item; + position: absolute; + left: 0; + top: 2px; + width: 18px; + height: 18px; + border-radius: 50%; + background: color-mix(in srgb, var(--acento) 18%, transparent); + border: 1px solid color-mix(in srgb, var(--acento) 45%, transparent); + color: var(--acento); + text-align: center; + font-size: 10px; + line-height: 18px; + font-weight: 700; +} + +/* ============================== CANDADO ============================== + Pantalla de contraseña que tapa TODO el HUD hasta desbloquear. El + asistente no escucha ni ejecuta nada por detras mientras esto esta puesto: + no es solo visual, el panel no ha arrancado la escucha. */ + +.candado { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + /* Negro opaco, no un degradado: lo que hay detras del candado no tiene que + intuirse. Con el 96 % se sigue viendo que hay un panel debajo sin poder + leer nada de el. */ + background: rgba(0, 0, 0, 0.96); + backdrop-filter: blur(6px); +} + +.candado.oculto { display: none; } + +.candado-caja { + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + padding: 40px 48px; + border: 1px solid color-mix(in srgb, var(--acento) 30%, transparent); + border-radius: 16px; + background: color-mix(in srgb, var(--acento) 6%, transparent); + box-shadow: 0 0 60px color-mix(in srgb, var(--acento) 20%, transparent); +} + +.candado-icono { + font-size: 40px; + color: var(--acento); + text-shadow: 0 0 24px var(--acento); + animation: latido 2.4s ease-in-out infinite; +} +@keyframes latido { 0%,100%{opacity:.7} 50%{opacity:1} } + +.candado-msg { + margin: 0; + font-family: var(--titulo); + font-size: 13px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--tenue); + text-align: center; +} +.candado-msg.error { color: #ff7a6b; } + +#candado-clave { + width: 260px; + font-family: var(--cifras); + font-size: 15px; + text-align: center; + color: var(--texto); + background: color-mix(in srgb, var(--acento) 8%, transparent); + border: 1px solid var(--linea); + border-radius: 8px; + padding: 12px 14px; + letter-spacing: 0.3em; +} +#candado-clave:focus { outline: none; border-color: var(--acento); } + +#candado-ok { + font-family: var(--titulo); + font-size: 12px; + letter-spacing: 0.2em; + text-transform: uppercase; + color: var(--fondo-hondo); + background: var(--acento); + border: none; + border-radius: 999px; + padding: 12px 32px; + cursor: pointer; + transition: background 0.15s, transform 0.1s; +} +#candado-ok:hover { background: var(--brillo); } +#candado-ok:active { transform: translateY(1px); } + + +/* ---------------------------------------------------------- la consola + + Lo que pasa AHORA, linea a linea, como en una terminal. Va en la columna + de la derecha porque es donde el ojo ya busca "que esta haciendo", y las + tarjetas del centro se quedan para el estado de un vistazo. */ + +.consola-barra { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 8px; + font-size: 10px; + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--tenue); +} + +#consola-estado.ocupado { + color: var(--acento); +} + +/* El punto que late solo cuando hay algo corriendo: si no, es una etiqueta + quieta y no distrae. */ +#consola-estado.ocupado::before { + content: ""; + display: inline-block; + width: 6px; height: 6px; + margin-right: 6px; + border-radius: 50%; + background: var(--acento); + box-shadow: 0 0 6px var(--acento); + animation: late 1.1s ease-in-out infinite; +} + +#consola-parar { + font-family: var(--cifras); + font-size: 10px; + padding: 3px 8px; + color: var(--texto); + background: color-mix(in srgb, var(--mal) 12%, transparent); + border: 1px solid color-mix(in srgb, var(--mal) 45%, transparent); + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease; +} + +#consola-parar:hover:not(:disabled) { + background: color-mix(in srgb, var(--mal) 28%, transparent); + color: var(--brillo); +} + +/* Deshabilitado tiene que leerse como "no hay nada que cortar", no como un + boton roto. */ +#consola-parar:disabled { + opacity: 0.3; + cursor: default; +} + +#consola { + font-family: var(--cifras); + font-size: 10.5px; + line-height: 1.5; + color: var(--tenue); + background: color-mix(in srgb, var(--fondo-hondo) 70%, transparent); + border: 1px solid var(--linea); + border-left: 2px solid color-mix(in srgb, var(--acento) 40%, transparent); + padding: 8px 10px; + height: calc(100vh - 260px); + overflow-y: auto; + overflow-x: hidden; + white-space: pre-wrap; + word-break: break-word; + display: flex; + flex-direction: column; + gap: 1px; +} + +#consola .linea { display: block; } +#consola .linea.orden { color: var(--texto); } /* la orden que se lanza */ +#consola .linea.bueno { color: var(--acento); } /* termino bien */ +#consola .linea.malo { color: var(--mal); } /* fallo o corte */ + +#consola::-webkit-scrollbar { width: 6px; } +#consola::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--acento) 30%, transparent); +} + +/* El cerebro de fuera se marca. No es decoracion: es la unica pista visible de + que lo que se diga sale de esta maquina, y un tooltip no se ve de reojo. */ +.controles .secundario.fuera { + color: var(--brillo); + border-color: color-mix(in srgb, var(--aviso, #e0a030) 65%, transparent); + background: color-mix(in srgb, var(--aviso, #e0a030) 12%, transparent); +} + +/* En ::before y no en ::after: el ::after de estos botones ya esta cogido por + el barrido de luz del hover, que va en position:absolute. Poner texto ahi lo + estira por todo el boton y ademas rompe el barrido. + + La palabra y no una flecha: el simbolo se pierde con la fuente del panel, y + ademas "sale de la maquina" no conviene insinuarlo con un icono. */ +.controles .secundario.fuera::before { + content: "fuera"; + font-family: var(--cifras); + font-size: 8px; + letter-spacing: 0.18em; + margin-right: 8px; + padding: 1px 4px; + border: 1px solid color-mix(in srgb, var(--aviso, #e0a030) 55%, transparent); + color: var(--aviso, #e0a030); + vertical-align: middle; +} + + +/* ================== ROTULOS EN DUNE RISE Y NEON ===================== + + Solo donde el texto lo escribimos nosotros y no lleva tildes, ñ, ° ni %: + la fuente no los tiene (105 glifos, comprobado) y a media palabra se + notaria el salto a Inter. Las cifras y los mensajes se quedan como estaban. + + El halo va con moderacion: encendido en lo que manda —los rotulos de + seccion, el estado, los botones— y apagado en las listas largas, donde + veinte lineas brillando cansan la vista y dejan de decir nada. */ + +h2, +.etiqueta, +.constantes li > span, +.procesos .nombre, +.controles button .rotulo, +.estado, +.pestanas button, +.grupo, +.consola-barra, +.tarea .titulo, +.candado-msg, +#candado-ok { + font-family: var(--display); + font-weight: var(--display-peso, 700); + letter-spacing: 0.12em; +} + +/* Dune Rise es ancha: con el espaciado de Inter, "ENCENDIDO" se sale de la + columna. Se compensa donde el sitio es justo. */ +.etiqueta, +.constantes li > span { letter-spacing: 0.06em; font-size: 9.5px; } +.procesos .nombre { letter-spacing: 0.02em; } +h2 { letter-spacing: 0.18em; } + +h2 { color: var(--acento); text-shadow: var(--halo); } + +.estado { + font-size: 13px; + letter-spacing: 0.34em; + text-shadow: var(--halo); +} +.estado.escuchando { color: var(--brillo); text-shadow: var(--halo-blanco); } + +/* --- los hovers ------------------------------------------------------- */ + +/* Los botones de abajo: al pasar por encima se encienden en blanco sobre el + naranja, que es el gesto que ya usa el resto del panel para decir "esto es + lo importante ahora". */ +.controles button { + transition: color 0.16s ease, border-color 0.16s ease, + background-color 0.16s ease, box-shadow 0.2s ease, + transform 0.12s ease; +} + +.controles button:hover { + color: var(--brillo); + border-color: var(--acento); + background: color-mix(in srgb, var(--acento) 22%, transparent); + box-shadow: + 0 0 0 1px var(--acento), + 0 0 18px color-mix(in srgb, var(--acento) 55%, transparent), + inset 0 0 22px color-mix(in srgb, var(--acento) 20%, transparent); + text-shadow: var(--halo-blanco); +} + +.controles button:focus-visible { + outline: 2px solid var(--brillo); + outline-offset: 3px; +} + +/* Las lineas del catalogo: una barra naranja que crece por la izquierda al + pasar, en vez de cambiarles el fondo. Marca donde esta el raton sin mover + nada de sitio. */ +#catalogo .catalogo-item { + position: relative; + transition: color 0.14s ease, padding-left 0.14s ease; +} + +#catalogo .catalogo-item::before { + content: ""; + position: absolute; + left: 0; top: 50%; + width: 0; height: 1px; + background: var(--acento); + box-shadow: var(--halo); + transform: translateY(-50%); + transition: width 0.16s ease; +} + +#catalogo .catalogo-item:hover { + color: var(--brillo); + padding-left: 16px; + text-shadow: var(--halo-blanco); +} + +#catalogo .catalogo-item:hover::before { width: 10px; } + +/* Los procesos, igual pero mas sobrio: son datos, no botones. */ +.procesos li { transition: color 0.14s ease; } +.procesos li:hover { color: var(--brillo); } + +/* Las pestañas de la derecha */ +.pestanas button { transition: color 0.15s ease, box-shadow 0.2s ease; } +.pestanas button:hover { color: var(--brillo); text-shadow: var(--halo); } +.pestanas button.activa { color: var(--brillo); text-shadow: var(--halo-blanco); } + +/* Las barras de los medidores brillan: son la cifra que uno mira de lejos */ +.barra i { box-shadow: 0 0 10px color-mix(in srgb, var(--acento) 75%, transparent); } + +/* El candado: el boton de desbloquear en blanco sobre naranja al pasar */ +#candado-ok { transition: background 0.16s, color 0.16s, box-shadow 0.2s; } +#candado-ok:hover { + background: var(--brillo); + color: #000000; + box-shadow: 0 0 24px color-mix(in srgb, var(--acento) 70%, transparent); +} + +/* Quien no quiera animaciones, no las tiene. */ +@media (prefers-reduced-motion: reduce) { + .controles button, + #catalogo .catalogo-item, + #catalogo .catalogo-item::before, + .pestanas button, + #candado-ok { transition: none; } +} + + +/* Lo oido pero NO atendido: apagado y con el motivo al lado. + + Se enseña igualmente, y esa es la gracia. Antes se descartaba en silencio y + la pantalla se quedaba en "escuchando" para siempre: desde fuera eso es lo + mismo que un microfono estropeado. Viendolo, se distingue al instante si el + problema es que no oye, que oye mal, o que oye bien y falta llamarle. */ +.dicho.desoido { + color: var(--tenue); + opacity: 0.75; + font-style: italic; +} + +.dicho.desoido::after { + content: attr(data-motivo); + display: block; + margin-top: 6px; + font-family: var(--cifras); + font-size: 10px; + font-style: normal; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--acento); + text-shadow: var(--halo); +} + + +/* ---- la consola: que se distinga quien dice cada cosa ------------------- + + Con la paleta de un solo color, .orden/.bueno/.malo caian los tres en el + mismo naranja y el hilo se leia como un bloque: no se veia donde acababa lo + que dijiste tu y empezaba lo que contesto el. + + Se separa por PESO y por BLANCO, que es el resalte de la casa, y no + inventando colores nuevos: + + blanco en negrita lo que se dice (tuyo o suyo): los anclajes del hilo + naranja lo que contesta y las salidas + rojo con barra lo que fallo o se descarto */ + +#consola .linea.orden { + color: var(--brillo); + font-weight: 600; + text-shadow: var(--halo-blanco); +} + +#consola .linea.bueno { + color: var(--acento); + text-shadow: var(--halo); +} + +/* El rojo solo no basta contra este naranja: se le pone barra a la izquierda, + que se ve aunque los dos tonos se parezcan. */ +#consola .linea.malo { + color: var(--mal); + border-left: 2px solid var(--mal); + padding-left: 6px; + margin-left: -8px; +} + +/* Las lineas sin clase son salida de las tareas: acompañan, no mandan. */ +#consola .linea:not(.orden):not(.bueno):not(.malo) { + color: var(--tenue); + opacity: 0.8; +} + +/* Un poco de aire entre turnos, para que el hilo se lea como una conversacion + y no como un volcado. */ +#consola .linea.orden { margin-top: 6px; } +#consola .linea.orden:first-child { margin-top: 0; } + + +/* Mientras hay una pregunta en el aire, el panel entero lo dice: lo siguiente + que se hable es la RESPUESTA, no una orden. Sin esta señal uno contesta + "informes" y no entiende por que no ha buscado nada. */ +:root.preguntando .estado { + color: var(--brillo); + text-shadow: var(--halo-blanco); + animation: late 1.4s ease-in-out infinite; +} + +:root.preguntando .dicho { color: var(--brillo); } diff --git a/nucleo/cara/panel.py b/nucleo/cara/panel.py new file mode 100644 index 0000000..b6eb77c --- /dev/null +++ b/nucleo/cara/panel.py @@ -0,0 +1,575 @@ +"""Lo que une el nucleo con la cara: que se empuja al HUD y que se hace al pulsar. + +`servidor.py` solo mueve bytes; aqui vive el significado. Es el equivalente de +lo que hacia `jarvis.py` dentro de Newelle —el fichero de 1.244 lineas que era +lo unico acoplado al framework— pero sin GTK, sin WebKit y sin el ciclo de vida +de la aplicacion de por medio. + +## Lo que se empuja y cada cuanto + + telemetria 1 s cpu, memoria, grafica, disco, procesos + pulso 15/s solo mientras escucha o habla; es lo que mueve el reactor + estado cuando cambia + tarea cuando cambia una + +La telemetria cuesta procesos (`nvidia-smi`, `ps`), asi que va en su propio hilo +y a su ritmo, no en el bucle de voz. Y el pulso solo se manda si hay alguien +mirando: sin panel abierto son quince mensajes por segundo a nadie. +""" +import os +import shutil +import subprocess +import threading +import time + +from manos.preguntar import confirmacion, es_si, pregunta_por + +AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def _lee(ruta, defecto=""): + try: + with open(ruta) as f: + return f.read().strip() + except OSError: + return defecto + + +def _manda(orden, tope=4000): + try: + r = subprocess.run(orden, capture_output=True, text=True, timeout=10) + return (r.stdout or "").strip()[:tope] + except (OSError, subprocess.TimeoutExpired): + return "" + + +class Telemetria: + """Las constantes de la maquina, leidas baratas. + + Todo de /proc salvo la grafica, que necesita nvidia-smi. Se lee /proc a + mano en vez de usar psutil para no arrastrar una dependencia por cuatro + numeros que estan en ficheros de texto. + """ + + def __init__(self): + self._cpu_previo = None + self._nucleos_previo = None + self._hay_nvidia = shutil.which("nvidia-smi") is not None + + def _cpu(self): + try: + with open("/proc/stat") as f: + lineas = [l for l in f if l.startswith("cpu")] + except OSError: + return 0, [] + def uso(campos): + n = [int(x) for x in campos[1:11]] + ocupado = sum(n) - n[3] - n[4] + return ocupado, sum(n) + ahora = [uso(l.split()) for l in lineas] + previo, self._cpu_previo = self._cpu_previo, ahora + if not previo or len(previo) != len(ahora): + return 0, [0] * (len(ahora) - 1) + def pct(a, b): + d_ocu, d_tot = a[0] - b[0], a[1] - b[1] + return round(100 * d_ocu / d_tot) if d_tot > 0 else 0 + return pct(ahora[0], previo[0]), [pct(a, b) for a, b in + zip(ahora[1:], previo[1:])] + + def _memoria(self): + datos = {} + try: + with open("/proc/meminfo") as f: + for l in f: + k, _, v = l.partition(":") + datos[k] = int(v.split()[0]) // 1024 # MB + except OSError: + return 0, 0, 0, 0 + total = datos.get("MemTotal", 0) + libre = datos.get("MemAvailable", 0) + stotal = datos.get("SwapTotal", 0) + slibre = datos.get("SwapFree", 0) + return total, total - libre, stotal, stotal - slibre + + def _grafica(self): + if not self._hay_nvidia: + return None + s = _manda(["nvidia-smi", + "--query-gpu=utilization.gpu,memory.used,memory.total", + "--format=csv,noheader,nounits"]) + try: + u, usada, total = (int(x.strip()) for x in s.split(",")[:3]) + return {"uso": u, "usada": usada, "total": total} + except (ValueError, IndexError): + return None + + def _temperatura(self): + mejor = 0 + for zona in range(12): + t = _lee(f"/sys/class/thermal/thermal_zone{zona}/temp") + if t.isdigit(): + mejor = max(mejor, int(t) // 1000) + return mejor + + def _procesos(self, tope=5): + """Los que mas CPU comen. + + cpu y mem van como NUMEROS, no como las cadenas que devuelve ps. El HUD + hace `p.cpu.toFixed(0)`, y una cadena no tiene toFixed: la excepcion + aborta el manejador entero de telemetria y se lleva por delante todo lo + que se pinta despues —cpu, ram y grafica se quedaban en "--"—. El + sintoma no apuntaba a los procesos en absoluto. + """ + s = _manda(["ps", "-eo", "pid,comm,pcpu,pmem", "--sort=-pcpu"]) + salida = [] + for l in s.splitlines()[1:tope + 1]: + c = l.split(None, 3) + if len(c) < 4: + continue + try: + salida.append({"pid": int(c[0]), "nombre": c[1][:18], + "cpu": float(c[2]), "mem": float(c[3].split()[0])}) + except ValueError: + continue + return salida + + def _disco(self): + try: + st = os.statvfs("/") + total = st.f_blocks * st.f_frsize / 1e9 + libre = st.f_bavail * st.f_frsize / 1e9 + return round(total - libre, 1), round(total, 1) + except OSError: + return 0, 0 + + def _red(self): + """Bytes acumulados por todas las interfaces menos la de loopback.""" + baja = sube = 0 + try: + with open("/proc/net/dev") as f: + for l in f.readlines()[2:]: + nombre, _, resto = l.partition(":") + if nombre.strip() == "lo": + continue + c = resto.split() + baja += int(c[0]); sube += int(c[8]) + except (OSError, IndexError, ValueError): + return None + previo, self._red_previo = getattr(self, "_red_previo", None), (baja, sube, time.time()) + if not previo: + return {"baja": 0, "sube": 0} + dt = max(self._red_previo[2] - previo[2], 0.001) + return {"baja": max(0, (baja - previo[0]) / dt), + "sube": max(0, (sube - previo[1]) / dt)} + + def _bateria(self): + for n in range(3): + p = _lee(f"/sys/class/power_supply/BAT{n}/capacity") + if p.isdigit(): + return int(p) + return None + + def lee(self) -> dict: + """Las unidades son las que espera el HUD, no las de /proc. + + Se convierte aqui y no en JavaScript porque el formato lo fija la + pantalla: hud.js escribe "GB" y "TB" literalmente al lado del numero + (ponMedidor), asi que mandar megas se ve como "63488/64256 GB" y parece + que el panel esta roto cuando lo que esta mal es quien lo llena. + """ + cpu, nucleos = self._cpu() + mtotal, musada, stotal, susada = self._memoria() + dusado, dtotal = self._disco() + arriba = _lee("/proc/uptime", "0").split()[0] + datos = { + "cpu": cpu, + "nucleos": nucleos, + "ram": {"usada": musada / 1024, "total": mtotal / 1024}, # GB + "swap": {"usada": susada / 1024, "total": max(stotal / 1024, 0.01)}, + "disco": {"usado": dusado / 1000, "total": dtotal / 1000}, # TB + "temperatura": self._temperatura(), + "carga": [float(x) for x in _lee("/proc/loadavg", "0 0 0").split()[:3]], + "uptime": float(arriba) if arriba else 0, # segundos + "procesos": self._procesos(), + } + g = self._grafica() + if g: + datos["gpu"] = {"uso": g["uso"], + "vram_usada": g["usada"] / 1024, # GB + "vram_total": g["total"] / 1024} + red = self._red() + if red: + datos["red"] = red + bat = self._bateria() + if bat is not None: + datos["bateria"] = bat + return datos + + +class Panel: + """El nucleo visto desde la cara.""" + + def __init__(self, cara, boca, manos, cerebro=None, estado_voz=None, + candado=True, centinela=None): + self.cara = cara + self.boca = boca + self.manos = manos + self.cerebro = cerebro + self.centinela = centinela # quien decide si hay que llamarle + self.estado_voz = estado_voz # callable -> "escuchando"|"hablando"|... + self.telemetria = Telemetria() + self._parar = threading.Event() + + # Las tarjetas: una por orden en marcha, con su reloj y su boton de + # parar. El prefijo va vacio porque aqui ya estamos en el host; dentro + # del flatpak era `flatpak-spawn --host --watch-bus`. + from manos.tareas import GestorTareas + self.tareas = GestorTareas(al_cambiar=self._tarea_cambio, prefijo_host=[]) + + # El candado. Tapa el panel y, sobre todo, hace que no obedezca: sin + # esto cualquiera que pase por delante del portatil abierto le puede + # dar ordenes a una maquina con sudo. + # + # La contraseña es la del sistema, comprobada con `sudo -S -v`, que NO + # ejecuta nada: solo valida. Asi no hay una contraseña propia que + # guardar en ningun sitio, que es una cosa menos que se puede filtrar. + self.candado = candado + self.desbloqueado = not candado + self._intentos = 0 + + # Lo que se ha preguntado y esta esperando respuesta hablada. + # (accion, para_que, hasta_cuando). para_que es "argumento" o "confirmar". + self.pendiente = None + + # -------------------------------------------------------------- empujar + + def arranca(self): + threading.Thread(target=self._bucle_telemetria, daemon=True).start() + if self.candado and not self.desbloqueado: + self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"}) + self.manda_catalogo() + self.manda_controles() + self.cara.manda("voz", {"nombre": self.boca.voz, "id": self.boca.voz}) + if self.cerebro: + self.cara.manda("cerebro", {"nombre": "local", "id": "local", + "detalle": self.cerebro.modelo, + "fuera": False}) + + def _bucle_telemetria(self): + tic = 0 + while not self._parar.is_set(): + # Sin nadie mirando no se gasta un nvidia-smi por segundo. + if self.cara.conectados: + try: + self.cara.manda("telemetria", self.telemetria.lee()) + except Exception: + pass + # Las medidas de JARVIS (latencia, reparto, RAG) cambian mucho + # mas despacio que la CPU: se recalculan cada 5 s, no cada uno. + # Salen del diario, que es leer un fichero, no del sistema. + if tic % 5 == 0: + try: + from manos.medidas import lee_medidas + self.cara.manda("medidas", lee_medidas()) + except Exception: + pass + tic += 1 + self._parar.wait(1.0) + + def manda_catalogo(self): + """Lo que sabe hacer, agrupado, para la pestaña de la derecha.""" + grupos = {} + for a in self.manos.catalogo.acciones: + if not a.frases: + continue + grupos.setdefault(a.grupo or "otras", []).append({ + "id": a.id, + "frase": a.frases[0], + "argumento": a.captura, + "confirma": a.confirmar, + }) + datos = [{"grupo": g, "acciones": v} for g, v in + sorted(grupos.items(), key=lambda x: -len(x[1]))] + self.cara.manda("catalogo", datos) + + def _tarea_cambio(self, tarea): + """Llamado desde el hilo de la tarea cada vez que hay algo que enseñar.""" + try: + self.cara.manda("tarea", tarea.resumen()) + except Exception: + pass + + def lanza(self, accion, argumento=""): + """Ejecuta una accion como TAREA, con tarjeta en el panel. + + Se usa para lo que tarda. Lo instantaneo —el disco, la hora— no merece + una tarjeta: aparece, parpadea y se va antes de que nadie la lea. + """ + return self.tareas.lanza(accion, argumento) + + def manda_controles(self): + """El estado real de los interruptores, para que el rotulo no mienta. + + Los botones NO se pintan al pulsarlos: se manda la orden y se espera a + que el nucleo conteste con como quedo. Asi el boton nunca dice + "despierto" con el centinela dormido. + """ + self.cara.manda("controles", { + # encendido = atiende todo; apagado = solo si le llaman + "sueno": not (self.centinela and self.centinela.activo), + }) + + def estado(self, cual: str): + self.cara.manda("estado", cual) + + def dicho(self, texto: str, atendido=True, motivo=""): + """Lo que se ha oido, SIEMPRE, tambien cuando se descarta. + + Esto es lo que faltaba y por lo que el panel parecia muerto: se oia, se + transcribia, y si no llevaba la palabra de activacion se tiraba en + silencio. Desde la pantalla eso es indistinguible de un microfono + estropeado, y no hay forma de saber si el problema es que no te oye, que + te oye mal, o que te oye bien y no te hace caso. + + El diccionario y no la cadena pelada: el HUD lee datos.texto. + """ + self.cara.manda("dicho", {"texto": texto, "atendido": atendido, + "motivo": motivo}) + + def respuesta(self, texto: str): + self.cara.manda("respuesta", {"texto": texto}) + + def para(self): + self._parar.set() + try: + self.tareas.para_todas() + except Exception: + pass + + # --------------------------------------------------------------- pulsar + + def pulsado(self, canal: str, dato: str): + """Lo que llega del panel. Corre en su propio hilo, puede tardar.""" + if canal == "listo": + self.arranca() + return + if canal != "accion": + return + + # Desbloquear es lo unico que se atiende con el candado puesto. + if dato.startswith("desbloquear:"): + self._intenta_desbloquear(dato.split(":", 1)[1]) + return + + # Todo lo demas son ORDENES, y con el candado puesto no hay ordenes. + if self.candado and not self.desbloqueado: + self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"}) + return + + if dato == "voz:siguiente": + nueva = self.boca.siguiente() + self.cara.manda("voz", {"nombre": nueva, "id": nueva}) + self.boca.di("Buenas noches, señor.") + + elif dato == "prueba": + threading.Thread(target=self._tanda_de_prueba, daemon=True).start() + + elif dato.startswith("explica:"): + self._explica(dato.split(":", 1)[1]) + + elif dato.startswith("parar_tarea:"): + self.tareas.para(dato.split(":", 1)[1]) + + elif dato.startswith("cerrar_tarea:"): + ident = dato.split(":", 1)[1] + self.tareas.cierra(ident) + self.cara.manda("tarea_fuera", {"id": ident}) + + elif dato == "sueno": + # Cambia si hace falta llamarle por su nombre. + # + # Por defecto NO hace falta: medido sobre 412 frases reales, el + # ruido ambiente no dispara nada, asi que exigir el nombre en cada + # frase era peaje sin contrapartida. El interruptor se queda para + # cuando haya una tele cerca. + if not self.centinela: + return + self.centinela.activo = not self.centinela.activo + if self.centinela.activo: + self.centinela.duerme() + self.boca.di_si_libre("Llameme por mi nombre, señor.") + else: + self.boca.di_si_libre("Le escucho, señor.") + self.manda_controles() + self.estado("escuchando" if not self.centinela.activo else "dormido") + + elif dato.startswith("parar"): + # Cortar lo que este pasando AHORA: las tareas en marcha y la voz. + n = 0 + for t in self.tareas.todas(): + if t.estado == "corriendo" and self.tareas.para(t.id): + n += 1 + self.boca.di_si_libre(f"Cortadas {n} tareas, señor." if n + else "No habia nada corriendo, señor.") + + # Cuanto se espera la respuesta a una pregunta antes de darla por olvidada. + # Corto cansa —hay que pensar que fichero era—; largo hace que una frase + # suelta media conversacion despues se tome por la respuesta. + ESPERA_S = 45 + + def _explica(self, ident: str): + """Pulsar una accion PREGUNTA lo que falta y la hace. + + No recita la frase que la dispara. "Diga «busca el fichero» y a + continuacion lo que busca" es un manual de instrucciones, y uno pulsa + justo porque no quiere leerse el manual. + + espera argumento -> pregunta cual y lo ejecuta con lo que digas + no tiene vuelta -> pide confirmacion antes + ninguna cosa -> la hace, que es a lo que has pulsado + + Lo destructivo sigue sin dispararse de un clic: pasa por un si o un no + hablado, que es la misma red que tenia antes pero sin obligar a leer. + """ + accion = next((a for a in self.manos.catalogo.acciones if a.id == ident), None) + if accion is None: + return + + if accion.captura: + self._pregunta(accion, "argumento", pregunta_por(accion)) + elif accion.confirmar: + self._pregunta(accion, "confirmar", confirmacion(accion)) + else: + self.tareas.lanza(accion, "") + + def _pregunta(self, accion, para_que, texto, argumento=""): + self._argumento_pendiente = argumento + self.pendiente = (accion, para_que, time.monotonic() + self.ESPERA_S, + getattr(self, "_argumento_pendiente", "")) + self.cara.manda("preguntando", {"texto": texto, "accion": accion.id}) + self.cara.manda("respuesta", {"texto": texto}) + self.boca.di(texto) + + def esperando(self): + """La pregunta en el aire, o None si no hay o ya caduco.""" + if not self.pendiente: + return None + if time.monotonic() > self.pendiente[2]: + self.olvida() + return None + return self.pendiente + + def olvida(self): + self.pendiente = None + self.cara.manda("preguntando", {"texto": ""}) + + def responde(self, texto: str) -> bool: + """Contesta a lo preguntado. Devuelve si consumio la frase. + + Se llama desde el bucle de voz ANTES del emparejador: mientras hay una + pregunta en el aire, lo que se dice es la respuesta, no una orden nueva. + """ + pend = self.esperando() + if not pend: + return False + accion, para_que, _, argumento = pend + + if para_que == "confirmar": + respuesta = es_si(texto) + if respuesta is None: + self.boca.di("No le he entendido. ¿Si o no, señor?") + return True + self.olvida() + if respuesta: + self.tareas.lanza(accion, argumento) + else: + self.boca.di("De acuerdo, lo dejo.") + return True + + # argumento: cualquier cosa que no sea una cancelacion vale + if es_si(texto) is False: + self.olvida() + self.boca.di("De acuerdo, lo dejo.") + return True + self.olvida() + arg = texto.strip() + + # Si ademas no tiene vuelta atras, se confirma AHORA que ya se sabe + # sobre que. Hay siete acciones que piden argumento y confirmacion + # —"mata el proceso", "que puertos tiene abiertos"— y preguntando solo + # el argumento se saltaban la red entera. Y confirmar antes de saber el + # argumento seria pedir un cheque en blanco: "¿mato el proceso?" "¿cual?" + if accion.confirmar: + self._pregunta(accion, "confirmar", + f"«{accion.frases[0]} {arg}». Esto no tiene vuelta " + f"atras. ¿Confirma, señor?", argumento=arg) + return True + + self.tareas.lanza(accion, arg) + return True + + def _intenta_desbloquear(self, clave: str): + if not clave: + return + # La misma GestorRoot de las manos: si la contraseña es buena, se queda + # ahi y el cerebro puede hacer sudo durante la sesion. Desbloquear el + # panel y abrir el root son el mismo gesto, con la misma contraseña. + try: + valida = self.manos.root.valida(clave) + except Exception as e: + self.cara.manda("bloqueo", {"mensaje": f"No se pudo comprobar: {e}", + "error": True}) + return + + if not valida: + self._intentos += 1 + # Sin bloqueo por intentos ni espera creciente: la contraseña es la + # del sistema y quien la teclea esta sentado delante del portatil. + # Contarlos sirve para que el mensaje sea util, no para castigar. + self.cara.manda("bloqueo", { + "mensaje": "No es esa" + (f" ({self._intentos} intentos)" + if self._intentos > 1 else ""), + "error": True}) + return + + self._intentos = 0 + self.desbloqueado = True + self.manos.root.recuerda(clave) # root disponible para la sesion + self.cara.manda("desbloqueado", True) + self.boca.di_si_libre("A su servicio, señor.") + + def bloquea(self): + """Echa el candado, aunque se arrancara sin el. + + Que `--sin-candado` sea un arranque comodo no significa que uno no + pueda querer cerrarlo al levantarse de la silla. Por eso esto ACTIVA el + candado en vez de comprobar si estaba activo: negarse a cerrar porque + no estaba puesto seria justo lo contrario de lo que se pide. + """ + self.candado = True + self.desbloqueado = False + self._intentos = 0 + self.manos.root.olvida() # y se cierra el root con el candado + self.cara.manda("bloqueo", {"mensaje": "Introduzca la contraseña"}) + + # Elegidas para que se vea el ciclo entero: una que acaba al instante, dos + # que tardan un poco, y una larga a la que da tiempo a pulsarle el boton de + # parar. Todas de solo lectura. + # Cada una comprobada contra el catalogo: una que no encaje no lanza + # tarjeta y la tanda queda coja sin decir por que. + PRUEBA = ["cuanto espacio queda", "cuanta memoria", "que temperatura hay", + "que esta consumiendo", "cuantos procesos hay"] + + def _tanda_de_prueba(self): + """Lanza varias a la vez para ver las tarjetas trabajando. + + Van como TAREAS y no ejecutadas a pelo: el objetivo es justo ver el + ciclo entero —aparecer, contar segundos, terminar— y poder pulsarle el + boton de parar a la que tarda. + """ + for orden in self.PRUEBA: + accion, argumento = self.manos.busca(orden) + if accion is None: + continue + self.tareas.lanza(accion, argumento) + time.sleep(0.25) diff --git a/nucleo/cara/servidor.py b/nucleo/cara/servidor.py new file mode 100644 index 0000000..c5ab75a --- /dev/null +++ b/nucleo/cara/servidor.py @@ -0,0 +1,173 @@ +"""La cara: sirve el HUD y lo mantiene al dia por WebSocket. + +## Por que un servidor y no una ventana + +En Newelle el HUD era un widget WebKit dentro de la aplicacion. Sacandolo a un +servidor local se gana mas de lo que parece: + + - el panel es una pagina normal: se abre con `chromium --app=`, en cualquier + navegador, o desde el movil o una tablet en la misma red; + - se puede mirar el diseño sin arrancar nada, que es como se ha trabajado en + el todo el tiempo; + - desaparece GTK de la cara, que era la ultima atadura al framework. + +El formato de mensajes no cambia —{tipo, datos}, igual que empujaba Python por +evaluate_javascript— asi que `hud.js` se queda sin tocar. Lo unico nuevo es +`puente.js`, que suplanta `window.webkit` con el socket. + +## Escucha solo en local + +127.0.0.1 a proposito. Este panel ejecuta ordenes en la maquina: abrirlo a la +red seria dar una terminal a cualquiera del wifi. Para verlo desde el movil, un +tunel ssh, que ademas obliga a autenticarse. +""" +import asyncio +import json +import mimetypes +import os +import threading + +HUD = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hud") +PUERTO = int(os.environ.get("JARVIS_PUERTO", "8790")) + +FICHEROS = { + "/": "index.html", + "/index.html": "index.html", + "/hud.js": "hud.js", + "/puente.js": "puente.js", + "/style.css": "style.css", +} + + +class Cara: + """El panel: se le empujan eventos y devuelve lo que se pulsa.""" + + def __init__(self, al_pulsar=None, puerto=PUERTO): + self.al_pulsar = al_pulsar # (canal, dato) -> None + self.puerto = puerto + self._clientes = set() + self._bucle = None + self._hilo = None + self._ultimo = {} # el estado, para el que llegue tarde + + # ------------------------------------------------------------- arrancar + + def arranca(self): + """Levanta el servidor en un hilo aparte y vuelve enseguida. + + Devuelve la URL, o None si no se pudo. El motivo mas comun es que ya + haya otro JARVIS abierto: sin este manejo, el fallo salia como una traza + de asyncio dentro de un hilo, con "address already in use" enterrado en + la linea veinte, y el proceso seguia vivo sin panel. + """ + listo = threading.Event() + + def corre(): + self._bucle = asyncio.new_event_loop() + asyncio.set_event_loop(self._bucle) + try: + self._bucle.run_until_complete(self._sirve(listo)) + except OSError as e: + self._error = e + listo.set() + return + self._bucle.run_forever() + + self._error = None + self._hilo = threading.Thread(target=corre, daemon=True) + self._hilo.start() + listo.wait(timeout=10) + if self._error is not None: + import errno + if self._error.errno == errno.EADDRINUSE: + print(f" el puerto {self.puerto} ya esta cogido: ¿hay otro " + f"JARVIS abierto? (arranca.sh --stop, o JARVIS_PUERTO=otro)") + else: + print(f" no se pudo levantar el panel: {self._error}") + return None + return f"http://127.0.0.1:{self.puerto}/" + + async def _sirve(self, listo): + import websockets + from websockets.http11 import Response + from websockets.datastructures import Headers + + async def http(conexion, peticion): + """Sirve los ficheros del HUD; deja pasar el WebSocket.""" + ruta = peticion.path.split("?")[0] + if ruta == "/puente": + return None # que siga el apreton de manos + nombre = FICHEROS.get(ruta) + if not nombre: + return Response(404, "Not Found", Headers(), b"no esta\n") + try: + with open(os.path.join(HUD, nombre), "rb") as f: + cuerpo = f.read() + except OSError: + return Response(404, "Not Found", Headers(), b"no esta\n") + tipo = mimetypes.guess_type(nombre)[0] or "application/octet-stream" + cabeceras = Headers({ + "Content-Type": tipo + ("; charset=utf-8" if "text" in tipo + or "javascript" in tipo else ""), + "Content-Length": str(len(cuerpo)), + # Sin cache: el HUD se edita en caliente y recargar tiene que + # traer lo nuevo, no lo de hace media hora. + "Cache-Control": "no-store", + }) + return Response(200, "OK", cabeceras, cuerpo) + + self._servidor = await websockets.serve( + self._cliente, "127.0.0.1", self.puerto, process_request=http) + listo.set() + + # -------------------------------------------------------------- clientes + + async def _cliente(self, ws): + self._clientes.add(ws) + try: + # Al conectar se le manda el estado que ya habia. Sin esto, abrir el + # panel a media sesion enseña un HUD vacio hasta que algo cambie. + for mensaje in self._ultimo.values(): + await ws.send(json.dumps(mensaje)) + async for crudo in ws: + try: + m = json.loads(crudo) + except json.JSONDecodeError: + continue + if self.al_pulsar: + # En otro hilo: atender una pulsacion puede tardar (hablar, + # cambiar de voz, lanzar una tarea) y el bucle del socket no + # puede quedarse esperando o el panel se congela entero. + threading.Thread( + target=self.al_pulsar, + args=(m.get("canal", ""), m.get("dato", "")), + daemon=True).start() + except Exception: + pass + finally: + self._clientes.discard(ws) + + # --------------------------------------------------------------- empujar + + def manda(self, tipo: str, datos): + """Empuja un evento al panel. Se puede llamar desde cualquier hilo.""" + mensaje = {"tipo": tipo, "datos": datos} + # Se recuerda lo ultimo de cada tipo salvo lo que caduca al instante: + # el pulso de la voz a 15 por segundo no tiene sentido reenviarlo. + if tipo not in ("pulso",): + self._ultimo[tipo] = mensaje + if not self._bucle or not self._clientes: + return + crudo = json.dumps(mensaje, ensure_ascii=False) + asyncio.run_coroutine_threadsafe(self._reparte(crudo), self._bucle) + + async def _reparte(self, crudo): + for ws in list(self._clientes): + try: + await ws.send(crudo) + except Exception: + self._clientes.discard(ws) + + @property + def conectados(self) -> int: + return len(self._clientes) diff --git a/nucleo/cerebro/Modelfile b/nucleo/cerebro/Modelfile new file mode 100644 index 0000000..750d0cf --- /dev/null +++ b/nucleo/cerebro/Modelfile @@ -0,0 +1,26 @@ +# El modelo de JARVIS: qwen3.5 4B con parametros de asistente CONCISO. +# +# La build de qwen3.5 viene con temperature 1 y presence_penalty 1.5. Eso +# esta pensado para escritura creativa, no para un asistente de voz: el +# presence_penalty alto empuja al modelo a meter temas nuevos que no vienen a +# cuento —de ahi que acabara las frases soltando cuanta RAM queda— y la +# temperature alta lo hace divagar en vez de contestar y callar. +# +# Reconstruir: ollama create qwen3.5:4b-jarvis -f nucleo/cerebro/Modelfile + +FROM qwen3.5:4b + +# Bajo y estable: para "cuanto espacio queda" no hay creatividad que aportar, +# hay una cifra que dar. +PARAMETER temperature 0.4 +# El que causaba el relleno. 0 = no penaliza repetir, asi que no siente la +# necesidad de introducir temas nuevos para variar. +PARAMETER presence_penalty 0.0 +PARAMETER frequency_penalty 0.0 +PARAMETER top_p 0.9 +PARAMETER top_k 20 +# Que pare cuando acabe la respuesta, no cuando se le acabe la cuerda. +PARAMETER num_predict 300 +# 24 de 32 capas en la grafica: deja sitio a whisper en los 4 GB. +PARAMETER num_gpu 24 +PARAMETER num_ctx 4096 diff --git a/nucleo/cerebro/__init__.py b/nucleo/cerebro/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nucleo/cerebro/ollama.py b/nucleo/cerebro/ollama.py new file mode 100644 index 0000000..cb968d1 --- /dev/null +++ b/nucleo/cerebro/ollama.py @@ -0,0 +1,316 @@ +"""Pensar: hablar con ollama y dejarle usar las manos. + +Newelle dedica 578 lineas a esto porque soporta quince motores distintos y +tiene que traducir el formato de herramientas de cada uno. Nosotros hablamos +solo con ollama, que trae tool calling nativo en `/api/chat`. + +## Dos herramientas, no una + +La primera version tenia una sola —"ejecuta una orden"— y salio mal de una +forma instructiva. Medido con tres modelos: + + 4B "abre una terminal" -> ejecuta_orden("xterm -e bash") + 9B "cuanto espacio queda" -> ejecuta_orden("df -h / | grep ...") + 9B "abre una terminal" -> ejecuta_orden("open -a Terminal") (¡macOS!) + +Los modelos son serviciales: si les dejas un hueco donde cabe un comando, lo +escriben. Y el 9B, que es mas listo, lo hacia MAS —escribia pipelines enteras y +hasta ordenes de otro sistema operativo—. + +La solucion no es rogarle al modelo en el prompt, es que el hueco no quepa. Se +parten en dos herramientas con fronteras claras: + + `orden_del_catalogo` recibe castellano, se lo come el emparejador. La + descripcion lleva ejemplos REALES sacados del catalogo, + no inventados, y dice explicitamente que no acepta + comandos. + `comando_de_shell` para lo que no esta en el catalogo. Aqui SI se espera + un comando, asi que el modelo tiene donde poner lo que + queria poner, y deja de meterlo donde no toca. + +## Por que el catalogo va antes que esto + +Las 150 acciones se emparejan ANTES de llamar a ningun modelo. Esto solo se +usa para lo que no encaja —alrededor del 15 %— y para conversar. Por eso un +cerebro lento duele menos de lo que parece: la mayoria de las ordenes ni pasan +por aqui. +""" +import json +import os +import urllib.error +import urllib.request + +ENDPOINT = os.environ.get("JARVIS_OLLAMA", "http://127.0.0.1:11434") +MODELO = os.environ.get("JARVIS_MODELO", "qwen3.5:4b-jarvis") + +# Cuantas vueltas de "llama a una herramienta -> toma el resultado" se permiten +# antes de cortar. Sin tope, un modelo que se atasca pidiendo lo mismo deja al +# usuario esperando para siempre. +VUELTAS = 4 + +PERSONA = """Eres JARVIS, el asistente de voz de esta maquina. + +REGLA QUE NO SE ROMPE NUNCA: no des ni un solo dato sobre este ordenador sin +haberlo comprobado antes con una herramienta. Ni el espacio del disco, ni la +memoria, ni cuantos ficheros hay, ni si algo esta abierto, ni la hora. Aunque +creas saberlo. Aunque parezca obvio. Si no lo has comprobado en esta misma +conversacion, lo compruebas. + +Inventarse una cifra que suena razonable es el peor fallo que puedes cometer, +porque el usuario no tiene forma de saber que te la has inventado. + +Y AL REVES: contesta SOLO lo que te preguntan. No añadas al final de tus +respuestas datos del sistema que nadie ha pedido —cuanta RAM queda, el espacio +del disco, la hora—. Si te preguntan como estas, "bien, señor" y punto; no lleva +un informe de memoria detras. + +Para hacer algo, el orden es: +1. orden_del_catalogo, siempre que lo que te piden se parezca a algo de la + lista. Es lo que JARVIS sabe hacer bien y esta probado. +2. comando_de_shell solo si de verdad no esta en el catalogo. + +Si te preguntan COMO se hace algo tecnico —una tecnica de pentesting, un flag, +una herramienta— y no lo tienes claro o no aparece en la documentacion de abajo, +usa buscar_en_apuntes con palabras clave ANTES de contestar. Ahi estan los +comandos que el usuario ya ha probado; es mejor buscarlos que inventarlos. Si la +primera busqueda no da, reformula y prueba otra vez. + +Tu respuesta se convierte en AUDIO y se escucha en voz alta: +- Castellano siempre. Trata al usuario de usted y llamale "señor" en la primera + frase. +- Una o dos frases. Nada mas, salvo que te pidan detalle. +- Texto plano: ni asteriscos, ni vinetas, ni bloques de codigo, ni rutas largas. +- Los numeros redondeados y en palabras: "quedan setecientos gigas". +- Si algo falla, di en una frase QUE fallo. Sin disculpas de tres lineas. + +Tienes acceso real a esta maquina y no estas en ningun contenedor. No digas +nunca que no puedes. En concreto, con comando_de_shell puedes: +- Ejecutar cualquier herramienta instalada, incluidas las de COFRE. Si no + recuerdas como se usa una, en la documentacion del usuario de abajo suele + estar el comando exacto. +- Conectarte a sus servidores: "ssh ''" corre algo en el + servidor. Sus hosts estan en su configuracion; abajo veras cual encaja. +- Tareas de administrador, SOLO si el candado esta abierto. Si algo lo necesita + y no lo esta, dilo: que desbloquee y lo repites. +Antes de nada que borre datos o cambie el sistema, di lo que vas a hacer.""" + + +def _ejemplos(catalogo, cuantos=8) -> str: + """Ordenes de verdad del catalogo, repartidas entre grupos. + + Van en la descripcion de la herramienta porque es lo unico que consigue que + el modelo pase castellano en vez de inventarse un comando. Salen del + catalogo y no escritas a mano para que no se queden viejas. + """ + por_grupo = {} + for a in catalogo.acciones: + if a.frases and not a.captura: + por_grupo.setdefault(a.grupo or "otras", []).append(a.frases[0]) + muestra = [] + grupos = list(por_grupo.values()) + for i in range(cuantos): + for g in grupos: + if i < len(g) and len(muestra) < cuantos: + muestra.append(g[i]) + return ", ".join(f'"{m}"' for m in muestra) + + +def herramientas(catalogo) -> list: + return [ + { + "type": "function", + "function": { + "name": "orden_del_catalogo", + "description": ( + "Ejecuta una de las ordenes que JARVIS ya sabe hacer. " + "Recibe la orden EN CASTELLANO, tal y como la diria una " + "persona. NO acepta comandos de shell: si escribes 'df -h' " + "o 'xterm' falla. Ejemplos validos: " + + _ejemplos(catalogo) + ". " + "Usala siempre que puedas antes que el shell." + ), + "parameters": { + "type": "object", + "properties": { + "orden": { + "type": "string", + "description": ("la orden en castellano, en palabras, " + "nunca un comando"), + } + }, + "required": ["orden"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "buscar_en_apuntes", + "description": ( + "Busca en los apuntes de pentesting y Linux del usuario (su " + "COFRE) el comando o la explicacion que necesites. Usala " + "cuando te pregunten COMO se hace algo tecnico —una tecnica, " + "un flag, una herramienta— y no lo tengas ya delante en la " + "documentacion. Puedes reformular la consulta con tus propias " + "palabras clave y buscar varias veces hasta dar con ello. " + "Devuelve los fragmentos mas parecidos, con su fuente, para " + "que respondas desde ahi y no de memoria." + ), + "parameters": { + "type": "object", + "properties": { + "consulta": { + "type": "string", + "description": ("que buscar, en pocas palabras clave; " + "el nombre de la herramienta ayuda"), + } + }, + "required": ["consulta"], + }, + }, + }, + { + "type": "function", + "function": { + "name": "comando_de_shell", + "description": ( + "Ejecuta un comando en la terminal de esta maquina, que es " + "Debian con GNOME. Solo para lo que NO esta en el catalogo. " + "Es de solo lectura: no ejecutes nada que borre o modifique " + "sin que te lo pidan expresamente." + ), + "parameters": { + "type": "object", + "properties": { + "comando": {"type": "string", + "description": "el comando, para bash"}, + }, + "required": ["comando"], + }, + }, + }, + ] + + +class Cerebro: + def __init__(self, catalogo, modelo=MODELO, endpoint=ENDPOINT, en_ram=False, + manos=None): + self.catalogo = catalogo + self.modelo = modelo + self.endpoint = endpoint + # num_gpu 0 fuerza CPU: el modelo entero en RAM. Util para probar uno + # mas grande del que cabe en la grafica, a costa de velocidad. + self.capas = 0 if en_ram else None + self.manos = manos # a quien pedirle que ejecute + self.historia = [] + + def _saber(self, texto: str) -> str: + """Los apuntes de COFRE que vengan a cuento, para meterlos al prompt. + + Es la mitad "aumentar" de RAG: antes de contestar, se le pone delante al + modelo el trozo de los apuntes del usuario que mas se parece a lo que + pregunta. Asi construye la respuesta desde SU documentacion —flags + reales, comandos que ha probado— en vez de desde lo que recuerde, que + para 668 herramientas seria inventarselo. + + Si no hay indice o nada encaja lo bastante, devuelve cadena vacia y el + prompt se queda como estaba: la funcion no puede empeorar una respuesta, + solo mejorarla. + """ + try: + from saber.busca import contexto, disponible + except Exception: + return "" + if not disponible(): + return "" + trozos = contexto(texto, cuantos=4, minimo=0.38) + if not trozos: + return "" + return ("\n\n## Documentacion del usuario relevante a esto\n" + "Usa ESTO para responder, son sus apuntes probados. Si el " + "comando exacto esta aqui, dalo tal cual; no te inventes flags " + "que no aparezcan.\n\n" + trozos) + + def _pide(self, mensajes, tools=None) -> dict: + cuerpo = {"model": self.modelo, "messages": mensajes, "stream": False, + "think": False, "options": {"temperature": 0.3}} + if tools: + cuerpo["tools"] = tools + if self.capas is not None: + cuerpo["options"]["num_gpu"] = self.capas + req = urllib.request.Request( + self.endpoint + "/api/chat", data=json.dumps(cuerpo).encode(), + headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=300) as r: + return json.loads(r.read()) + + def responde(self, texto: str, al_ejecutar=None) -> str: + """Contesta, usando las manos si hace falta. Devuelve lo que hay que decir.""" + self.historia.append({"role": "user", "content": texto}) + sistema = PERSONA + self._saber(texto) + mensajes = [{"role": "system", "content": sistema}] + self.historia[-8:] + tools = herramientas(self.catalogo) + + for _ in range(VUELTAS): + try: + d = self._pide(mensajes, tools) + except (urllib.error.URLError, OSError) as e: + return f"No he podido pensar, señor: {str(e)[:50]}" + + m = d.get("message", {}) + llamadas = m.get("tool_calls") or [] + if not llamadas: + dicho = (m.get("content") or "").strip() + self.historia.append({"role": "assistant", "content": dicho}) + return dicho + + mensajes.append(m) + for lc in llamadas: + f = lc.get("function", {}) + args = f.get("arguments") or {} + if isinstance(args, str): + try: + args = json.loads(args) + except json.JSONDecodeError: + args = {} + resultado = self._usa(f.get("name", ""), args, al_ejecutar) + mensajes.append({"role": "tool", "content": resultado[:2000]}) + + return "Me he liado dando vueltas, señor. Pruebe a pedirmelo de otra forma." + + def _usa(self, nombre: str, args: dict, al_ejecutar=None) -> str: + # buscar_en_apuntes NO pasa por las manos: es lectura del indice, no + # toca la maquina, asi que funciona aunque el candado este echado. + if nombre == "buscar_en_apuntes": + return self._busca_apuntes(args.get("consulta", "")) + if self.manos is None: + return "no hay manos conectadas" + if nombre == "orden_del_catalogo": + return self.manos.por_orden(args.get("orden", ""), al_ejecutar) + if nombre == "comando_de_shell": + return self.manos.por_shell(args.get("comando", ""), al_ejecutar) + return f"no existe la herramienta {nombre}" + + def _busca_apuntes(self, consulta: str) -> str: + """El RAG como herramienta: el modelo busca cuando lo decide. + + La inyeccion pasiva de _saber() usa la frase LITERAL del usuario y + dispara siempre. Esto es lo otro: el modelo reformula la consulta con + sus palabras y busca a proposito, que es lo que salva los casos donde el + usuario dice una cosa y el apunte esta escrito de otra.""" + consulta = (consulta or "").strip() + if not consulta: + return "dime que buscar" + try: + from saber.busca import busca + except Exception: + return "no tengo los apuntes indexados" + trozos = [t for t in busca(consulta, cuantos=5) if t.get("sim", 0) >= 0.3] + if not trozos: + return ("no encontre nada en los apuntes sobre eso; prueba con otras " + "palabras o el nombre de la herramienta") + lineas = [] + for t in trozos: + fuente = t.get("fichero") or f"{t.get('perfil')}/{t.get('herramienta')}" + lineas.append(f"[{fuente}]\n{t['texto'][:500]}") + return "\n\n".join(lineas) diff --git a/nucleo/datos/_generar.py b/nucleo/datos/_generar.py new file mode 100644 index 0000000..5579f2c --- /dev/null +++ b/nucleo/datos/_generar.py @@ -0,0 +1,468 @@ +#!/usr/bin/env python3 +"""Genera acciones.json. + +Se escribe aqui y no a mano en el JSON porque son mas de cien entradas con la +misma forma: en tabla se leen de un tirón y se ve si falta un acuse o si dos +frases chocan. El JSON es el producto; este fichero es la fuente. + + python3 _generar.py && python3 -m json.tool acciones.json >/dev/null + +Columnas: id | frases (| separadas) | acuse | comando | respuesta + +Reglas que conviene no romper: + - Las frases van sin tildes ni signos: el motor normaliza lo que oye, pero + aqui se comparan tal cual. + - Frases cortas y genericas ("abre") arrastran todo lo demas; se prefieren + de tres palabras o mas, que es como se habla de verdad. + - Todo lo que borre, mate o apague lleva confirmar=True. + - {argumento} solo en las que declaran captura. +""" +import json +import pathlib + +# --------------------------------------------------------------- sistema +SISTEMA = [ + ("disco_libre", "cuanto espacio queda|espacio en disco|espacio libre en disco|cuanto disco queda", + "Un momento, lo miro.", "df -h / | tail -1", + "Quedan {campo4} libres de {campo2}, al {campo5} de uso."), + ("disco_home", "como esta mi disco|cuanto ocupa mi disco|espacio de mi disco", + "Un momento.", "df -h ~ | tail -1", + "Su disco esta al {campo5}, con {campo4} libres de {campo2}."), + ("disco_todos", "discos montados|particiones|unidades montadas", + "Enseguida.", "df -h --output=target,size,pcent -x tmpfs -x devtmpfs | tail -n +2", + "Hay {lineas} unidades montadas. {salida}"), + ("memoria", "cuanta memoria|memoria libre|cuanta ram|ram libre", + "Voy.", "free -g | awk '/Mem:/{print $2, $3, $7}'", + "De {campo1} gigas hay {campo2} en uso y {campo3} disponibles."), + ("swap", "como esta la swap|memoria de intercambio", + "Voy.", "free -g | awk '/Swap:/{print $2, $3}'", + "Swap: {campo2} gigas usados de {campo1}."), + ("cpu_carga", "carga del sistema|como va el procesador|carga de cpu", + "Un segundo.", "uptime | sed 's/.*average: //'", + "La carga media es {ultima}."), + ("cpu_modelo", "que procesador tengo|modelo de procesador|que cpu tengo", + "Enseguida.", "lscpu | grep 'Model name' | cut -d: -f2 | xargs", + "Lleva un {ultima}."), + ("cpu_nucleos", "cuantos nucleos tengo|cuantos hilos tengo", + "Voy.", "nproc", "Tiene {ultima} hilos disponibles."), + ("temperatura", "que temperatura hay|como esta de temperatura|se esta calentando", + "Lo compruebo.", "for z in /sys/class/thermal/thermal_zone*/temp; do cat $z; done | sort -rn | head -1 | awk '{printf \"%.0f\", $1/1000}'", + "El punto mas caliente esta a {ultima} grados."), + ("gpu_estado", "como esta la grafica|estado de la gpu|uso de la grafica", + "Un momento.", + "nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader", + "La grafica esta asi: {ultima}."), + ("uptime", "cuanto lleva encendido|desde cuando esta encendido|tiempo encendido", + "Voy.", "uptime -p | sed 's/^up //; s/ days\\?/ dias/; s/ hours\\?/ horas/; s/ minutes\\?/ minutos/; s/,\\([^,]*\\)$/ y\\1/'", + "Lleva encendido {ultima}."), + ("bateria", "cuanta bateria queda|como esta la bateria|nivel de bateria", + "Lo miro.", "cat /sys/class/power_supply/BAT*/capacity 2>/dev/null | head -1", + "La bateria esta al {ultima} por ciento."), + ("kernel", "que kernel tengo|version del kernel|que nucleo tengo", + "Enseguida.", "uname -r", "Kernel {ultima}."), + ("distro", "que distribucion tengo|que sistema tengo|que linux tengo", + "Voy.", "grep PRETTY_NAME /etc/os-release | cut -d'\"' -f2", + "Esta usando {ultima}."), + ("fecha", "que hora es|que dia es|dime la fecha", + "", "date '+%H:%M del %A %d de %B'", "Son las {salida}."), + ("procesos_top", "que esta consumiendo|que consume mas|procesos que mas gastan|quien se come la cpu", + "Lo compruebo.", "ps -eo pcpu,comm --sort=-pcpu --no-headers | head -5 | awk '{c=$1; $1=\"\"; sub(/^ +/,\"\"); printf \"%s al %s por ciento\\n\", $0, c}'", + "Lo que mas consume es {primera}."), + ("procesos_memoria", "que consume mas memoria|quien se come la ram", + "Lo compruebo.", "ps -eo comm,pmem --sort=-pmem --no-headers | head -5", + "Por memoria: {salida}"), + ("procesos_cuantos", "cuantos procesos hay|cuantos procesos corren", + "Voy.", "ps -e --no-headers | wc -l", "Hay {ultima} procesos en marcha."), + ("red_ip", "cual es mi ip|mi ip local|que ip tengo", + "Un momento.", "hostname -I | awk '{print $1}'", "Su IP local es {ultima}."), + ("red_ip_publica", "cual es mi ip publica|ip externa", + "Salgo a mirarlo.", "curl -s --max-time 6 ifconfig.me", "Su IP publica es {ultima}."), + ("red_wifi", "a que wifi estoy conectado|red wifi|como se llama la wifi", + "Voy.", "nmcli -t -f NAME,TYPE connection show --active | grep wireless | cut -d: -f1", + "Conectado a {ultima}."), + ("red_velocidad", "cuanto trafico de red|trafico de red", + "Lo miro.", "cat /proc/net/dev | awk '/wlp|enp/{rx+=$2; tx+=$10} END{printf \"%.1f %.1f\", rx/1e9, tx/1e9}'", + "Desde el arranque: {campo1} gigas bajados y {campo2} subidos."), + ("red_ping", "hay internet|tengo conexion|funciona internet", + "Lo compruebo.", + "ping -c1 -W2 1.1.1.1 >/dev/null 2>&1 && echo si || echo no", + "Conexion: {ultima}."), + ("puertos", "que puertos hay abiertos|puertos a la escucha|quien esta escuchando", + "Un momento.", "ss -ltnp 2>/dev/null | awk 'NR>1{print $4}' | sort -u | head -8", + "A la escucha: {salida}"), + ("servicios_fallidos", "hay servicios caidos|servicios con fallos|algo ha fallado", + "Lo reviso.", "systemctl --failed --no-legend | wc -l", + "Servicios en fallo: {ultima}."), + ("actualizaciones", "hay actualizaciones|paquetes por actualizar", + "Lo compruebo, tarda un poco.", + "apt list --upgradable 2>/dev/null | tail -n +2 | wc -l", + "Hay {ultima} paquetes por actualizar."), + ("usuarios", "quien esta conectado|hay alguien conectado", + "Voy.", "who | wc -l", "Hay {ultima} sesiones abiertas."), + ("gpu_procesos", "que usa la grafica|quien usa la gpu", + "Lo miro.", "O=$(nvidia-smi --query-compute-apps=process_name,used_gpu_memory --format=csv,noheader | awk -F, '{n=split($1,p,\"/\"); printf \"%s%s\\n\", p[n], $2}'); echo \"${O:-nada, la grafica esta libre}\"", + "En la grafica: {primera}"), + ("audio_salida", "por donde suena|salida de audio|que altavoz esta puesto", + "Voy.", "pactl get-default-sink 2>/dev/null", "La salida es {ultima}."), + ("audio_micro", "que microfono hay puesto|entrada de audio", + "Voy.", "pactl get-default-source 2>/dev/null", "La entrada es {ultima}."), + ("pantallas", "cuantas pantallas hay|monitores conectados", + "Voy.", "n=$(xrandr --listmonitors 2>/dev/null | head -1 | awk '{print $2}'); xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $NF}' | paste -sd', '; echo \"Tiene $n pantalla$([ \"$n\" = 1 ] || echo s) conectada$([ \"$n\" = 1 ] || echo s)\"", + "{ultima}"), + ("dispositivos_usb", "que hay conectado por usb|dispositivos usb", + "Un momento.", "lsusb | cut -d' ' -f7- | head -6", "Por USB: {salida}"), + ("bluetooth", "que hay emparejado por bluetooth|dispositivos bluetooth", + "Voy.", "bluetoothctl devices 2>/dev/null | cut -d' ' -f3- | head -5", + "Emparejados: {salida}"), + ("flatpaks", "que flatpaks tengo|aplicaciones flatpak", + "Voy.", "flatpak list --app --columns=name | head -10", "Instalados: {salida}"), + ("docker", "hay contenedores corriendo|contenedores docker", + "Lo miro.", "O=$(docker ps --format '{{.Names}}' 2>/dev/null | head -5); echo \"${O:-ninguno en marcha}\"", + "Contenedores: {salida}"), + ("git_estado", "como esta el repositorio|estado de git", + "Voy.", "cd ~/COFRE/CODERS/JARVIS && git status --short | wc -l", + "Hay {ultima} ficheros con cambios sin guardar."), + ("ollama_estado", "esta ollama funcionando|como esta el modelo|que modelo hay cargado", + "Lo compruebo.", + "M=$(curl -s --max-time 3 http://localhost:11434/api/ps | grep -o '\"name\":\"[^\"]*' | cut -d'\"' -f4 | head -1); echo \"${M:-ninguno ahora mismo}\"", + "Modelo cargado: {ultima}."), + ("apagar_pantalla", "apaga la pantalla|apagar pantalla", + "Enseguida.", "xset dpms force off", "Pantalla apagada."), + # --- administracion: necesitan root, disparan el dialogo de contraseña --- + # (Las de pentesting van en su propia lista, PENTEST, mas abajo.) + ("actualizar_lista", "actualiza los repositorios|refresca los paquetes|actualiza la lista de paquetes", + "Voy, necesito permiso de administrador.", "sudo apt update", + "Repositorios actualizados, señor.", True), + ("espacio_liberar", "limpia los paquetes viejos|libera espacio de apt|limpia el cache de paquetes", + "Necesito permiso para esto.", "sudo apt autoclean", + "Cache de paquetes limpiada, señor.", True), + ("servicio_reiniciar", "reinicia el servicio de red|reinicia la red", + "Necesito permiso de administrador.", "sudo systemctl restart NetworkManager", + "Red reiniciada, señor.", True), + ("fecha_sincronizar", "sincroniza la hora|pon la hora en hora", + "Un momento, con permiso.", "sudo systemctl restart systemd-timesyncd", + "Hora sincronizada, señor.", True), + ("bloquear", "bloquea la sesion|bloquear pantalla|echa el cerrojo", + "Ahora mismo.", "loginctl lock-session", "Sesion bloqueada."), + ("volumen_subir", "sube el volumen|mas volumen", + "", "pactl set-sink-volume @DEFAULT_SINK@ +10%", "Volumen subido."), + ("volumen_bajar", "baja el volumen|menos volumen", + "", "pactl set-sink-volume @DEFAULT_SINK@ -10%", "Volumen bajado."), + ("volumen_silencio", "silencia el sistema|quita el sonido|mutea el sistema", + "", "pactl set-sink-mute @DEFAULT_SINK@ toggle", "Sonido conmutado."), + ("brillo_subir", "sube el brillo|mas brillo", + "", "brightnessctl set +10% 2>/dev/null || xrandr --output $(xrandr | awk '/ connected/{print $1; exit}') --brightness 1", + "Brillo subido."), + ("captura_pantalla", "hazme una captura|captura de pantalla|haz una foto de la pantalla", + "Enseguida se la hago.", "D=$(xdg-user-dir PICTURES 2>/dev/null || echo ~); F=$D/captura-$(date +%H%M%S).png; import -window root \"$F\" 2>/dev/null || gnome-screenshot -f \"$F\" 2>/dev/null || ffmpeg -loglevel error -f x11grab -video_size $(xdpyinfo | awk \"/dimensions/{print \\$2}\") -i $DISPLAY -frames:v 1 -y \"$F\" 2>/dev/null; [ -s \"$F\" ] && echo \"$F\"", + "Captura guardada."), + ("papelera_tamano", "cuanto ocupa la papelera|papelera llena", + "Voy.", "du -sh ~/.local/share/Trash 2>/dev/null | cut -f1", + "La papelera ocupa {ultima}."), + ("descargas_listar", "que hay en descargas|ultimas descargas|que me he bajado", + "Voy.", "ls -t ~/Downloads 2>/dev/null | head -6", "Hay {lineas} cosas en descargas"), + ("descargas_abrir", "abre la carpeta de descargas|abreme descargas", + "Enseguida se la abro.", "xdg-open ~/Downloads", "Carpeta de descargas abierta."), + ("documentos_abrir", "abre mis documentos|abreme documentos", + "Enseguida.", "xdg-open ~/Documents", "Documentos abierto."), + ("cofre_abrir", "abre el cofre|abreme el cofre", + "Enseguida.", "xdg-open ~/COFRE", "COFRE abierto."), + ("ficheros_recientes", "que he tocado ultimamente|ficheros recientes|en que estaba trabajando", + "Lo miro.", + "find ~ -maxdepth 3 -type f -mmin -180 -not -path '*/.*' 2>/dev/null | head -6", + "Tocado en las ultimas horas: {salida}"), + ("grandes", "que ocupa mas sitio|que tengo en descargas|que me esta llenando la carpeta", + "Lo miro.", + "du -ah ~/Downloads ~/Descargas ~/Documents ~/Desktop 2>/dev/null | sort -rh | head -6", + "Lo mas grande: {salida}"), +] + +# ------------------------------------------------------- con argumento +CAPTURA = [ + ("buscar_fichero", "busca el fichero|buscame el fichero|encuentra el fichero", + "Lo busco.", "timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS $HOME/Downloads -iname '*{argumento}*' -not -path '*/.*' -not -path '*/node_modules/*' -not -path '*venv*' 2>/dev/null | head -12", + "He encontrado {lineas}"), + ("buscar_carpeta", "busca la carpeta|encuentra la carpeta", + "La busco.", "find ~ -type d -iname '*{argumento}*' -not -path '*/.*' 2>/dev/null | head -12", + "Carpetas encontradas: {salida}"), + # busca un TEXTO dentro de los ficheros, recursivo, con ruta y linea + ("grep_recursivo", "busca el texto|busca la palabra en mis ficheros|donde pone|que fichero contiene", + "Lo busco en sus ficheros.", "timeout 12 grep -rIn --exclude-dir='.*' --exclude-dir='*venv*' --exclude-dir=node_modules --exclude-dir=__pycache__ --exclude-dir=modelos --exclude-dir=muestras -m1 {argumento} $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS 2>/dev/null | head -12", + "{lineas} ficheros lo contienen."), + ("leer_fichero", "leeme el fichero|abre y leeme|lee el fichero", + "Voy a leerlo.", "f=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS -iname '*{argumento}*' -type f -not -path '*/.*' 2>/dev/null | head -1); [ -n \"$f\" ] && head -c 800 \"$f\" || echo \"no lo encuentro\"", + "{salida}, señor"), + ("abrir_fichero", "abre el fichero|abreme el fichero", + "Enseguida se lo abro.", + "f=$(find ~ -iname '*{argumento}*' -type f -not -path '*/.*' 2>/dev/null | head -1); " + "[ -n \"$f\" ] && xdg-open \"$f\" && echo \"$f\" || echo 'no lo encuentro'", + "Abierto {ultima}."), + ("abrir_carpeta", "abre la carpeta|abreme la carpeta", + "Enseguida.", "a={argumento}; if [ -d \"$a\" ]; then d=\"$a\"; else d=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE $HOME/Downloads -type d -iname \"*$a*\" -not -path \"*/.*\" -not -path \"*/node_modules/*\" 2>/dev/null | head -1); fi; if [ -n \"$d\" ]; then xdg-open \"$d\" >/dev/null 2>&1 & echo \"Abierta $(basename \"$d\")\"; else echo \"No encuentro esa carpeta\"; fi", + "{ultima}"), + ("tamano_carpeta", "cuanto ocupa la carpeta|tamaño de la carpeta", + "Lo calculo.", + "a={argumento}; if [ -d \"$a\" ]; then d=\"$a\"; else d=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE $HOME/Downloads -type d -iname \"*$a*\" -not -path \"*/.*\" 2>/dev/null | head -1); fi; [ -n \"$d\" ] && du -sh \"$d\" 2>/dev/null | cut -f1 || echo \"no la encuentro\"", + "Ocupa {ultima}."), + ("proceso_buscar", "esta corriendo|se esta ejecutando|hay algun proceso de", + "Lo compruebo.", "pgrep -c -f {argumento} 2>/dev/null || echo 0", + "Hay {ultima} procesos que encajan."), + ("proceso_matar", "mata el proceso|cierra el proceso|termina el proceso", + "Antes de matarlo, confirmeme.", "pkill -f {argumento}", + "Proceso terminado.", True), + ("instalado", "tengo instalado|esta instalado", + "Lo miro.", "command -v {argumento} >/dev/null && echo si || echo no", + "Instalado: {ultima}."), + ("manual", "para que sirve el comando|que hace el comando", + "Voy.", "whatis {argumento} 2>/dev/null | head -1", "{salida}"), +] + +# --------------------------------------------------------------- oasis +# --------------------------------------------------------------- pentesting +# Red de seguridad: los escaneos ACTIVOS (que mandan paquetes a otros) llevan +# confirmar=True, asi que el dialogo muestra el comando exacto antes de lanzarse +# y la voz no puede disparar un escaneo sola. Los que apuntan a un objetivo se +# limitan a la RED LOCAL por construccion (autodetectan la subred o exigen IP +# privada); un objetivo de fuera se rechaza. Lo de solo lectura —buscar un +# exploit en la base local, resolver un dominio— va directo, no toca a nadie. +# +# Que apunte solo a la red local es a proposito: un asistente por voz que +# escanea lo que crea haber entendido, con salida al host, es un incidente +# esperando. Para objetivos de fuera, mejor a mano y con intencion. +PENTEST = [ + ("red_dispositivos", "escanea mi red|que hay en mi red|dispositivos en la red|quien esta en la red", + "Voy a escanear la red local, necesito su permiso.", + "R=$(ip route | awk '/proto.*src/{for(i=1;i<=NF;i++)if($i~/\\//)print $i; exit}'); " + "sudo nmap -sn ${R:-192.168.1.0/24} | grep -E 'Nmap scan|MAC' | head -30", + "Estos equipos hay en la red, señor: {salida}", True), + ("puertos_host", "que puertos tiene abiertos|escanea los puertos de|escanea el host", + "Un escaneo de puertos, con su permiso.", + "T={argumento}; echo \"$T\" | grep -qE '^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|127\\.|localhost)' " + "&& nmap -F \"$T\" | grep -E '^[0-9]+/|Nmap scan' | head -20 " + "|| echo 'Solo objetivos de la red local, señor.'", + "{salida}", True), + ("mis_puertos", "que puertos tengo abiertos yo|mis puertos abiertos|que estoy exponiendo", + "Lo compruebo.", + "ss -ltnp 2>/dev/null | awk 'NR>1{print $4}' | sort -u | head -15", + "Tengo {lineas} puertos a la escucha"), + ("exploit_buscar", "busca un exploit para|busca exploits de|hay algun exploit de", + "Lo busco en la base de exploits.", + "searchsploit {argumento} 2>/dev/null | grep -vE '^-|Shellcode' | head -12", + "Esto he encontrado, señor: {salida}"), + ("dominio_resolver", "resuelve el dominio|que ip tiene el dominio|a que ip apunta", + "Lo resuelvo.", "dig +short {argumento} 2>/dev/null | head -5", + "Resuelve a: {salida}"), + ("dominio_whois", "de quien es el dominio|quien registro|whois de", + "Lo consulto.", "whois {argumento} 2>/dev/null | grep -iE 'registrar:|creation|expir|org' | head -6", + "{salida}"), + ("cabeceras_web", "que servidor usa|cabeceras de|que tecnologia usa la web", + "Miro las cabeceras.", + "curl -sI --max-time 8 {argumento_url} 2>/dev/null | grep -iE 'server:|x-powered|location' | head -6", + "{salida}"), + ("hash_identificar", "que tipo de hash es|identifica el hash", + "Lo miro.", + "H={argumento}; L=${#H}; case $L in 32)echo 'MD5, probablemente';; 40)echo 'SHA1';; " + "64)echo 'SHA256';; *)echo \"$L caracteres, no lo tengo claro\";; esac", + "{ultima}, señor."), +] + +# las con argumento del bloque PENTEST (para marcar captura=True) +PENTEST_CAPTURA = {"puertos_host", "exploit_buscar", "dominio_resolver", + "dominio_whois", "cabeceras_web", "hash_identificar"} + +OASIS = [ + ("oasis_corriendo", "esta oasis funcionando|esta corriendo oasis|oasis esta levantado", + "Lo compruebo.", + "pgrep -c -f 'oasis|ssb-server|sbot' 2>/dev/null || echo 0", + "Procesos de Oasis en marcha: {ultima}."), + ("oasis_sincronizando", "estan sincronizando los pubs|como van los pubs|se estan sincronizando los pubs", + "Voy a mirar las conexiones.", + "python3 -c \"import json,time; d=json.load(open('$HOME/.ssb/conn.json')); " + "act=sum(1 for v in d.values() if time.time()*1000-v.get('stateChange',0)<300000); " + "fail=sum(1 for v in d.values() if v.get('failure',0)>0); " + "print(f'{len(d)} pubs, {act} activos hace poco, {fail} con fallos')\"", + "{ultima}."), + ("oasis_conexiones", "cuantos pubs hay conectados|conexiones de oasis", + "Un momento.", + "cat ~/.ssb/conn.json 2>/dev/null | grep -c 'connected' || echo 0", + "Pubs conectados: {ultima}."), + ("oasis_tamano", "cuanto ocupa oasis|tamaño de la base de ssb", + "Lo calculo.", "du -sh ~/.ssb 2>/dev/null | cut -f1", + "La base de SSB ocupa {ultima}."), + ("oasis_abrir", "abre oasis|abreme oasis", + "Enseguida se lo abro.", + "xdg-open http://localhost:3000 2>/dev/null && echo abierto || echo 'no responde'", + "Oasis: {ultima}."), + ("oasis_blobs", "cuanto ocupan los blobs|tamaño de los blobs", + "Voy.", "du -sh ~/.ssb/blobs 2>/dev/null | cut -f1", "Los blobs ocupan {ultima}."), +] + + +# Alias foneticos: lo que Whisper devuelve DE VERDAD, no lo que uno escribe. +# Medido con ocho ordenes habladas: "abre el correo" se transcribe "a ver el +# correo" (en castellano suenan igual), "los pubs" sale "los push" o "los +# puos", y "youtube" se parte en "yo tube". Añadirlos sube los aciertos sin +# tocar el modelo ni añadir un milisegundo. +ALIAS = { + "ff_gmail": ["a ver el correo", "abrir el correo"], + "oasis_sincronizando": ["estan sincronizando los push", "estan sincronizando los puos", + "sincronizando los pubs", "como van los push"], + "oasis_corriendo": ["esta oasis funcionando", "esta corriendo casis"], + "ff_buscar_youtube": ["busca en yo tube", "buscan youtube", "buscan yo tube", + "busca en you tube"], + "disco_libre": ["cuando espacio queda", "cuanto espacio quedan"], + "ff_youtube": ["abre yo tube", "abre you tube"], + "descargas_abrir": ["abre descargas", "abreme las descargas"], + "procesos_top": ["que esta consumiendo mas", "quien consume"], + "temperatura": ["que temperatura tiene", "como esta la temperatura"], + "bateria": ["cuanta bateria tengo", "como va la bateria"], +} + + +def con_señor(texto: str) -> str: + """Mete el trato en las frases fijas. + + Son las que mas suenan —el acuse va en cada orden— y hasta ahora eran las + unicas que no trataban de señor, asi que el personaje se caia justo ahi. + """ + if not texto or "señor" in texto.lower(): + return texto + if texto.endswith("."): + return texto[:-1] + ", señor." + if texto.endswith("?"): + return texto[:-1] + ", señor?" + return texto + ", señor" + + +# Abrir una terminal y poner un video: de lo primero que se le pide a un +# asistente de escritorio, y no estaban. Las busquedas van acotadas y con +# tope porque el home son casi mil gigas y un find sin limite no vuelve. +ESCRITORIO = [ + ("terminal_abrir", "abre una terminal|abreme una terminal|abre la terminal|saca una terminal|quiero una terminal", + "Ahora mismo.", "gnome-terminal 2>/dev/null || kgx 2>/dev/null || xterm 2>/dev/null &", + "Terminal abierta."), + ("terminal_aqui", "abre una terminal en el cofre|terminal en el cofre", + "Enseguida.", "gnome-terminal --working-directory=$HOME/COFRE 2>/dev/null || kgx 2>/dev/null &", + "Ahi la tiene, en el cofre."), + ("videos_listar", "que peliculas tengo|que videos tengo|lista mis videos", + "Un momento, lo miro.", "timeout 12 find $HOME/Videos $HOME/Downloads $HOME/Desktop -type f -regextype posix-extended -iregex '.*\\.(mp4|mkv|avi|webm|mov|m4v)$' 2>/dev/null | head -12", + "He encontrado {lineas}."), +] + +# esta captura el nombre de la pelicula +ESCRITORIO_CAPTURA = [ + ("pelicula_abrir", "pon la pelicula|abre la pelicula|reproduce el video|pon el video|quiero ver", + "Enseguida se la pongo.", "v=$(timeout 12 find $HOME/Videos $HOME/Downloads $HOME/Desktop -type f -regextype posix-extended -iregex '.*{argumento}.*\\.(mp4|mkv|avi|webm|mov|m4v)$' 2>/dev/null | head -1); if [ -n \"$v\" ]; then xdg-open \"$v\" >/dev/null 2>&1 & echo \"Poniendo $(basename \"$v\")\"; else echo \"No encuentro ese video\"; fi", + "{ultima}"), +] + +# Ventanas y pantallas. Toda la logica vive en config/ventanas.sh (un script del +# host): aqui solo estan las frases y la llamada. +# +# Las frases llevan TRES palabras o mas y evitan a proposito ser subcadena de +# las que ya existen con "pantalla" o "ventana" dentro —apagar_pantalla, +# bloquear, captura_pantalla, ff_firefox_privado, ff_firefox_cerrar, +# proceso_matar, terminal_abrir—, porque el emparejador gana por frase mas +# larga sin mirar el grupo y una frase mal elegida se las come. +VENTANAS = [ + ("ventanas_listar", "que ventanas tengo abiertas|que ventanas hay abiertas|lista las ventanas", + "Un momento, lo miro.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh listar", + "Tiene {lineas} ventanas abiertas."), + + ("ventana_izquierda", "pon la ventana a la izquierda|manda la ventana a la izquierda|esta ventana a la izquierda", + "Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta izquierda", "{ultima}"), + + ("ventana_derecha", "pon la ventana a la derecha|manda la ventana a la derecha|esta ventana a la derecha", + "Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta derecha", "{ultima}"), + + ("ventana_arriba", "pon la ventana arriba del todo|manda la ventana arriba", + "Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta arriba", "{ultima}"), + + ("ventana_abajo", "pon la ventana abajo del todo|manda la ventana abajo", + "Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta abajo", "{ultima}"), + + ("ventana_centrar", "centra la ventana activa|pon la ventana en el centro", + "Enseguida.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta centro", "{ultima}"), + + ("ventana_maximizar", "maximiza la ventana activa|pon la ventana a pantalla completa", + "Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh maximiza esta", "{ultima}"), + + ("ventana_restaurar", "restaura la ventana activa|quita la pantalla completa", + "Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh restaura esta", "{ultima}"), + + ("ventana_minimizar", "minimiza la ventana activa|esconde la ventana activa", + "Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh minimiza esta", "{ultima}"), +] + +# Estas capturan lo que se dice detras: el nombre de la pantalla, el numero de +# escritorio o el mote que se le quiera poner a un monitor. +VENTANAS_CAPTURA = [ + ("ventana_a_pantalla", "manda la ventana a la pantalla|pasa la ventana a la pantalla", + "Ahora mismo.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh mover esta {argumento}", "{ultima}"), + + ("ventana_al_escritorio", "manda la ventana al escritorio|pasa la ventana al escritorio", + "Voy.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh escritorio esta {argumento}", "{ultima}"), + + ("pantalla_nombrar", "a esta pantalla llamala|llama a esta pantalla", + "Anotado.", "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh alias 1 {argumento}", "{ultima}"), +] + + +def fila(t, grupo, captura=False): + ident, frases, acuse, comando, respuesta = t[:5] + confirmar = len(t) > 5 and t[5] + return { + "id": ident, + "grupo": grupo, + "frases": frases.split("|"), + "acuse": con_señor(acuse or "Voy."), + "comando": comando, + "host": True, + "respuesta": con_señor(respuesta), + "confirmar": bool(confirmar), + "captura": captura, + } + + +def main(): + acciones = [fila(t, "sistema") for t in SISTEMA] + acciones += [fila(t, "sistema") for t in ESCRITORIO] + acciones += [fila(t, "sistema", captura=True) for t in ESCRITORIO_CAPTURA] + acciones += [fila(t, "ventanas") for t in VENTANAS] + acciones += [fila(t, "ventanas", captura=True) for t in VENTANAS_CAPTURA] + acciones += [fila(t, "ficheros", captura=True) for t in CAPTURA] + acciones += [fila(t, "oasis") for t in OASIS] + acciones += [fila(t, "pentest", captura=t[0] in PENTEST_CAPTURA) for t in PENTEST] + + # las de firefox viven en su propio fichero, que son otras cincuenta + firefox = pathlib.Path(__file__).parent / "_firefox.py" + if firefox.exists(): + import importlib.util + spec = importlib.util.spec_from_file_location("firefox", firefox) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + acciones += mod.acciones() + + # Los alias se aplican aqui, sobre la lista completa: si se hicieran al + # construir cada fila, las de firefox se quedarian fuera porque se generan + # en otro fichero. Se aprende por las malas. + for accion in acciones: + extra = ALIAS.get(accion["id"]) + if extra: + accion["frases"] += [f for f in extra if f not in accion["frases"]] + + salida = pathlib.Path(__file__).parent / "acciones.json" + salida.write_text(json.dumps({"version": 1, "acciones": acciones}, + indent=2, ensure_ascii=False) + "\n") + + ids = [a["id"] for a in acciones] + print(f"{len(acciones)} acciones escritas en {salida.name}") + if len(ids) != len(set(ids)): + repes = {i for i in ids if ids.count(i) > 1} + print(f"CUIDADO, ids repetidos: {repes}") + for grupo in sorted({a["grupo"] for a in acciones}): + print(f" {grupo:<10} {sum(1 for a in acciones if a['grupo'] == grupo)}") + + +if __name__ == "__main__": + main() diff --git a/nucleo/datos/acciones.json b/nucleo/datos/acciones.json new file mode 100644 index 0000000..69e6291 --- /dev/null +++ b/nucleo/datos/acciones.json @@ -0,0 +1,2179 @@ +{ + "version": 1, + "acciones": [ + { + "id": "disco_libre", + "grupo": "sistema", + "frases": [ + "cuanto espacio queda", + "espacio en disco", + "espacio libre en disco", + "cuanto disco queda", + "cuando espacio queda", + "cuanto espacio quedan" + ], + "acuse": "Un momento, lo miro, señor.", + "comando": "df -h / | tail -1", + "host": true, + "respuesta": "Quedan {campo4} libres de {campo2}, al {campo5} de uso, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "disco_home", + "grupo": "sistema", + "frases": [ + "como esta mi disco", + "cuanto ocupa mi disco", + "espacio de mi disco" + ], + "acuse": "Un momento, señor.", + "comando": "df -h ~ | tail -1", + "host": true, + "respuesta": "Su disco esta al {campo5}, con {campo4} libres de {campo2}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "disco_todos", + "grupo": "sistema", + "frases": [ + "discos montados", + "particiones", + "unidades montadas" + ], + "acuse": "Enseguida, señor.", + "comando": "df -h --output=target,size,pcent -x tmpfs -x devtmpfs | tail -n +2", + "host": true, + "respuesta": "Hay {lineas} unidades montadas. {salida}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "memoria", + "grupo": "sistema", + "frases": [ + "cuanta memoria", + "memoria libre", + "cuanta ram", + "ram libre" + ], + "acuse": "Voy, señor.", + "comando": "free -g | awk '/Mem:/{print $2, $3, $7}'", + "host": true, + "respuesta": "De {campo1} gigas hay {campo2} en uso y {campo3} disponibles, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "swap", + "grupo": "sistema", + "frases": [ + "como esta la swap", + "memoria de intercambio" + ], + "acuse": "Voy, señor.", + "comando": "free -g | awk '/Swap:/{print $2, $3}'", + "host": true, + "respuesta": "Swap: {campo2} gigas usados de {campo1}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "cpu_carga", + "grupo": "sistema", + "frases": [ + "carga del sistema", + "como va el procesador", + "carga de cpu" + ], + "acuse": "Un segundo, señor.", + "comando": "uptime | sed 's/.*average: //'", + "host": true, + "respuesta": "La carga media es {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "cpu_modelo", + "grupo": "sistema", + "frases": [ + "que procesador tengo", + "modelo de procesador", + "que cpu tengo" + ], + "acuse": "Enseguida, señor.", + "comando": "lscpu | grep 'Model name' | cut -d: -f2 | xargs", + "host": true, + "respuesta": "Lleva un {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "cpu_nucleos", + "grupo": "sistema", + "frases": [ + "cuantos nucleos tengo", + "cuantos hilos tengo" + ], + "acuse": "Voy, señor.", + "comando": "nproc", + "host": true, + "respuesta": "Tiene {ultima} hilos disponibles, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "temperatura", + "grupo": "sistema", + "frases": [ + "que temperatura hay", + "como esta de temperatura", + "se esta calentando", + "que temperatura tiene", + "como esta la temperatura" + ], + "acuse": "Lo compruebo, señor.", + "comando": "for z in /sys/class/thermal/thermal_zone*/temp; do cat $z; done | sort -rn | head -1 | awk '{printf \"%.0f\", $1/1000}'", + "host": true, + "respuesta": "El punto mas caliente esta a {ultima} grados, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "gpu_estado", + "grupo": "sistema", + "frases": [ + "como esta la grafica", + "estado de la gpu", + "uso de la grafica" + ], + "acuse": "Un momento, señor.", + "comando": "nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader", + "host": true, + "respuesta": "La grafica esta asi: {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "uptime", + "grupo": "sistema", + "frases": [ + "cuanto lleva encendido", + "desde cuando esta encendido", + "tiempo encendido" + ], + "acuse": "Voy, señor.", + "comando": "uptime -p | sed 's/^up //; s/ days\\?/ dias/; s/ hours\\?/ horas/; s/ minutes\\?/ minutos/; s/,\\([^,]*\\)$/ y\\1/'", + "host": true, + "respuesta": "Lleva encendido {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "bateria", + "grupo": "sistema", + "frases": [ + "cuanta bateria queda", + "como esta la bateria", + "nivel de bateria", + "cuanta bateria tengo", + "como va la bateria" + ], + "acuse": "Lo miro, señor.", + "comando": "cat /sys/class/power_supply/BAT*/capacity 2>/dev/null | head -1", + "host": true, + "respuesta": "La bateria esta al {ultima} por ciento, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "kernel", + "grupo": "sistema", + "frases": [ + "que kernel tengo", + "version del kernel", + "que nucleo tengo" + ], + "acuse": "Enseguida, señor.", + "comando": "uname -r", + "host": true, + "respuesta": "Kernel {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "distro", + "grupo": "sistema", + "frases": [ + "que distribucion tengo", + "que sistema tengo", + "que linux tengo" + ], + "acuse": "Voy, señor.", + "comando": "grep PRETTY_NAME /etc/os-release | cut -d'\"' -f2", + "host": true, + "respuesta": "Esta usando {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "fecha", + "grupo": "sistema", + "frases": [ + "que hora es", + "que dia es", + "dime la fecha" + ], + "acuse": "Voy, señor.", + "comando": "date '+%H:%M del %A %d de %B'", + "host": true, + "respuesta": "Son las {salida}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "procesos_top", + "grupo": "sistema", + "frases": [ + "que esta consumiendo", + "que consume mas", + "procesos que mas gastan", + "quien se come la cpu", + "que esta consumiendo mas", + "quien consume" + ], + "acuse": "Lo compruebo, señor.", + "comando": "ps -eo pcpu,comm --sort=-pcpu --no-headers | head -5 | awk '{c=$1; $1=\"\"; sub(/^ +/,\"\"); printf \"%s al %s por ciento\\n\", $0, c}'", + "host": true, + "respuesta": "Lo que mas consume es {primera}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "procesos_memoria", + "grupo": "sistema", + "frases": [ + "que consume mas memoria", + "quien se come la ram" + ], + "acuse": "Lo compruebo, señor.", + "comando": "ps -eo comm,pmem --sort=-pmem --no-headers | head -5", + "host": true, + "respuesta": "Por memoria: {salida}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "procesos_cuantos", + "grupo": "sistema", + "frases": [ + "cuantos procesos hay", + "cuantos procesos corren" + ], + "acuse": "Voy, señor.", + "comando": "ps -e --no-headers | wc -l", + "host": true, + "respuesta": "Hay {ultima} procesos en marcha, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "red_ip", + "grupo": "sistema", + "frases": [ + "cual es mi ip", + "mi ip local", + "que ip tengo" + ], + "acuse": "Un momento, señor.", + "comando": "hostname -I | awk '{print $1}'", + "host": true, + "respuesta": "Su IP local es {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "red_ip_publica", + "grupo": "sistema", + "frases": [ + "cual es mi ip publica", + "ip externa" + ], + "acuse": "Salgo a mirarlo, señor.", + "comando": "curl -s --max-time 6 ifconfig.me", + "host": true, + "respuesta": "Su IP publica es {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "red_wifi", + "grupo": "sistema", + "frases": [ + "a que wifi estoy conectado", + "red wifi", + "como se llama la wifi" + ], + "acuse": "Voy, señor.", + "comando": "nmcli -t -f NAME,TYPE connection show --active | grep wireless | cut -d: -f1", + "host": true, + "respuesta": "Conectado a {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "red_velocidad", + "grupo": "sistema", + "frases": [ + "cuanto trafico de red", + "trafico de red" + ], + "acuse": "Lo miro, señor.", + "comando": "cat /proc/net/dev | awk '/wlp|enp/{rx+=$2; tx+=$10} END{printf \"%.1f %.1f\", rx/1e9, tx/1e9}'", + "host": true, + "respuesta": "Desde el arranque: {campo1} gigas bajados y {campo2} subidos, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "red_ping", + "grupo": "sistema", + "frases": [ + "hay internet", + "tengo conexion", + "funciona internet" + ], + "acuse": "Lo compruebo, señor.", + "comando": "ping -c1 -W2 1.1.1.1 >/dev/null 2>&1 && echo si || echo no", + "host": true, + "respuesta": "Conexion: {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "puertos", + "grupo": "sistema", + "frases": [ + "que puertos hay abiertos", + "puertos a la escucha", + "quien esta escuchando" + ], + "acuse": "Un momento, señor.", + "comando": "ss -ltnp 2>/dev/null | awk 'NR>1{print $4}' | sort -u | head -8", + "host": true, + "respuesta": "A la escucha: {salida}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "servicios_fallidos", + "grupo": "sistema", + "frases": [ + "hay servicios caidos", + "servicios con fallos", + "algo ha fallado" + ], + "acuse": "Lo reviso, señor.", + "comando": "systemctl --failed --no-legend | wc -l", + "host": true, + "respuesta": "Servicios en fallo: {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "actualizaciones", + "grupo": "sistema", + "frases": [ + "hay actualizaciones", + "paquetes por actualizar" + ], + "acuse": "Lo compruebo, tarda un poco, señor.", + "comando": "apt list --upgradable 2>/dev/null | tail -n +2 | wc -l", + "host": true, + "respuesta": "Hay {ultima} paquetes por actualizar, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "usuarios", + "grupo": "sistema", + "frases": [ + "quien esta conectado", + "hay alguien conectado" + ], + "acuse": "Voy, señor.", + "comando": "who | wc -l", + "host": true, + "respuesta": "Hay {ultima} sesiones abiertas, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "gpu_procesos", + "grupo": "sistema", + "frases": [ + "que usa la grafica", + "quien usa la gpu" + ], + "acuse": "Lo miro, señor.", + "comando": "O=$(nvidia-smi --query-compute-apps=process_name,used_gpu_memory --format=csv,noheader | awk -F, '{n=split($1,p,\"/\"); printf \"%s%s\\n\", p[n], $2}'); echo \"${O:-nada, la grafica esta libre}\"", + "host": true, + "respuesta": "En la grafica: {primera}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "audio_salida", + "grupo": "sistema", + "frases": [ + "por donde suena", + "salida de audio", + "que altavoz esta puesto" + ], + "acuse": "Voy, señor.", + "comando": "pactl get-default-sink 2>/dev/null", + "host": true, + "respuesta": "La salida es {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "audio_micro", + "grupo": "sistema", + "frases": [ + "que microfono hay puesto", + "entrada de audio" + ], + "acuse": "Voy, señor.", + "comando": "pactl get-default-source 2>/dev/null", + "host": true, + "respuesta": "La entrada es {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "pantallas", + "grupo": "sistema", + "frases": [ + "cuantas pantallas hay", + "monitores conectados" + ], + "acuse": "Voy, señor.", + "comando": "n=$(xrandr --listmonitors 2>/dev/null | head -1 | awk '{print $2}'); xrandr --listmonitors 2>/dev/null | tail -n +2 | awk '{print $NF}' | paste -sd', '; echo \"Tiene $n pantalla$([ \"$n\" = 1 ] || echo s) conectada$([ \"$n\" = 1 ] || echo s)\"", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "dispositivos_usb", + "grupo": "sistema", + "frases": [ + "que hay conectado por usb", + "dispositivos usb" + ], + "acuse": "Un momento, señor.", + "comando": "lsusb | cut -d' ' -f7- | head -6", + "host": true, + "respuesta": "Por USB: {salida}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "bluetooth", + "grupo": "sistema", + "frases": [ + "que hay emparejado por bluetooth", + "dispositivos bluetooth" + ], + "acuse": "Voy, señor.", + "comando": "bluetoothctl devices 2>/dev/null | cut -d' ' -f3- | head -5", + "host": true, + "respuesta": "Emparejados: {salida}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "flatpaks", + "grupo": "sistema", + "frases": [ + "que flatpaks tengo", + "aplicaciones flatpak" + ], + "acuse": "Voy, señor.", + "comando": "flatpak list --app --columns=name | head -10", + "host": true, + "respuesta": "Instalados: {salida}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "docker", + "grupo": "sistema", + "frases": [ + "hay contenedores corriendo", + "contenedores docker" + ], + "acuse": "Lo miro, señor.", + "comando": "O=$(docker ps --format '{{.Names}}' 2>/dev/null | head -5); echo \"${O:-ninguno en marcha}\"", + "host": true, + "respuesta": "Contenedores: {salida}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "git_estado", + "grupo": "sistema", + "frases": [ + "como esta el repositorio", + "estado de git" + ], + "acuse": "Voy, señor.", + "comando": "cd ~/COFRE/CODERS/JARVIS && git status --short | wc -l", + "host": true, + "respuesta": "Hay {ultima} ficheros con cambios sin guardar, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ollama_estado", + "grupo": "sistema", + "frases": [ + "esta ollama funcionando", + "como esta el modelo", + "que modelo hay cargado" + ], + "acuse": "Lo compruebo, señor.", + "comando": "M=$(curl -s --max-time 3 http://localhost:11434/api/ps | grep -o '\"name\":\"[^\"]*' | cut -d'\"' -f4 | head -1); echo \"${M:-ninguno ahora mismo}\"", + "host": true, + "respuesta": "Modelo cargado: {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "apagar_pantalla", + "grupo": "sistema", + "frases": [ + "apaga la pantalla", + "apagar pantalla" + ], + "acuse": "Enseguida, señor.", + "comando": "xset dpms force off", + "host": true, + "respuesta": "Pantalla apagada, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "actualizar_lista", + "grupo": "sistema", + "frases": [ + "actualiza los repositorios", + "refresca los paquetes", + "actualiza la lista de paquetes" + ], + "acuse": "Voy, necesito permiso de administrador, señor.", + "comando": "sudo apt update", + "host": true, + "respuesta": "Repositorios actualizados, señor.", + "confirmar": true, + "captura": false + }, + { + "id": "espacio_liberar", + "grupo": "sistema", + "frases": [ + "limpia los paquetes viejos", + "libera espacio de apt", + "limpia el cache de paquetes" + ], + "acuse": "Necesito permiso para esto, señor.", + "comando": "sudo apt autoclean", + "host": true, + "respuesta": "Cache de paquetes limpiada, señor.", + "confirmar": true, + "captura": false + }, + { + "id": "servicio_reiniciar", + "grupo": "sistema", + "frases": [ + "reinicia el servicio de red", + "reinicia la red" + ], + "acuse": "Necesito permiso de administrador, señor.", + "comando": "sudo systemctl restart NetworkManager", + "host": true, + "respuesta": "Red reiniciada, señor.", + "confirmar": true, + "captura": false + }, + { + "id": "fecha_sincronizar", + "grupo": "sistema", + "frases": [ + "sincroniza la hora", + "pon la hora en hora" + ], + "acuse": "Un momento, con permiso, señor.", + "comando": "sudo systemctl restart systemd-timesyncd", + "host": true, + "respuesta": "Hora sincronizada, señor.", + "confirmar": true, + "captura": false + }, + { + "id": "bloquear", + "grupo": "sistema", + "frases": [ + "bloquea la sesion", + "bloquear pantalla", + "echa el cerrojo" + ], + "acuse": "Ahora mismo, señor.", + "comando": "loginctl lock-session", + "host": true, + "respuesta": "Sesion bloqueada, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "volumen_subir", + "grupo": "sistema", + "frases": [ + "sube el volumen", + "mas volumen" + ], + "acuse": "Voy, señor.", + "comando": "pactl set-sink-volume @DEFAULT_SINK@ +10%", + "host": true, + "respuesta": "Volumen subido, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "volumen_bajar", + "grupo": "sistema", + "frases": [ + "baja el volumen", + "menos volumen" + ], + "acuse": "Voy, señor.", + "comando": "pactl set-sink-volume @DEFAULT_SINK@ -10%", + "host": true, + "respuesta": "Volumen bajado, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "volumen_silencio", + "grupo": "sistema", + "frases": [ + "silencia el sistema", + "quita el sonido", + "mutea el sistema" + ], + "acuse": "Voy, señor.", + "comando": "pactl set-sink-mute @DEFAULT_SINK@ toggle", + "host": true, + "respuesta": "Sonido conmutado, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "brillo_subir", + "grupo": "sistema", + "frases": [ + "sube el brillo", + "mas brillo" + ], + "acuse": "Voy, señor.", + "comando": "brightnessctl set +10% 2>/dev/null || xrandr --output $(xrandr | awk '/ connected/{print $1; exit}') --brightness 1", + "host": true, + "respuesta": "Brillo subido, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "captura_pantalla", + "grupo": "sistema", + "frases": [ + "hazme una captura", + "captura de pantalla", + "haz una foto de la pantalla" + ], + "acuse": "Enseguida se la hago, señor.", + "comando": "D=$(xdg-user-dir PICTURES 2>/dev/null || echo ~); F=$D/captura-$(date +%H%M%S).png; import -window root \"$F\" 2>/dev/null || gnome-screenshot -f \"$F\" 2>/dev/null || ffmpeg -loglevel error -f x11grab -video_size $(xdpyinfo | awk \"/dimensions/{print \\$2}\") -i $DISPLAY -frames:v 1 -y \"$F\" 2>/dev/null; [ -s \"$F\" ] && echo \"$F\"", + "host": true, + "respuesta": "Captura guardada, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "papelera_tamano", + "grupo": "sistema", + "frases": [ + "cuanto ocupa la papelera", + "papelera llena" + ], + "acuse": "Voy, señor.", + "comando": "du -sh ~/.local/share/Trash 2>/dev/null | cut -f1", + "host": true, + "respuesta": "La papelera ocupa {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "descargas_listar", + "grupo": "sistema", + "frases": [ + "que hay en descargas", + "ultimas descargas", + "que me he bajado" + ], + "acuse": "Voy, señor.", + "comando": "ls -t ~/Downloads 2>/dev/null | head -6", + "host": true, + "respuesta": "Hay {lineas} cosas en descargas, señor", + "confirmar": false, + "captura": false + }, + { + "id": "descargas_abrir", + "grupo": "sistema", + "frases": [ + "abre la carpeta de descargas", + "abreme descargas", + "abre descargas", + "abreme las descargas" + ], + "acuse": "Enseguida se la abro, señor.", + "comando": "xdg-open ~/Downloads", + "host": true, + "respuesta": "Carpeta de descargas abierta, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "documentos_abrir", + "grupo": "sistema", + "frases": [ + "abre mis documentos", + "abreme documentos" + ], + "acuse": "Enseguida, señor.", + "comando": "xdg-open ~/Documents", + "host": true, + "respuesta": "Documentos abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "cofre_abrir", + "grupo": "sistema", + "frases": [ + "abre el cofre", + "abreme el cofre" + ], + "acuse": "Enseguida, señor.", + "comando": "xdg-open ~/COFRE", + "host": true, + "respuesta": "COFRE abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ficheros_recientes", + "grupo": "sistema", + "frases": [ + "que he tocado ultimamente", + "ficheros recientes", + "en que estaba trabajando" + ], + "acuse": "Lo miro, señor.", + "comando": "find ~ -maxdepth 3 -type f -mmin -180 -not -path '*/.*' 2>/dev/null | head -6", + "host": true, + "respuesta": "Tocado en las ultimas horas: {salida}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "grandes", + "grupo": "sistema", + "frases": [ + "que ocupa mas sitio", + "que tengo en descargas", + "que me esta llenando la carpeta" + ], + "acuse": "Lo miro, señor.", + "comando": "du -ah ~/Downloads ~/Descargas ~/Documents ~/Desktop 2>/dev/null | sort -rh | head -6", + "host": true, + "respuesta": "Lo mas grande: {salida}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "terminal_abrir", + "grupo": "sistema", + "frases": [ + "abre una terminal", + "abreme una terminal", + "abre la terminal", + "saca una terminal", + "quiero una terminal" + ], + "acuse": "Ahora mismo, señor.", + "comando": "gnome-terminal 2>/dev/null || kgx 2>/dev/null || xterm 2>/dev/null &", + "host": true, + "respuesta": "Terminal abierta, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "terminal_aqui", + "grupo": "sistema", + "frases": [ + "abre una terminal en el cofre", + "terminal en el cofre" + ], + "acuse": "Enseguida, señor.", + "comando": "gnome-terminal --working-directory=$HOME/COFRE 2>/dev/null || kgx 2>/dev/null &", + "host": true, + "respuesta": "Ahi la tiene, en el cofre, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "videos_listar", + "grupo": "sistema", + "frases": [ + "que peliculas tengo", + "que videos tengo", + "lista mis videos" + ], + "acuse": "Un momento, lo miro, señor.", + "comando": "timeout 12 find $HOME/Videos $HOME/Downloads $HOME/Desktop -type f -regextype posix-extended -iregex '.*\\.(mp4|mkv|avi|webm|mov|m4v)$' 2>/dev/null | head -12", + "host": true, + "respuesta": "He encontrado {lineas}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "pelicula_abrir", + "grupo": "sistema", + "frases": [ + "pon la pelicula", + "abre la pelicula", + "reproduce el video", + "pon el video", + "quiero ver" + ], + "acuse": "Enseguida se la pongo, señor.", + "comando": "v=$(timeout 12 find $HOME/Videos $HOME/Downloads $HOME/Desktop -type f -regextype posix-extended -iregex '.*{argumento}.*\\.(mp4|mkv|avi|webm|mov|m4v)$' 2>/dev/null | head -1); if [ -n \"$v\" ]; then xdg-open \"$v\" >/dev/null 2>&1 & echo \"Poniendo $(basename \"$v\")\"; else echo \"No encuentro ese video\"; fi", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": true + }, + { + "id": "ventanas_listar", + "grupo": "ventanas", + "frases": [ + "que ventanas tengo abiertas", + "que ventanas hay abiertas", + "lista las ventanas" + ], + "acuse": "Un momento, lo miro, señor.", + "comando": "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh listar", + "host": true, + "respuesta": "Tiene {lineas} ventanas abiertas, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ventana_izquierda", + "grupo": "ventanas", + "frases": [ + "pon la ventana a la izquierda", + "manda la ventana a la izquierda", + "esta ventana a la izquierda" + ], + "acuse": "Ahora mismo, señor.", + "comando": "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta izquierda", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "ventana_derecha", + "grupo": "ventanas", + "frases": [ + "pon la ventana a la derecha", + "manda la ventana a la derecha", + "esta ventana a la derecha" + ], + "acuse": "Ahora mismo, señor.", + "comando": "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta derecha", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "ventana_arriba", + "grupo": "ventanas", + "frases": [ + "pon la ventana arriba del todo", + "manda la ventana arriba" + ], + "acuse": "Voy, señor.", + "comando": "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta arriba", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "ventana_abajo", + "grupo": "ventanas", + "frases": [ + "pon la ventana abajo del todo", + "manda la ventana abajo" + ], + "acuse": "Voy, señor.", + "comando": "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta abajo", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "ventana_centrar", + "grupo": "ventanas", + "frases": [ + "centra la ventana activa", + "pon la ventana en el centro" + ], + "acuse": "Enseguida, señor.", + "comando": "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh zona esta centro", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "ventana_maximizar", + "grupo": "ventanas", + "frases": [ + "maximiza la ventana activa", + "pon la ventana a pantalla completa" + ], + "acuse": "Ahora mismo, señor.", + "comando": "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh maximiza esta", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "ventana_restaurar", + "grupo": "ventanas", + "frases": [ + "restaura la ventana activa", + "quita la pantalla completa" + ], + "acuse": "Voy, señor.", + "comando": "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh restaura esta", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "ventana_minimizar", + "grupo": "ventanas", + "frases": [ + "minimiza la ventana activa", + "esconde la ventana activa" + ], + "acuse": "Voy, señor.", + "comando": "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh minimiza esta", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": false + }, + { + "id": "ventana_a_pantalla", + "grupo": "ventanas", + "frases": [ + "manda la ventana a la pantalla", + "pasa la ventana a la pantalla" + ], + "acuse": "Ahora mismo, señor.", + "comando": "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh mover esta {argumento}", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": true + }, + { + "id": "ventana_al_escritorio", + "grupo": "ventanas", + "frases": [ + "manda la ventana al escritorio", + "pasa la ventana al escritorio" + ], + "acuse": "Voy, señor.", + "comando": "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh escritorio esta {argumento}", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": true + }, + { + "id": "pantalla_nombrar", + "grupo": "ventanas", + "frases": [ + "a esta pantalla llamala", + "llama a esta pantalla" + ], + "acuse": "Anotado, señor.", + "comando": "$HOME/COFRE/CODERS/JARVIS/config/ventanas.sh alias 1 {argumento}", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": true + }, + { + "id": "buscar_fichero", + "grupo": "ficheros", + "frases": [ + "busca el fichero", + "buscame el fichero", + "encuentra el fichero" + ], + "acuse": "Lo busco, señor.", + "comando": "timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS $HOME/Downloads -iname '*{argumento}*' -not -path '*/.*' -not -path '*/node_modules/*' -not -path '*venv*' 2>/dev/null | head -12", + "host": true, + "respuesta": "He encontrado {lineas}, señor", + "confirmar": false, + "captura": true + }, + { + "id": "buscar_carpeta", + "grupo": "ficheros", + "frases": [ + "busca la carpeta", + "encuentra la carpeta" + ], + "acuse": "La busco, señor.", + "comando": "find ~ -type d -iname '*{argumento}*' -not -path '*/.*' 2>/dev/null | head -12", + "host": true, + "respuesta": "Carpetas encontradas: {salida}, señor", + "confirmar": false, + "captura": true + }, + { + "id": "grep_recursivo", + "grupo": "ficheros", + "frases": [ + "busca el texto", + "busca la palabra en mis ficheros", + "donde pone", + "que fichero contiene" + ], + "acuse": "Lo busco en sus ficheros, señor.", + "comando": "timeout 12 grep -rIn --exclude-dir='.*' --exclude-dir='*venv*' --exclude-dir=node_modules --exclude-dir=__pycache__ --exclude-dir=modelos --exclude-dir=muestras -m1 {argumento} $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS 2>/dev/null | head -12", + "host": true, + "respuesta": "{lineas} ficheros lo contienen, señor.", + "confirmar": false, + "captura": true + }, + { + "id": "leer_fichero", + "grupo": "ficheros", + "frases": [ + "leeme el fichero", + "abre y leeme", + "lee el fichero" + ], + "acuse": "Voy a leerlo, señor.", + "comando": "f=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE/CODERS -iname '*{argumento}*' -type f -not -path '*/.*' 2>/dev/null | head -1); [ -n \"$f\" ] && head -c 800 \"$f\" || echo \"no lo encuentro\"", + "host": true, + "respuesta": "{salida}, señor", + "confirmar": false, + "captura": true + }, + { + "id": "abrir_fichero", + "grupo": "ficheros", + "frases": [ + "abre el fichero", + "abreme el fichero" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "f=$(find ~ -iname '*{argumento}*' -type f -not -path '*/.*' 2>/dev/null | head -1); [ -n \"$f\" ] && xdg-open \"$f\" && echo \"$f\" || echo 'no lo encuentro'", + "host": true, + "respuesta": "Abierto {ultima}, señor.", + "confirmar": false, + "captura": true + }, + { + "id": "abrir_carpeta", + "grupo": "ficheros", + "frases": [ + "abre la carpeta", + "abreme la carpeta" + ], + "acuse": "Enseguida, señor.", + "comando": "a={argumento}; if [ -d \"$a\" ]; then d=\"$a\"; else d=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE $HOME/Downloads -type d -iname \"*$a*\" -not -path \"*/.*\" -not -path \"*/node_modules/*\" 2>/dev/null | head -1); fi; if [ -n \"$d\" ]; then xdg-open \"$d\" >/dev/null 2>&1 & echo \"Abierta $(basename \"$d\")\"; else echo \"No encuentro esa carpeta\"; fi", + "host": true, + "respuesta": "{ultima}, señor", + "confirmar": false, + "captura": true + }, + { + "id": "tamano_carpeta", + "grupo": "ficheros", + "frases": [ + "cuanto ocupa la carpeta", + "tamaño de la carpeta" + ], + "acuse": "Lo calculo, señor.", + "comando": "a={argumento}; if [ -d \"$a\" ]; then d=\"$a\"; else d=$(timeout 12 find $HOME/Documents $HOME/Desktop $HOME/COFRE $HOME/Downloads -type d -iname \"*$a*\" -not -path \"*/.*\" 2>/dev/null | head -1); fi; [ -n \"$d\" ] && du -sh \"$d\" 2>/dev/null | cut -f1 || echo \"no la encuentro\"", + "host": true, + "respuesta": "Ocupa {ultima}, señor.", + "confirmar": false, + "captura": true + }, + { + "id": "proceso_buscar", + "grupo": "ficheros", + "frases": [ + "esta corriendo", + "se esta ejecutando", + "hay algun proceso de" + ], + "acuse": "Lo compruebo, señor.", + "comando": "pgrep -c -f {argumento} 2>/dev/null || echo 0", + "host": true, + "respuesta": "Hay {ultima} procesos que encajan, señor.", + "confirmar": false, + "captura": true + }, + { + "id": "proceso_matar", + "grupo": "ficheros", + "frases": [ + "mata el proceso", + "cierra el proceso", + "termina el proceso" + ], + "acuse": "Antes de matarlo, confirmeme, señor.", + "comando": "pkill -f {argumento}", + "host": true, + "respuesta": "Proceso terminado, señor.", + "confirmar": true, + "captura": true + }, + { + "id": "instalado", + "grupo": "ficheros", + "frases": [ + "tengo instalado", + "esta instalado" + ], + "acuse": "Lo miro, señor.", + "comando": "command -v {argumento} >/dev/null && echo si || echo no", + "host": true, + "respuesta": "Instalado: {ultima}, señor.", + "confirmar": false, + "captura": true + }, + { + "id": "manual", + "grupo": "ficheros", + "frases": [ + "para que sirve el comando", + "que hace el comando" + ], + "acuse": "Voy, señor.", + "comando": "whatis {argumento} 2>/dev/null | head -1", + "host": true, + "respuesta": "{salida}, señor", + "confirmar": false, + "captura": true + }, + { + "id": "oasis_corriendo", + "grupo": "oasis", + "frases": [ + "esta oasis funcionando", + "esta corriendo oasis", + "oasis esta levantado", + "esta corriendo casis" + ], + "acuse": "Lo compruebo, señor.", + "comando": "pgrep -c -f 'oasis|ssb-server|sbot' 2>/dev/null || echo 0", + "host": true, + "respuesta": "Procesos de Oasis en marcha: {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "oasis_sincronizando", + "grupo": "oasis", + "frases": [ + "estan sincronizando los pubs", + "como van los pubs", + "se estan sincronizando los pubs", + "estan sincronizando los push", + "estan sincronizando los puos", + "sincronizando los pubs", + "como van los push" + ], + "acuse": "Voy a mirar las conexiones, señor.", + "comando": "python3 -c \"import json,time; d=json.load(open('$HOME/.ssb/conn.json')); act=sum(1 for v in d.values() if time.time()*1000-v.get('stateChange',0)<300000); fail=sum(1 for v in d.values() if v.get('failure',0)>0); print(f'{len(d)} pubs, {act} activos hace poco, {fail} con fallos')\"", + "host": true, + "respuesta": "{ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "oasis_conexiones", + "grupo": "oasis", + "frases": [ + "cuantos pubs hay conectados", + "conexiones de oasis" + ], + "acuse": "Un momento, señor.", + "comando": "cat ~/.ssb/conn.json 2>/dev/null | grep -c 'connected' || echo 0", + "host": true, + "respuesta": "Pubs conectados: {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "oasis_tamano", + "grupo": "oasis", + "frases": [ + "cuanto ocupa oasis", + "tamaño de la base de ssb" + ], + "acuse": "Lo calculo, señor.", + "comando": "du -sh ~/.ssb 2>/dev/null | cut -f1", + "host": true, + "respuesta": "La base de SSB ocupa {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "oasis_abrir", + "grupo": "oasis", + "frases": [ + "abre oasis", + "abreme oasis" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "xdg-open http://localhost:3000 2>/dev/null && echo abierto || echo 'no responde'", + "host": true, + "respuesta": "Oasis: {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "oasis_blobs", + "grupo": "oasis", + "frases": [ + "cuanto ocupan los blobs", + "tamaño de los blobs" + ], + "acuse": "Voy, señor.", + "comando": "du -sh ~/.ssb/blobs 2>/dev/null | cut -f1", + "host": true, + "respuesta": "Los blobs ocupan {ultima}, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "red_dispositivos", + "grupo": "pentest", + "frases": [ + "escanea mi red", + "que hay en mi red", + "dispositivos en la red", + "quien esta en la red" + ], + "acuse": "Voy a escanear la red local, necesito su permiso, señor.", + "comando": "R=$(ip route | awk '/proto.*src/{for(i=1;i<=NF;i++)if($i~/\\//)print $i; exit}'); sudo nmap -sn ${R:-192.168.1.0/24} | grep -E 'Nmap scan|MAC' | head -30", + "host": true, + "respuesta": "Estos equipos hay en la red, señor: {salida}", + "confirmar": true, + "captura": false + }, + { + "id": "puertos_host", + "grupo": "pentest", + "frases": [ + "que puertos tiene abiertos", + "escanea los puertos de", + "escanea el host" + ], + "acuse": "Un escaneo de puertos, con su permiso, señor.", + "comando": "T={argumento}; echo \"$T\" | grep -qE '^(10\\.|172\\.(1[6-9]|2[0-9]|3[01])\\.|192\\.168\\.|127\\.|localhost)' && nmap -F \"$T\" | grep -E '^[0-9]+/|Nmap scan' | head -20 || echo 'Solo objetivos de la red local, señor.'", + "host": true, + "respuesta": "{salida}, señor", + "confirmar": true, + "captura": true + }, + { + "id": "mis_puertos", + "grupo": "pentest", + "frases": [ + "que puertos tengo abiertos yo", + "mis puertos abiertos", + "que estoy exponiendo" + ], + "acuse": "Lo compruebo, señor.", + "comando": "ss -ltnp 2>/dev/null | awk 'NR>1{print $4}' | sort -u | head -15", + "host": true, + "respuesta": "Tengo {lineas} puertos a la escucha, señor", + "confirmar": false, + "captura": false + }, + { + "id": "exploit_buscar", + "grupo": "pentest", + "frases": [ + "busca un exploit para", + "busca exploits de", + "hay algun exploit de" + ], + "acuse": "Lo busco en la base de exploits, señor.", + "comando": "searchsploit {argumento} 2>/dev/null | grep -vE '^-|Shellcode' | head -12", + "host": true, + "respuesta": "Esto he encontrado, señor: {salida}", + "confirmar": false, + "captura": true + }, + { + "id": "dominio_resolver", + "grupo": "pentest", + "frases": [ + "resuelve el dominio", + "que ip tiene el dominio", + "a que ip apunta" + ], + "acuse": "Lo resuelvo, señor.", + "comando": "dig +short {argumento} 2>/dev/null | head -5", + "host": true, + "respuesta": "Resuelve a: {salida}, señor", + "confirmar": false, + "captura": true + }, + { + "id": "dominio_whois", + "grupo": "pentest", + "frases": [ + "de quien es el dominio", + "quien registro", + "whois de" + ], + "acuse": "Lo consulto, señor.", + "comando": "whois {argumento} 2>/dev/null | grep -iE 'registrar:|creation|expir|org' | head -6", + "host": true, + "respuesta": "{salida}, señor", + "confirmar": false, + "captura": true + }, + { + "id": "cabeceras_web", + "grupo": "pentest", + "frases": [ + "que servidor usa", + "cabeceras de", + "que tecnologia usa la web" + ], + "acuse": "Miro las cabeceras, señor.", + "comando": "curl -sI --max-time 8 {argumento_url} 2>/dev/null | grep -iE 'server:|x-powered|location' | head -6", + "host": true, + "respuesta": "{salida}, señor", + "confirmar": false, + "captura": true + }, + { + "id": "hash_identificar", + "grupo": "pentest", + "frases": [ + "que tipo de hash es", + "identifica el hash" + ], + "acuse": "Lo miro, señor.", + "comando": "H={argumento}; L=${#H}; case $L in 32)echo 'MD5, probablemente';; 40)echo 'SHA1';; 64)echo 'SHA256';; *)echo \"$L caracteres, no lo tengo claro\";; esac", + "host": true, + "respuesta": "{ultima}, señor.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_gmail", + "grupo": "firefox", + "frases": [ + "abre el correo", + "abre gmail", + "abreme el correo", + "a ver el correo", + "abrir el correo" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://mail.google.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_calendario", + "grupo": "firefox", + "frases": [ + "abre el calendario", + "abreme el calendario" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://calendar.google.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_drive", + "grupo": "firefox", + "frases": [ + "abre drive", + "abre el drive" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://drive.google.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_maps", + "grupo": "firefox", + "frases": [ + "abre el mapa", + "abre google maps", + "abreme los mapas" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://maps.google.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_youtube", + "grupo": "firefox", + "frases": [ + "abre youtube", + "abreme youtube", + "abre yo tube", + "abre you tube" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://youtube.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_github", + "grupo": "firefox", + "frases": [ + "abre github", + "abreme github" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://github.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_gitea", + "grupo": "firefox", + "frases": [ + "abre gitea", + "abre el gitea" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://gitea.laenre.net' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_wikipedia", + "grupo": "firefox", + "frases": [ + "abre la wikipedia", + "abreme wikipedia" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://es.wikipedia.org' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_traductor", + "grupo": "firefox", + "frases": [ + "abre el traductor", + "abreme el traductor" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://translate.google.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_chatgpt_no", + "grupo": "firefox", + "frases": [ + "abre duckduckgo", + "abre el buscador" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://duckduckgo.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_mastodon", + "grupo": "firefox", + "frases": [ + "abre mastodon", + "abreme mastodon" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://mastodon.social' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_reddit", + "grupo": "firefox", + "frases": [ + "abre reddit" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://reddit.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_hackernews", + "grupo": "firefox", + "frases": [ + "abre hacker news", + "abreme hacker news" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://news.ycombinator.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_archwiki", + "grupo": "firefox", + "frases": [ + "abre la wiki de arch", + "abre archwiki" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://wiki.archlinux.org' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_stackoverflow", + "grupo": "firefox", + "frases": [ + "abre stack overflow", + "abreme stackoverflow" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://stackoverflow.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_flathub", + "grupo": "firefox", + "frases": [ + "abre flathub" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://flathub.org' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_openstreetmap", + "grupo": "firefox", + "frases": [ + "abre openstreetmap", + "abre el mapa libre" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://openstreetmap.org' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_wetransfer", + "grupo": "firefox", + "frases": [ + "abre wetransfer" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://wetransfer.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_bandcamp", + "grupo": "firefox", + "frases": [ + "abre bandcamp" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://bandcamp.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_soundcloud", + "grupo": "firefox", + "frases": [ + "abre soundcloud" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://soundcloud.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_spotify_web", + "grupo": "firefox", + "frases": [ + "abre spotify" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://open.spotify.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_twitch", + "grupo": "firefox", + "frases": [ + "abre twitch" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://twitch.tv' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_archive", + "grupo": "firefox", + "frases": [ + "abre archive punto org", + "abre internet archive" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://archive.org' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_libgen", + "grupo": "firefox", + "frases": [ + "abre la biblioteca", + "abre library genesis" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://libgen.is' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_meteo", + "grupo": "firefox", + "frases": [ + "abre el tiempo", + "que tiempo va a hacer", + "abre la meteorologia" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://www.aemet.es' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_renfe", + "grupo": "firefox", + "frases": [ + "abre renfe", + "mira los trenes" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://www.renfe.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_idealista", + "grupo": "firefox", + "frases": [ + "abre idealista" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://www.idealista.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_wallapop", + "grupo": "firefox", + "frases": [ + "abre wallapop" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://es.wallapop.com' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_oasis_web", + "grupo": "firefox", + "frases": [ + "abre oasis en el navegador" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'http://localhost:3000' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_router", + "grupo": "firefox", + "frases": [ + "abre el router", + "entra en el router" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'http://192.168.1.1' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_localhost", + "grupo": "firefox", + "frases": [ + "abre localhost" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'http://localhost' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_correo_local", + "grupo": "firefox", + "frases": [ + "abre el webmail" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://webmail.laenre.net' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_nodo50", + "grupo": "firefox", + "frases": [ + "abre nodo cincuenta", + "abre nodo50" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://nodo50.org' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_hackmeeting", + "grupo": "firefox", + "frases": [ + "abre hackmeeting" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://sindominio.net/hackmeeting' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_reas", + "grupo": "firefox", + "frases": [ + "abre reas", + "abre la red de economia alternativa" + ], + "acuse": "Enseguida se lo abro, señor.", + "comando": "firefox --new-tab 'https://www.economiasolidaria.org' >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ya lo tiene abierto, señor.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_buscar_web", + "grupo": "firefox", + "frases": [ + "busca en internet", + "buscame en internet", + "busca en la web" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://duckduckgo.com/?q={argumento_url}\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_duck", + "grupo": "firefox", + "frases": [ + "busca en duckduckgo" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://duckduckgo.com/?q={argumento_url}\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_youtube", + "grupo": "firefox", + "frases": [ + "busca en youtube", + "buscame en youtube", + "busca en yo tube", + "buscan youtube", + "buscan yo tube", + "busca en you tube" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://www.youtube.com/results?search_query={argumento_url}\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_wikipedia", + "grupo": "firefox", + "frases": [ + "busca en la wikipedia", + "buscame en wikipedia" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://es.wikipedia.org/w/index.php?search={argumento_url}\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_github", + "grupo": "firefox", + "frases": [ + "busca en github", + "buscame en github" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://github.com/search?q={argumento_url}\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_mapas", + "grupo": "firefox", + "frases": [ + "busca en el mapa", + "buscame en el mapa", + "donde esta" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://www.openstreetmap.org/search?query={argumento_url}\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_traducir", + "grupo": "firefox", + "frases": [ + "traduce", + "traduceme" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://translate.google.com/?sl=auto&tl=es&text={argumento_url}\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_imagenes", + "grupo": "firefox", + "frases": [ + "busca imagenes de", + "buscame imagenes de" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://duckduckgo.com/?q={argumento_url}&iax=images&ia=images\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_paquete", + "grupo": "firefox", + "frases": [ + "busca el paquete", + "busca en flathub" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://flathub.org/apps/search?q={argumento_url}\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_precio", + "grupo": "firefox", + "frases": [ + "busca el precio de", + "cuanto cuesta" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://duckduckgo.com/?q={argumento_url}+precio\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_receta", + "grupo": "firefox", + "frases": [ + "busca la receta de", + "receta de" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://duckduckgo.com/?q=receta+{argumento_url}\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_musica", + "grupo": "firefox", + "frases": [ + "busca la cancion", + "busca en bandcamp" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://bandcamp.com/search?q={argumento_url}\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_stack", + "grupo": "firefox", + "frases": [ + "busca en stack overflow", + "como se hace en codigo" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://stackoverflow.com/search?q={argumento_url}\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_manual", + "grupo": "firefox", + "frases": [ + "busca la documentacion de", + "documentacion de" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://duckduckgo.com/?q={argumento_url}+docs\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_buscar_noticia", + "grupo": "firefox", + "frases": [ + "busca noticias de", + "noticias sobre" + ], + "acuse": "Lo busco, señor.", + "comando": "firefox --new-tab \"https://duckduckgo.com/?q={argumento_url}&iar=news&ia=news\" >/dev/null 2>&1 &", + "host": true, + "respuesta": "Buscando {argumento}, señor. Ya lo tiene en pantalla.", + "confirmar": false, + "captura": true + }, + { + "id": "ff_firefox_abrir", + "grupo": "firefox", + "frases": [ + "abre el navegador", + "abre firefox", + "abreme el navegador" + ], + "acuse": "Voy, señor.", + "comando": "firefox >/dev/null 2>&1 &", + "host": true, + "respuesta": "Navegador abierto.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_firefox_privado", + "grupo": "firefox", + "frases": [ + "abre una ventana privada", + "modo incognito", + "navegacion privada" + ], + "acuse": "Voy, señor.", + "comando": "firefox --private-window >/dev/null 2>&1 &", + "host": true, + "respuesta": "Ventana privada abierta.", + "confirmar": false, + "captura": false + }, + { + "id": "ff_firefox_cerrar", + "grupo": "firefox", + "frases": [ + "cierra el navegador", + "cierra firefox" + ], + "acuse": "Voy, señor.", + "comando": "pkill -f 'firefox' ", + "host": true, + "respuesta": "Navegador cerrado.", + "confirmar": true, + "captura": false + }, + { + "id": "ff_firefox_pestanas", + "grupo": "firefox", + "frases": [ + "cuantas pestañas tengo abiertas", + "cuantas pestañas hay" + ], + "acuse": "Voy, señor.", + "comando": "ls ~/.mozilla/firefox/*/sessionstore-backups/recovery.jsonlz4 2>/dev/null | wc -l", + "host": true, + "respuesta": "Tengo {ultima} sesiones guardadas; el numero exacto de pestañas solo lo sabe el navegador.", + "confirmar": false, + "captura": false + } + ] +} diff --git a/nucleo/estado.py b/nucleo/estado.py new file mode 100755 index 0000000..96dfc55 --- /dev/null +++ b/nucleo/estado.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Por donde va la migracion, en la terminal. + + ./estado.py las barras + ./estado.py -v y que falta exactamente en cada hito + ./estado.py --rapido sin ejecutar nada, solo mirar ficheros + +El porcentaje NO se escribe a mano: sale de comprobaciones de verdad —que un +fichero exista, que una funcion este, que una prueba pase—. Un contador que se +actualiza a mano miente a la semana, y ademas es justo lo que uno quiere que no +mienta: si dice 60 % es porque tres de cinco comprobaciones pasan ahora mismo. + +Las que cuestan tiempo (arrancar whisper, hablar con ollama) van marcadas como +lentas y se saltan con --rapido. +""" +import argparse +import os +import subprocess +import sys + +AQUI = os.path.dirname(os.path.abspath(__file__)) +RAIZ = os.path.dirname(AQUI) +VENV = os.path.join(AQUI, "venv", "bin", "python") + + +# ------------------------------------------------------------- comprobantes + +def hay(*trozos) -> bool: + return os.path.exists(os.path.join(AQUI, *trozos)) + + +def dentro(fichero, texto) -> bool: + try: + with open(os.path.join(AQUI, fichero), encoding="utf-8") as f: + return texto in f.read() + except OSError: + return False + + +def corre(orden, espera=90) -> bool: + try: + r = subprocess.run(orden, cwd=AQUI, capture_output=True, timeout=espera) + return r.returncode == 0 + except (OSError, subprocess.TimeoutExpired): + return False + + +def modulo(nombre) -> bool: + return corre([VENV if os.path.exists(VENV) else sys.executable, + "-c", f"import {nombre}"], espera=30) + + +def sin_comentarios(fichero, texto) -> bool: + """Si el texto aparece en una linea que NO es un comentario.""" + try: + with open(os.path.join(AQUI, fichero), encoding="utf-8") as f: + for l in f: + limpia = l.strip() + if limpia and not limpia.startswith(("#", "//", "*")) and texto in limpia: + return True + except OSError: + pass + return False + + +def catalogo_tiene(n) -> bool: + try: + import json + with open(os.path.join(AQUI, "datos", "acciones.json"), encoding="utf-8") as f: + return len(json.load(f)["acciones"]) >= n + except Exception: + return False + + +# ---------------------------------------------------------------- los hitos +# +# Cada comprobacion es (que se comprueba, como, si es lenta). El orden dentro +# de cada hito va de lo mas basico a lo mas fino, que es como se construyo. + +HITOS = [ + ("la boca", "hablar por los altavoces", [ + ("el modulo esta", lambda: hay("boca", "voz.py"), False), + ("hay tres voces o mas", lambda: dentro("boca/voz.py", '"claude"'), False), + ("cache de frases por sha1", lambda: dentro("boca/voz.py", "_clave"), False), + ("sabe si esta sonando", lambda: dentro("boca/voz.py", "def hablando"), False), + ("habla de verdad", lambda: corre( + [VENV, "jarvis.py", "--di", "prueba"], 60), True), + ]), + + ("el oido", "escuchar y transcribir", [ + ("captura con pw-record", lambda: dentro("oido/captura.py", "pw-record"), False), + ("detector de voz Silero", lambda: modulo("pysilero_vad"), False), + ("cliente de whisper-server", lambda: hay("oido", "whisper.py"), False), + ("filtro de alucinaciones", lambda: dentro("oido/whisper.py", "def es_ruido"), False), + ("guarda contra el eco", lambda: dentro("oido/captura.py", "boca.hablando"), False), + ("whisper arranca y transcribe", lambda: corre( + [VENV, "-c", + "import sys;sys.path.insert(0,'.');from oido.whisper import Oido;" + "o=Oido();import sys as s;s.exit(0 if o.arranca() else 1)"], 90), True), + ]), + + ("las manos", "ejecutar las 150 acciones", [ + ("el emparejador copiado", lambda: hay("manos", "acciones.py"), False), + ("las tareas en paralelo", lambda: hay("manos", "tareas.py"), False), + ("el catalogo entero", lambda: catalogo_tiene(150), False), + ("una sola puerta para ejecutar", lambda: dentro("manos/__init__.py", "class Manos"), False), + ("compara tambien por sonido", lambda: hay("manos", "fonetica.py"), False), + ("banco fijo para medir", lambda: hay("datos", "banco.json"), False), + ("las pruebas en verde", lambda: corre( + [VENV, "pruebas/prueba_manos.py"], 120), True), + ]), + + ("el cerebro", "pensar lo que el catalogo no cubre", [ + ("cliente de ollama", lambda: hay("cerebro", "ollama.py"), False), + ("dos herramientas, no una", lambda: dentro("cerebro/ollama.py", "comando_de_shell"), False), + ("no puede inventarse datos", lambda: dentro("cerebro/ollama.py", "REGLA QUE NO SE ROMPE"), False), + ("tope de vueltas", lambda: dentro("cerebro/ollama.py", "VUELTAS"), False), + ("cadena catalogo-primero", lambda: dentro("jarvis.py", "def atiende"), False), + ("ollama contesta", lambda: corre( + ["curl", "-sf", "-m", "10", "http://127.0.0.1:11434/api/tags"], 20), True), + ]), + + ("la palabra", "que le llamen JARVIS", [ + ("el centinela existe", lambda: hay("oido", "despierta.py"), False), + ("se llama JARVIS", lambda: dentro("oido/despierta.py", 'NOMBRE = "jarvis"'), False), + ("aguanta como lo oye Whisper", lambda: dentro("oido/despierta.py", "PARECIDAS"), False), + ("la orden va en la misma frase", lambda: dentro("oido/despierta.py", + "def parte_el_nombre"), False), + ("conectado al bucle de voz", lambda: dentro("jarvis.py", "centinela.filtra"), False), + # El que de verdad importaba: sin el prompt del nombre, Whisper nunca + # escribe JARVIS y el centinela no despierta jamas. + ("prompt del nombre para Whisper", lambda: dentro("oido/whisper.py", + "PROMPT_NOMBRE"), False), + ("boton para dormirlo y despertarlo", lambda: dentro("cara/panel.py", + 'dato == "sueno"'), False), + ("prueba de voz de punta a punta", lambda: hay("pruebas", "prueba_voz.py"), False), + ("y la palabra va APAGADA por defecto", lambda: dentro("jarvis.py", + "activo=a.palabra"), False), + ]), + + ("la cara", "el panel", [ + ("el HUD copiado", lambda: hay("cara", "hud", "index.html"), False), + ("servidor http y websocket", lambda: hay("cara", "servidor.py"), False), + # El WebSocket vive en puente.js, no en hud.js: hud.js no se toca a + # proposito y el puente suplanta window.webkit por debajo. + ("puente por WebSocket", lambda: dentro("cara/hud/puente.js", "WebSocket"), False), + ("y hud.js sigue sin tocarse", lambda: dentro("cara/hud/hud.js", + "messageHandlers"), False), + ("el panel se conecta al nucleo", lambda: dentro("cara/servidor.py", "async"), False), + # Ojo con las comprobaciones flojas: esta buscaba la palabra "tarea" en + # servidor.py y pasaba porque salia en un COMENTARIO, con las tarjetas + # sin conectar. Una barra que miente es peor que no tener barra. + ("tarjetas de tareas en marcha", lambda: dentro("cara/panel.py", + "GestorTareas"), False), + ("la consola lleva la conversacion", lambda: dentro("cara/hud/hud.js", + "aConsolaDicho"), False), + ]), + + ("el dialogo", "que pregunte en vez de recitar", [ + ("las preguntas salen del catalogo", lambda: hay("manos", "preguntar.py"), False), + ("pulsar pregunta lo que falta", lambda: dentro("cara/panel.py", "_pregunta("), False), + ("la respuesta hablada la recoge", lambda: dentro("jarvis.py", "panel.responde"), False), + ("lo destructivo confirma", lambda: dentro("cara/panel.py", "accion.confirmar"), False), + ("y el panel enseña que espera", lambda: dentro("cara/hud/hud.js", "preguntando"), False), + ("la prueba pasa", lambda: corre([VENV, "pruebas/prueba_dialogo.py"], 120), True), + ]), + + ("el candado", "que no obedezca a cualquiera", [ + ("el candado en el HUD", lambda: dentro("cara/hud/index.html", "candado"), False), + ("lo atiende el nucleo", lambda: dentro("cara/panel.py", "_intenta_desbloquear"), False), + ("valida con la contrasena del sistema", lambda: dentro("cara/panel.py", + "GestorRoot"), False), + ("con el candado puesto no ejecuta", lambda: dentro("cara/panel.py", + "no hay ordenes"), False), + ("y la VOZ tampoco obedece", lambda: dentro("jarvis.py", "(bloqueado)"), False), + ("la prueba pasa", lambda: corre([VENV, "pruebas/prueba_candado.py"], 120), True), + ]), + + ("el saber", "conocer COFRE entero", [ + ("indexador de COFRE", lambda: hay("saber", "indexa.py"), False), + ("busqueda por significado", lambda: hay("saber", "busca.py"), False), + ("indice construido", lambda: hay("datos", "saber.jsonl"), False), + ("el cerebro lo consulta antes de responder", lambda: dentro( + "cerebro/ollama.py", "def _saber"), False), + ("filtra el codigo de terceros", lambda: dentro("saber/indexa.py", "IGNORA"), False), + ("indexa la documentacion de Linux", lambda: hay("saber", "sistema.py"), False), + ("y los servidores SSH", lambda: dentro("saber/sistema.py", "def servidores"), False), + ("el cerebro puede ejecutar como root", lambda: dentro("manos/__init__.py", + "necesita_root"), False), + ("root ligado al candado", lambda: dentro("cara/panel.py", "root.recuerda"), False), + ]), + + ("el pipeline", "aprender del uso", [ + ("diario de lo que oye y hace", lambda: hay("manos", "diario.py"), False), + ("apunta la VIA de cada frase", lambda: dentro("jarvis.py", 'anota(texto, "ruido")'), False), + ("y tambien los aciertos", lambda: dentro("jarvis.py", '"catalogo", accion.id'), False), + ("herramienta para revisarlo", lambda: hay("mejora.py"), False), + ("propone, NO commitea solo", lambda: dentro("mejora.py", "Esto NO cambia nada"), False), + ("mide contra el banco fijo", lambda: hay("datos", "banco.json"), False), + ]), + + ("paridad", "usarlo a diario", [ + ("icono naranja", lambda: hay("jarvis-naranja.svg"), False), + ("icono instalado", lambda: os.path.exists(os.path.expanduser( + "~/.local/share/icons/jarvis-nucleo.png")), False), + ("lanzador propio", lambda: hay("arranca.sh"), False), + ("ventana propia, no pestaña", lambda: hay("ventana.sh"), False), + # Se miran las lineas de CODIGO, no el fichero entero: el comentario que + # explica por que ya no se usa xdg-open contiene la palabra, y una + # comprobacion ingenua se cree que sigue ahi. Es la tercera vez que la + # barra casi miente por buscar texto suelto en vez de codigo. + ("una sola forma de abrirla", lambda: not sin_comentarios( + "arranca.sh", "xdg-open"), False), + ("la barra de tareas la reconoce", lambda: "StartupWMClass" in open( + os.path.expanduser("~/.local/share/applications/jarvis-nucleo.desktop")).read(), False), + ("entrada en el escritorio", lambda: os.path.exists(os.path.expanduser( + "~/.local/share/applications/jarvis-nucleo.desktop")), False), + ("Newelle congelado y aparte", lambda: corre( + ["git", "-C", RAIZ, "rev-parse", "jarvis-newelle-final"], 15), False), + ]), +] + + +# ------------------------------------------------------------------ pintarlo + +ANCHO = 34 + + +def color(t, c): + return f"\033[{c}m{t}\033[0m" if sys.stdout.isatty() else t + + +def barra(pct, hecho, total): + lleno = round(ANCHO * pct / 100) + # verde entero, amarillo a medias, gris sin empezar + c = "32" if pct == 100 else ("33" if pct > 0 else "90") + return color("█" * lleno, c) + color("·" * (ANCHO - lleno), "90") + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("-v", "--detalle", action="store_true") + p.add_argument("--rapido", action="store_true", + help="sin ejecutar nada, solo mirar ficheros") + a = p.parse_args() + + if not os.path.exists(VENV): + print(color(" aviso: no hay venv; algunas comprobaciones no se pueden hacer\n", "33")) + + print() + print(color(" JARVIS · migracion a nucleo propio", "1")) + print() + + hechas = totales = 0 + for i, (nombre, para_que, comprobaciones) in enumerate(HITOS, 1): + activas = [c for c in comprobaciones if not (a.rapido and c[2])] + pasan = [] + for texto, prueba, lenta in activas: + try: + ok = bool(prueba()) + except Exception: + ok = False + pasan.append((texto, ok, lenta)) + n = sum(1 for _, ok, _ in pasan if ok) + pct = 100 * n / len(pasan) if pasan else 0 + hechas += n + totales += len(pasan) + + marca = color("✓", "32") if pct == 100 else (" " if pct == 0 else color("·", "33")) + print(f" {marca} {i}. {nombre:12s} {barra(pct, n, len(pasan))} " + f"{pct:3.0f}% {n}/{len(pasan)} {color(para_que, '90')}") + + if a.detalle: + for texto, ok, lenta in pasan: + s = color(" ok ", "32") if ok else color(" --", "31") + print(f" {s} {texto}" + (color(" (lenta)", "90") if lenta else "")) + print() + + print() + total_pct = 100 * hechas / totales if totales else 0 + print(f" {'TOTAL':14s} {barra(total_pct, hechas, totales)} " + f"{total_pct:3.0f}% {hechas}/{totales}") + print() + if a.rapido: + print(color(" (rapido: sin las comprobaciones que ejecutan cosas)\n", "90")) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/nucleo/jarvis-naranja.svg b/nucleo/jarvis-naranja.svg new file mode 100644 index 0000000..d4c46a5 --- /dev/null +++ b/nucleo/jarvis-naranja.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nucleo/jarvis.py b/nucleo/jarvis.py new file mode 100755 index 0000000..e8beca9 --- /dev/null +++ b/nucleo/jarvis.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""JARVIS. Asistente de voz local, sin framework debajo. + + jarvis.py --di "Buenas noches, señor" prueba la boca + jarvis.py --voces las que hay + jarvis.py --accion "cuanto espacio queda" prueba las manos, sin voz + +Esto sustituye a la capa que corria dentro de Newelle. Lo que se trajo tal cual +—el emparejador, las tareas, el sudo, el HUD— esta en `manos/` y `cara/`; lo que +habia que reescribir porque era de Newelle esta en `oido/`, `boca/` y `cerebro/`. + +## Por que fuera del flatpak + +No es dogma, es que el sandbox costaba mas de lo que daba: escondia `/tmp`, +obligaba a `LD_LIBRARY_PATH` a mano para las libs de CUDA de whisper, metia un +`flatpak-spawn --host` en cada accion, y cambiar una linea de Python pedia +recompilar el paquete entero. Aqui un cambio es editar y reiniciar. +""" +import argparse +import os +import shutil +import subprocess +import sys +import time + +AQUI = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, AQUI) + +from boca.voz import Boca, VOCES # noqa: E402 +from cara.panel import Panel # noqa: E402 +from cara.servidor import Cara # noqa: E402 +from cerebro.ollama import Cerebro, MODELO # noqa: E402 +from manos import Manos # noqa: E402 +from manos.diario import anota # noqa: E402 +from manos.acciones import Catalogo # noqa: E402 +from oido.captura import Captura, fuentes # noqa: E402 +from oido.despierta import Centinela, NOMBRE # noqa: E402 +from oido.whisper import Oido, prompt_dominio, es_ruido # noqa: E402 + +CATALOGO = os.path.join(AQUI, "datos", "acciones.json") +PROPIO = os.path.expanduser("~/.config/jarvis/acciones.json") + + +def catalogo() -> Catalogo: + # El del usuario primero: puede redefinir cualquiera del paquete. Mismo + # orden que tenia el panel de Newelle. + return Catalogo([PROPIO, CATALOGO]) + + +def atiende(texto, manos: Manos, cerebro: Cerebro | None, traza=print): + """La cadena entera para una frase: catalogo primero, cerebro despues. + + El orden no es un detalle de eficiencia, es lo que hace usable esto. Las 150 + acciones se emparejan sin tocar ningun modelo y contestan al instante; el + cerebro solo ve lo que no encaja. Por eso un modelo lento se soporta: la + mayoria de las ordenes ni pasan por el. + """ + t0 = time.time() + accion, argumento = manos.busca(texto) + if accion is not None: + traza(f" catalogo: {accion.id}" + (f" <- {argumento}" if argumento else "")) + dicho, _ = manos.haz(accion, argumento) + anota(texto, "catalogo", accion.id, argumento, time.time() - t0, dicho) + return dicho + if cerebro is None: + traza(" (sin cerebro conectado)") + anota(texto, "sin-cerebro", seg=time.time() - t0) + return "" + traza(" al cerebro...") + dicho = cerebro.responde(texto, al_ejecutar=lambda i, a: traza( + f" usa {i}" + (f"({a[:52]})" if a else ""))) + # La via mas valiosa del diario: lo que el catalogo NO cubre es justo la + # lista de acciones que faltan por escribir. + anota(texto, "cerebro", seg=time.time() - t0, dicho=dicho) + return dicho + + +def manda_accion(frase: str, boca: Boca | None = None, cerebro=None) -> int: + manos = Manos(catalogo()) + dicho = atiende(frase, manos, cerebro, traza=lambda t: print(t)) + if not dicho: + print(f' sin accion para "{frase}"') + return 1 + print(f" {dicho}") + if boca: + boca.di(dicho) + return 0 + + +def escucha(boca: Boca, modelo="small", fuente=None, callado=False, + cerebro=None, panel=None, centinela=None) -> int: + """El bucle de verdad: oir, entender, hacer, contestar. + + Es la version desnuda de lo que hacia el panel, sin GTK y sin HUD todavia. + Sirve para comprobar que la cadena entera funciona antes de ponerle cara. + """ + manos = Manos(catalogo()) + # Con el prompt del NOMBRE, no con el del catalogo entero: sin el, Whisper + # nunca escribe "JARVIS" y el centinela no despierta jamas (ver whisper.py). + oreja = Oido(modelo=modelo) + if not oreja.disponible(): + print(f" no encuentro whisper o el modelo {modelo}") + return 1 + + print(f" arrancando whisper ({modelo}, beam {oreja.beam}, sns, nombre)...") + if not oreja.arranca(): + print(" whisper no arranco") + return 1 + aviso = (f"esperando a que le llamen: «{NOMBRE.upper()}»" + if centinela and centinela.activo + else "atendiendo todo lo que oiga (--palabra para exigir el nombre)") + print(f" escuchando por {fuente or 'la entrada por defecto'}, {aviso}.") + print(" Ctrl+C para parar.\n") + if panel: + panel.estado("dormido" if centinela and centinela.activo else "escuchando") + + # La captura consulta a la boca en cada trozo: mientras JARVIS habla, ese + # audio no se mira siquiera. Es lo que evita que se oiga a si mismo. + oreja_mic = Captura(boca=boca, fuente=fuente) + try: + for wav in oreja_mic.frases(): + try: + texto = oreja.transcribe(wav) + finally: + try: + os.unlink(wav) + except OSError: + pass + + if es_ruido(texto): + if texto: + print(f" · ruido descartado: {texto!r}") + anota(texto, "ruido") + if panel: + panel.dicho(texto, atendido=False, motivo="ruido") + continue + + # ¿Hay una pregunta en el aire? Entonces esto es la respuesta. + # + # Va antes que TODO —antes de la palabra de activacion y antes del + # emparejador— porque si JARVIS acaba de preguntarte que fichero + # buscas, obligarte a decir "JARVIS, informes" seria absurdo: ya + # esta hablando contigo. + if panel and panel.esperando(): + print(f" » {texto} (respondiendo a lo preguntado)") + anota(texto, "respuesta") + panel.dicho(texto) + if panel.responde(texto): + continue + + # La palabra de activacion, antes de nada: sin esto obedece a + # cualquiera que pase, y a la tele. + if centinela: + atender, texto_orden, recien = centinela.filtra(texto) + if not atender: + if recien: + print(f" » {texto} (le han llamado)") + if panel: + panel.dicho(texto) + panel.estado("escuchando") # ya despierto + boca.di_si_libre("¿Señor?") + else: + print(f" · {texto} (no le han llamado)") + anota(texto, "sin-nombre") + # Se enseña IGUAL, marcado: que se vea que te ha oido + # bien y que lo unico que falta es llamarle. + if panel: + panel.dicho(texto, atendido=False, + motivo=f"diga «{NOMBRE.upper()}» primero") + continue + texto = texto_orden + + # El candado tapa el panel, pero lo que hay que impedir es que + # OBEDEZCA: si no, quien pase por delante da ordenes hablando, que + # es justo lo que el candado existe para evitar. + if panel and getattr(panel, "candado", False) and not panel.desbloqueado: + print(f" · {texto} (bloqueado)") + anota(texto, "bloqueado") + panel.dicho(texto, atendido=False, motivo="hace falta la contraseña") + continue + + print(f" » {texto}") + if panel: + panel.dicho(texto) + panel.estado("pensando") + dicho = atiende(texto, manos, cerebro) + if not dicho: + if panel: + panel.estado("escuchando") + continue + print(f" {dicho}") + if panel: + panel.respuesta(dicho) + panel.estado("hablando") + if not callado: + boca.di(dicho) + if panel: + # Sigue despierto un rato tras atender, para poder encadenar sin + # repetir el nombre: el rotulo tiene que decir eso y no "dormido". + panel.estado("escuchando" if not centinela or centinela.despierto + else "dormido") + except KeyboardInterrupt: + print("\n parado") + finally: + oreja_mic.para() + oreja.para() + return 0 + + +def panel(boca: Boca, cerebro=None, escuchando=False, modelo="small", + fuente=None, abre=True, centinela=None, sin_candado=False) -> int: + """Levanta el panel y, si se pide, ademas escucha el microfono.""" + manos = Manos(catalogo()) + cara = Cara() + p = Panel(cara, boca, manos, cerebro, candado=not sin_candado, + centinela=centinela) + cara.al_pulsar = p.pulsado + url = cara.arranca() + if not url: + return 1 + p.arranca() + print(f" panel en {url}") + + if abre: + # Una sola forma de abrir la ventana, en ventana.sh, para que sea igual + # se llegue por donde se llegue. Tener aqui su propia lista de + # navegadores fue lo que dejo que arranca.sh abriera una pestaña de + # Firefox cuando JARVIS ya estaba en marcha. + abridor = os.path.join(AQUI, "ventana.sh") + if os.access(abridor, os.X_OK): + subprocess.Popen([abridor, url], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + + if not escuchando: + print(" sin microfono (--escucha para que ademas oiga). Ctrl+C para parar.") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\n parado") + p.para() + return 0 + + return escucha(boca, modelo, fuente, False, cerebro, p, centinela) + + +def main() -> int: + p = argparse.ArgumentParser(description="JARVIS") + p.add_argument("--di", metavar="TEXTO", help="decir algo en voz alta") + p.add_argument("--voz", default=None, help="con que voz") + p.add_argument("--voces", action="store_true", help="listar las voces") + p.add_argument("--accion", metavar="FRASE", help="ejecutar una orden del catalogo") + p.add_argument("--callado", action="store_true", help="no hablar el resultado") + p.add_argument("--escucha", action="store_true", help="escuchar el microfono") + p.add_argument("--modelo", default="small", help="modelo de whisper") + p.add_argument("--fuente", default=None, help="entrada de audio (ver --fuentes)") + p.add_argument("--fuentes", action="store_true", help="listar las entradas") + p.add_argument("--cerebro", default=MODELO, + help="modelo de ollama; 'no' para trabajar solo con el catalogo") + p.add_argument("--ram", action="store_true", + help="el modelo entero en RAM, sin usar la grafica") + p.add_argument("--panel", action="store_true", help="abrir el panel") + p.add_argument("--sin-abrir", action="store_true", + help="levantar el panel pero no abrir el navegador") + p.add_argument("--palabra", action="store_true", + help=f"exigir que le llamen «{NOMBRE}» antes de cada orden") + p.add_argument("--sin-candado", action="store_true", + help="no pedir la contraseña al abrir (solo para probar)") + a = p.parse_args() + + boca = Boca(a.voz or "davefx") + cerebro = None + if a.cerebro and a.cerebro != "no": + cerebro = Cerebro(catalogo(), modelo=a.cerebro, en_ram=a.ram, + manos=Manos(catalogo())) + + if a.voces: + hay = boca.disponibles() + for nombre in VOCES: + marca = "·" if nombre in hay else "×" + actual = " <- puesta" if nombre == boca.voz else "" + print(f" {marca} {nombre}{actual}") + return 0 + + if a.di: + if a.voz and not boca.cambia(a.voz): + print(f" no existe la voz {a.voz}") + return 1 + print(f" [{boca.voz}] {a.di}") + if not boca.di(a.di): + print(" no sono. mira que el modelo de la voz este en su sitio") + return 1 + return 0 + + if a.fuentes: + for f in fuentes(): + print(f" {f}") + return 0 + + # La palabra de activacion va APAGADA por defecto. + # + # Decirla en cada frase es un peaje, y medido no evitaba nada: sobre 412 + # frases reales oidas —con todo el eco y el ruido ambiente dentro— solo 3 + # habrian disparado una accion, y las tres eran ordenes de verdad. Cero + # falsos positivos del ruido, porque el emparejador ya es conservador. + # + # El candado cubre lo otro, que es quien puede darle ordenes. + # + # Se queda disponible con --palabra y en el boton del panel, por si algun + # dia hay una tele cerca y el ruido empieza a colarse. + centinela = Centinela(activo=a.palabra) + + if a.panel: + return panel(boca, cerebro, a.escucha, a.modelo, a.fuente, + abre=not a.sin_abrir, centinela=centinela, + sin_candado=a.sin_candado) + + if a.escucha: + return escucha(boca, a.modelo, a.fuente, a.callado, cerebro, + centinela=centinela) + + if a.accion: + return manda_accion(a.accion, None if a.callado else boca, cerebro) + + p.print_help() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/nucleo/manos/__init__.py b/nucleo/manos/__init__.py new file mode 100644 index 0000000..ad165f1 --- /dev/null +++ b/nucleo/manos/__init__.py @@ -0,0 +1,114 @@ +"""Lo que JARVIS puede HACER, y el unico sitio por donde pasa. + +`Manos` es la puerta: el catalogo entra por aqui, el cerebro entra por aqui y +el panel entrara por aqui. Tener un solo punto importa porque es donde se +decide que se ejecuta y que no, y esa decision no puede estar repartida. + +Las cuatro piezas de debajo vienen tal cual de la version sobre Newelle +—`acciones.py`, `tareas.py`, `sudo_root.py`, `seleccion.py`— y se comprobo al +copiarlas que 374 frases dan exactamente el mismo resultado en los dos sitios. +""" +import os +import shlex +import subprocess + +from .acciones import Catalogo, ejecuta, formatea + +AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +CATALOGO = os.path.join(AQUI, "datos", "acciones.json") +PROPIO = os.path.expanduser("~/.config/jarvis/acciones.json") + +# Lo que no se ejecuta por mucho que lo pida el modelo. No es una lista de +# seguridad completa —eso es imposible— sino un tope contra el accidente: el +# modelo es servicial y a veces se inventa una limpieza que nadie pidio. +PROHIBIDO = ( + "rm -rf /", "mkfs", "dd if=", ":(){", "shutdown", "reboot", "halt", + "> /dev/sd", "chmod -R 777 /", "chown -R", "userdel", "passwd", +) + +TOPE_SALIDA = 4000 + + +class Manos: + def __init__(self, catalogo=None, al_ejecutar=None): + self.catalogo = catalogo or Catalogo([PROPIO, CATALOGO]) + self.al_ejecutar = al_ejecutar + # Root para la sesion: cuando el usuario desbloquea el candado con su + # contraseña, esa misma queda aqui y el cerebro puede hacer sudo hasta + # que caduque. Sin desbloquear, no hay root: el candado es la puerta. + from manos.sudo_root import GestorRoot + self.root = GestorRoot([]) + + # ------------------------------------------------------------- catalogo + + def busca(self, texto: str): + return self.catalogo.busca(texto) + + def haz(self, accion, argumento="") -> tuple: + """Ejecuta una accion ya emparejada. Devuelve (lo que se dice, crudo).""" + dicho, salida = ejecuta(accion, [], argumento) + if salida: + dicho = formatea(accion.respuesta, salida, argumento) + return dicho, salida + + def por_orden(self, orden: str, al_ejecutar=None) -> str: + """Para el cerebro: empareja contra el catalogo y ejecuta. + + Devuelve texto para el modelo, no para el usuario: si no encaja, se le + dice claramente para que pruebe con el shell en vez de insistir. + """ + if not orden.strip(): + return "no me has dicho ninguna orden" + # El modelo a veces cuela un comando aqui de todas formas. Se detecta y + # se le devuelve el motivo, que es mas util que un "no encontrado". + if any(c in orden for c in "|><;&$") or orden.split()[0] in ("df", "ls", "ps", "cat"): + return ("eso es un comando de shell, no una orden del catalogo. " + "Usa la herramienta comando_de_shell.") + accion, argumento = self.catalogo.busca(orden) + if accion is None: + return (f"'{orden}' no esta en el catalogo. Si hace falta, usa " + "comando_de_shell.") + if al_ejecutar or self.al_ejecutar: + (al_ejecutar or self.al_ejecutar)(accion.id, argumento) + dicho, salida = self.haz(accion, argumento) + return (salida or dicho)[:TOPE_SALIDA] + + # ---------------------------------------------------------------- shell + + def por_shell(self, comando: str, al_ejecutar=None) -> str: + if not comando.strip(): + return "no me has dicho ningun comando" + bajo = comando.lower() + for veneno in PROHIBIDO: + if veneno in bajo: + return (f"no ejecuto eso: contiene '{veneno}'. Si de verdad hace " + "falta, que lo pida el usuario expresamente.") + if al_ejecutar or self.al_ejecutar: + (al_ejecutar or self.al_ejecutar)("shell", comando) + + from manos.sudo_root import necesita_root + # Los comandos con sudo van por GestorRoot, que da la contraseña de la + # sesion por stdin. Sin ella, `bash -c "sudo ..."` se colgaria pidiendo + # una contraseña que nadie teclea. Y si el candado no se ha abierto, no + # hay contraseña: el root queda cerrado hasta que el usuario desbloquee. + if necesita_root(comando): + if not self.root.sesion_activa(): + return ("eso necesita root y el candado no esta abierto. " + "Desbloquee con su contraseña y lo repito.") + try: + r = self.root.ejecuta(comando, timeout=60) + except Exception as e: + return f"no se pudo ejecutar como root: {e}" + else: + try: + r = subprocess.run(["bash", "-c", comando], capture_output=True, + text=True, timeout=60) + except subprocess.TimeoutExpired: + return "el comando tardo mas de un minuto y lo he cortado" + except OSError as e: + return f"no se pudo ejecutar: {e}" + + salida = (r.stdout or r.stderr or "").strip() + if not salida: + return "hecho, sin salida" if r.returncode == 0 else f"fallo (codigo {r.returncode})" + return salida[:TOPE_SALIDA] diff --git a/nucleo/manos/acciones.py b/nucleo/manos/acciones.py new file mode 100644 index 0000000..3012e76 --- /dev/null +++ b/nucleo/manos/acciones.py @@ -0,0 +1,310 @@ +"""Acciones rapidas: caminos predefinidos que se saltan el modelo. + +Por que existe esto. Preguntar "cuanto espacio queda" y esperar a que un modelo +de 4B razone, decida llamar a una herramienta, la ejecute y redacte la +respuesta cuesta segundos. Pero esa pregunta no necesita pensarse: es un `df`. +El catalogo empareja la frase con un comando y contesta con una plantilla, y +eso baja de segundos a decimas. + +No es entrenar un modelo: es una tabla. Y esa es la gracia, porque una tabla es +predecible, se lee, se corrige y no alucina. Lo que no encaje en la tabla sigue +yendo al modelo como siempre. + +Formato del catalogo (JSON): + + { + "acciones": [ + { + "id": "disco_libre", + "frases": ["cuanto espacio queda", "espacio en disco"], + "acuse": "Un momento, lo miro.", + "comando": "df -h / | tail -1", + "host": true, + "respuesta": "Quedan {campo4} libres de {campo2}.", + "confirmar": false + } + ] + } + +En `respuesta` se puede usar: + {salida} la salida completa, recortada + {primera} la primera linea + {ultima} la ultima linea no vacia + {campoN} la columna N de la ultima linea (1 en adelante) + {lineas} cuantas lineas ha devuelto +""" +import difflib +import json +import os +import re +import shlex +import subprocess +import urllib.parse +import unicodedata + +UMBRAL_PARECIDO = 0.86 # por debajo empieza a confundir ordenes +TIEMPO_LIMITE = 20 # ningun atajo deberia tardar mas +SALIDA_MAXIMA = 600 # lo que se guarda para enseñar y para la plantilla + + +def normaliza(texto: str) -> str: + """Minusculas, sin tildes, sin puntuacion y con los espacios apretados. + + Whisper devuelve "¿Cuánto espacio queda?" y el catalogo dice "cuanto + espacio queda". Sin normalizar no casaria ni una. + + Lo de apretar los espacios no es cosmetico: al sustituir la puntuacion por + un espacio, "cuanto, espacio queda" quedaba con dos seguidos y dejaba de + coincidir con la frase del catalogo. El sintoma era un asistente que a + veces no reacciona a una orden que ha entendido bien, segun donde el + reconocedor decida poner una coma. + """ + texto = unicodedata.normalize("NFD", texto.lower()) + texto = "".join(c for c in texto if unicodedata.category(c) != "Mn") + return " ".join(re.sub(r"[^\w\s]", " ", texto).split()) + + +# El envoltorio de cortesia con el que habla cualquiera. Nadie dice "pon la +# pelicula" a secas: dice "necesito que pongas la pelicula" o "puedes ponerme". +# Con eso delante, ninguna frase del catalogo encaja como subcadena y la orden +# se pierde entera. +# +# Medido sobre las 462 frases que el panel anoto por no entender: de las 18 que +# eran ordenes de verdad, la mayoria fallaba solo por esto. Cinco de ellas eran +# el mismo intento de poner una pelicula, dicho de cinco maneras. +CORTESIA = re.compile( + r"^(por favor,?\s+)?" + r"(necesito que( me)?|quiero que( me)?|me gustaria que( me)?|" + r"puedes|podrias|me puedes|me podrias|te importa|hazme el favor de|" + r"a ver si|va(le|)?,? |anda,? |porfa,? |quiero|necesito)\s+", + re.I) + + +class Accion: + def __init__(self, datos: dict): + self.id = datos.get("id", "") + self.frases = [normaliza(f) for f in datos.get("frases", [])] + self.acuse = datos.get("acuse", "Voy.") + self.comando = datos.get("comando", "") + self.host = bool(datos.get("host", True)) + self.respuesta = datos.get("respuesta", "{salida}") + self.confirmar = bool(datos.get("confirmar", False)) + self.captura = bool(datos.get("captura", False)) + self.grupo = datos.get("grupo", "") + + def coincide(self, texto: str): + """(longitud de la frase que encaja, lo que venia detras). + + Se devuelve la longitud y no un si/no para poder quedarse con la + coincidencia mas especifica: "abre la carpeta de descargas" debe ganar + a "abre la carpeta" cuando las dos encajan. + + El resto es el argumento: en "busca gatos en youtube" la frase es + "busca" ... y lo demas es lo que hay que buscar. Solo se recoge si la + accion lo pide, para que una frase suelta no arrastre basura. + """ + mejor, resto = 0, "" + for f in self.frases: + if not f or f not in texto: + continue + if len(f) > mejor: + mejor = len(f) + resto = texto.split(f, 1)[1].strip() if self.captura else "" + return mejor, resto + + +def parecido(a: str, b: str) -> float: + """Cuanto se parecen dos cadenas, de 0 a 1, por letra y por sonido. + + Para los casi-aciertos del reconocedor: "yo tuve" por "yo tube", "casis" + por "oasis". Se usa difflib y no una distancia de edicion propia porque + viene en la biblioteca estandar y aqui no hay que hilar tan fino. + + Se queda con el MAYOR de los dos parecidos, el de la letra y el del sonido. + Muchos fallos del reconocedor no son de significado sino de ortografia + —"Abre van Kamp" por "abre bandcamp", "poops" por "pubs"— y por escrito + quedan lejos aunque suenen igual. Medido contra el banco: sube el acierto + de 79,3 a 84,7 % sin que ninguna de las 50 frases de charla dispare nada. + + Que sea el maximo y no la media importa: la media hundiria los aciertos + normales, donde la letra ya casaba bien y el sonido no aporta. + """ + literal = difflib.SequenceMatcher(None, a, b).ratio() + if literal >= 0.98: + return literal # ya casa: no hace falta el sonido + from .fonetica import fonetica + return max(literal, + difflib.SequenceMatcher(None, fonetica(a), fonetica(b)).ratio()) + + +class Catalogo: + def __init__(self, rutas): + self.acciones = [] + self.errores = [] + for ruta in rutas: + if ruta and os.path.exists(ruta): + self._carga(ruta) + + def _carga(self, ruta): + try: + with open(ruta) as f: + datos = json.load(f) + except (OSError, json.JSONDecodeError) as e: + self.errores.append(f"{os.path.basename(ruta)}: {e}") + return + + vistos = {a.id for a in self.acciones} + for entrada in datos.get("acciones", []): + accion = Accion(entrada) + if not accion.id or not accion.frases: + self.errores.append(f"{os.path.basename(ruta)}: accion sin id o sin frases") + continue + # el primero que se carga manda: asi el catalogo del usuario puede + # redefinir uno del paquete sin tener que borrarlo + if accion.id not in vistos: + self.acciones.append(accion) + vistos.add(accion.id) + + def busca(self, texto: str): + """(accion mas especifica que encaje, argumento) o (None, ""). + + Si no encaja nada, se reintenta quitando la cortesia de delante. Se + hace como SEGUNDA pasada y no normalizando desde el principio a + proposito: asi lo que ya funcionaba sigue funcionando igual, y esto + solo añade aciertos donde antes no habia ninguno. + """ + encontrada, argumento = self._busca_directa(normaliza(texto)) + if encontrada is not None: + return encontrada, argumento + sin_cortesia = CORTESIA.sub("", normaliza(texto)).strip() + if sin_cortesia and sin_cortesia != normaliza(texto): + return self._busca_directa(sin_cortesia) + return None, "" + + def _busca_directa(self, texto: str): + mejor, puntos, argumento = None, 0, "" + for accion in self.acciones: + p, resto = accion.coincide(texto) + if p > puntos: + mejor, puntos, argumento = accion, p, resto + + # Nada exacto: se prueba por parecido. El reconocedor se come letras + # ("yo tuve" por "yo tube") y sin esto la orden se pierde entera. El + # umbral es alto a proposito: mas abajo empieza a confundir ordenes + # distintas entre si, que es peor que no reaccionar. + if mejor is None: + self._resto_parecido = "" + mejor, puntos = self._por_parecido(texto) + if mejor is not None and mejor.captura: + argumento = self._resto_parecido + + # una accion que captura y no recibe nada no sirve: "busca" a secas + if mejor is not None and mejor.captura and not argumento: + return None, "" + return mejor, argumento + + def _por_parecido(self, texto: str, umbral: float = UMBRAL_PARECIDO): + """La accion cuya frase mas se parezca, si pasa del umbral.""" + palabras = texto.split() + mejor, mejor_p = None, umbral + for accion in self.acciones: + if accion.captura: + # Con argumento no vale comparar la frase entera: "busca en + # youtube conciertos de techno" no se parece a "busca en + # youtube". Se compara solo el arranque y lo demas es el + # argumento. Se exigen tres palabras para que un disparador + # corto no se coma media conversacion. + for frase in accion.frases: + trozos = frase.split() + # Dos palabras, no tres. Con tres, disparadores cortos como + # "receta de" no entraban NUNCA en esta fase, y "receta de + # documentos" mal oido se perdia entero. El riesgo de bajar + # a dos lo cubre que la comparacion va anclada al principio: + # solo encaja si el arranque suena a la frase. + if len(trozos) < 2 or len(palabras) <= len(trozos): + continue + arranque = " ".join(palabras[:len(trozos)]) + p = parecido(arranque, frase) + if p > mejor_p: + mejor, mejor_p = accion, p + self._resto_parecido = " ".join(palabras[len(trozos):]) + continue + + # Frase ENTERA contra frase entera, no por ventana deslizante. La + # ventana encontraba un sub-trozo que puntuaba alto aunque la orden + # fuera otra: "que carne el tiempo" (mal oido de "que kernel tengo") + # colaba en la accion del tiempo con 0.90, y se ejecutaba la accion + # equivocada —lo peor que puede pasar en algo que lanza comandos—. + # Una orden se dice completa; si va incrustada en una frase larga, + # el emparejador exacto (por subcadena) ya la coge antes de llegar + # aqui. Comparar el todo con el todo deja fuera esos falsos + # positivos sin perder los casi-aciertos reales. + propio = " ".join(palabras) + for frase in accion.frases: + if not frase: + continue + p = parecido(propio, frase) + if p > mejor_p: + mejor, mejor_p = accion, p + return mejor, mejor_p + + def __len__(self): + return len(self.acciones) + + +def ejecuta(accion: Accion, prefijo_host, argumento: str = "") -> tuple: + """Corre el comando y devuelve (texto para decir, salida cruda). + + prefijo_host es lo que hay que anteponer para salir del sandbox; se pasa + como argumento en vez de importarlo para poder probar esto sin flatpak. + """ + if not accion.comando: + return accion.acuse, "" + + # El argumento viene de un microfono, o sea de fuera: va entrecomillado con + # shlex antes de tocar un shell. Sin esto, decir "borra punto y coma rm" + # seria una orden ejecutable. + comando = accion.comando.replace("{argumento}", shlex.quote(argumento)) + comando = comando.replace("{argumento_url}", + shlex.quote(urllib.parse.quote_plus(argumento))) + + orden = ["bash", "-lc", comando] + if accion.host: + orden = list(prefijo_host) + orden + + try: + # errors="replace": un grep o un cat que toque un binario devuelve + # bytes que no son utf-8, y sin esto la orden entera reventaria con + # UnicodeDecodeError. Mejor un caracter raro que un fallo. + r = subprocess.run(orden, capture_output=True, text=True, + errors="replace", timeout=TIEMPO_LIMITE) + salida = (r.stdout or r.stderr or "").strip() + except subprocess.TimeoutExpired: + return "La orden ha tardado demasiado y la he cancelado.", "" + except OSError as e: + return f"No he podido ejecutarlo: {e}", "" + + return formatea(accion.respuesta, salida, argumento), salida[:SALIDA_MAXIMA] + + +def formatea(plantilla: str, salida: str, argumento: str = "") -> str: + lineas = [l for l in salida.splitlines() if l.strip()] + ultima = lineas[-1] if lineas else "" + campos = ultima.split() + + valores = { + "argumento": argumento, + "salida": salida[:SALIDA_MAXIMA] if salida else "sin resultado", + "primera": lineas[0] if lineas else "", + "ultima": ultima, + "lineas": len(lineas), + } + for i, campo in enumerate(campos, start=1): + valores[f"campo{i}"] = campo + + try: + return plantilla.format(**valores) + except (KeyError, IndexError, ValueError): + # plantilla que pide un campo que no existe: mejor decir la salida + # cruda que soltar una excepcion en mitad de una conversacion + return valores["salida"] diff --git a/nucleo/manos/diario.py b/nucleo/manos/diario.py new file mode 100644 index 0000000..39dc4d1 --- /dev/null +++ b/nucleo/manos/diario.py @@ -0,0 +1,123 @@ +"""Lo que JARVIS oye y que hace con ello. El combustible de todo lo demas. + +Sin esto no hay forma de mejorar nada: no se puede arreglar lo que no se mide, +y hasta ahora el nucleo no apuntaba absolutamente nada. + +## Por que este y no el de antes + +El diario de la epoca de Newelle guardaba `{"texto": "..."}` y nada mas. Con eso +no se puede hacer casi nada: + + - no se sabe **que paso** con la frase, asi que no se distingue una orden que + fallo de una que funciono; + - no se sabe **cuando**, asi que no se ve si alguien repitio la misma cosa de + tres formas seguidas —que es la señal mas valiosa que hay—; + - y solo guardaba los FALLOS, asi que no habia con que comparar. + +Ademas se llenaba de eco y de alucinaciones de Whisper, y la mejora nocturna +acabo generando alias a partir del ruido. De 504 frases, 306 parecian ordenes +sin atender y al mirarlas casi todas eran JARVIS oyendose a si mismo. + +## Que se guarda + +Todo lo que llega, con su destino. Una linea por frase, JSON: + + t cuando + oido lo que transcribio Whisper + via catalogo | cerebro | ruido | bloqueado | respuesta + accion a que accion fue, si fue a alguna + arg con que argumento + seg lo que tardo en contestar + dicho lo que contesto + +Guardar TAMBIEN los aciertos es lo que permite comparar: sin ellos, un dia malo +y un dia bueno tienen el mismo aspecto. + +## Lo que NO se guarda + +Nada de audio. El diario es texto y se puede leer con `less`; si algun dia +guardara los WAV, el asistente pasaria a ser una grabadora de la habitacion y +eso es otra cosa muy distinta de lo que es. +""" +import json +import os +import threading +import time +from datetime import datetime + +AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DIARIO = os.path.join(AQUI, "datos", "diario.jsonl") + +# Un diario que crece sin fin acaba siendo un problema en vez de una ayuda. A +# ~150 bytes por linea, 200.000 son unos 30 MB: mas que suficiente para meses de +# uso y poco para el disco. +TOPE_LINEAS = 200_000 + +_cerrojo = threading.Lock() + + +def anota(oido: str, via: str, accion: str = "", arg: str = "", + seg: float = 0.0, dicho: str = "", ruta: str = DIARIO): + """Apunta una frase y su destino. No falla nunca hacia fuera. + + Si escribir el diario diera error, lo ultimo que debe hacer es tirar al + asistente: se pierde una linea y ya. + """ + if not oido: + return + linea = { + "t": datetime.now().isoformat(timespec="seconds"), + "oido": oido[:400], + "via": via, + } + if accion: + linea["accion"] = accion + if arg: + linea["arg"] = arg[:200] + if seg: + linea["seg"] = round(seg, 2) + if dicho: + linea["dicho"] = dicho[:300] + + try: + with _cerrojo: + os.makedirs(os.path.dirname(ruta), exist_ok=True) + with open(ruta, "a", encoding="utf-8") as f: + f.write(json.dumps(linea, ensure_ascii=False) + "\n") + except OSError: + pass + + +def lee(ruta: str = DIARIO, desde=None) -> list: + """Las lineas del diario, ya en diccionarios. Salta las corruptas.""" + salida = [] + try: + with open(ruta, encoding="utf-8") as f: + for l in f: + try: + d = json.loads(l) + except json.JSONDecodeError: + continue + if desde and d.get("t", "") < desde: + continue + salida.append(d) + except OSError: + pass + return salida + + +def recorta(ruta: str = DIARIO, tope: int = TOPE_LINEAS): + """Deja solo las ultimas `tope` lineas.""" + lineas = [] + try: + with open(ruta, encoding="utf-8") as f: + lineas = f.readlines() + except OSError: + return + if len(lineas) <= tope: + return + try: + with _cerrojo, open(ruta, "w", encoding="utf-8") as f: + f.writelines(lineas[-tope:]) + except OSError: + pass diff --git a/nucleo/manos/fonetica.py b/nucleo/manos/fonetica.py new file mode 100644 index 0000000..65832a8 --- /dev/null +++ b/nucleo/manos/fonetica.py @@ -0,0 +1,84 @@ +"""Comparar por como SUENA, no por como se escribe. + +## Que problema resuelve + +Midiendo donde se pierde el acierto salio esto: de 150 ordenes dichas, el 79 % +llega a su accion, un 5 % se oye mal de verdad, y un **15 % se oye bien pero el +emparejador no lo relaciona**. Mirando esos casos uno a uno, no son ordenes +distintas: son la misma orden mal escrita. + + dijo "receta de documentos" oyo "Leceta de documentos" + dijo "como van los pubs" oyo "¿Como van los poops?" + dijo "abre bandcamp" oyo "Abre van Kamp" + dijo "abre el drive" oyo "Abril Drive" + dijo "abreme oasis" oyo "Abre mi roasis" + +Eso NO se arregla con embeddings: un modelo semantico ve "Leceta" como una +palabra inexistente, no como "receta". Y bajar el umbral de parecido tampoco, +porque el umbral esta alto por una razon —hay una cicatriz en el codigo de +cuando "que carne el tiempo" se colaba en la accion del tiempo y ejecutaba lo +que no era—. + +Lo que sirve es comparar como suena. En castellano hay unas pocas confusiones +que lo explican casi todo, y son las que aplica `fonetica()`. + +## Lo que NO hace + +No es un Soundex ni un Metaphone: esos comprimen tanto que juntan palabras que +no se parecen, y aqui juntar de mas significa ejecutar la orden equivocada, que +es lo peor que puede hacer esto. Se queda en las equivalencias que de verdad +confunde el reconocedor de voz en castellano, y nada mas. +""" +import re +import unicodedata + +# El orden importa: cada regla se aplica sobre el resultado de la anterior. +REGLAS = ( + # la hache no suena + (r"h", ""), + # b y v son el mismo sonido: "van Kamp" / "bandcamp" + (r"v", "b"), + # ge/gi suenan como jota + (r"g([ei])", r"j\1"), + # gue/gui: la u es muda + (r"gu([ei])", r"g\1"), + # que/qui igual, y qu -> k + (r"qu([ei])", r"k\1"), + (r"q", "k"), + # ce/ci en seseo suenan como ese; ca/co/cu como ka + (r"c([ei])", r"s\1"), + (r"c", "k"), + # z siempre ese (seseo): asi "poops"/"pubs" y "haz"/"has" caen juntos + (r"z", "s"), + # x entre vocales suena ks, pero al principio suele ser ese + (r"^x", "s"), + (r"x", "ks"), + # ll e y son el mismo sonido para casi todo el mundo + (r"ll", "y"), + # w es de fuera: suena como u o como b segun la palabra; se unifica a b + (r"w", "b"), + # la erre doble y la simple no se distinguen al transcribir + (r"rr", "r"), + # la ñ se escribe de mil formas cuando el reconocedor duda + (r"ñ", "n"), + # las dobles no aportan: "poops" -> "pops" + (r"(.)\1+", r"\1"), + # las vocales finales se comen mucho; y la ese final tambien + (r"s$", ""), +) + + +def fonetica(texto: str) -> str: + """Como suena, aproximadamente, para poder comparar dos oidos distintos.""" + t = unicodedata.normalize("NFD", (texto or "").lower()) + t = "".join(c for c in t if unicodedata.category(c) != "Mn" or c == "̃") + t = unicodedata.normalize("NFC", t) + t = re.sub(r"[^a-zñ\s]", " ", t) + + palabras = [] + for p in t.split(): + for patron, cambio in REGLAS: + p = re.sub(patron, cambio, p) + if p: + palabras.append(p) + return " ".join(palabras) diff --git a/nucleo/manos/medidas.py b/nucleo/manos/medidas.py new file mode 100644 index 0000000..e01fd02 --- /dev/null +++ b/nucleo/manos/medidas.py @@ -0,0 +1,115 @@ +"""Las medidas de eficiencia de JARVIS, sacadas del diario. + +El diario ya apunta por cada frase: cuanto tardo (`seg`), por donde fue (`via`) +y cuando (`t`). De ahi salen las metricas de rendimiento sin instrumentar nada +del camino caliente: esto solo LEE, y se calcula fuera del hilo que contesta. + +## Que se mide y por que importa cada una + + latencia p50/p95/max del tiempo de respuesta. La mediana dice como va de + normal; el p95 dice como va cuando va mal, que es lo que se nota. + reparto que fraccion va al CEREBRO (lento, ~2-3 s) y cual al CATALOGO + (instantaneo, salta el modelo). Es la palanca de eficiencia mas + grande: cuanto mas catalogo, mas rapido el conjunto. + ruido que fraccion de lo que oye es Whisper oyendose a si mismo. No es + velocidad, es calidad de la escucha, y envenena todo lo demas. + volumen frases en las ultimas 24 h. Contexto para lo anterior: un p95 malo + con 4 frases no dice nada. + rag hit@5/hit@1 del ultimo benchmark del RAG (informe_rag.txt). Es la + unica que no sale del diario: es una medida de test, periodica. + +Todo sobre una VENTANA reciente (las ultimas MUESTRA frases), no sobre el diario +entero: interesa como va AHORA, no la media historica que nunca cambia. +""" +import os +import re +from datetime import datetime, timedelta + +from manos.diario import lee + +AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +INFORME_RAG = os.path.join(AQUI, "datos", "informe_rag.txt") + +MUESTRA = 600 # las ultimas N frases: "como va ahora" +CEREBRO = "cerebro" + + +def _percentil(valores, q): + if not valores: + return 0.0 + v = sorted(valores) + k = (len(v) - 1) * q + lo, hi = int(k), min(int(k) + 1, len(v) - 1) + return v[lo] + (v[hi] - v[lo]) * (k - lo) + + +def _rag(): + """hit@5 y hit@1 del ultimo informe del RAG, si existe.""" + try: + txt = open(INFORME_RAG, encoding="utf-8").read() + except OSError: + return None + # el bloque CANDIDATO es el que se promociono; si no, el ultimo que haya + def pct(clave): + # " hit@5 43/45 (95%)" -> 95 + ms = re.findall(clave + r"\s+\d+/\d+\s+\((\d+)%\)", txt) + return int(ms[-1]) if ms else None + h5, h1 = pct("hit@5"), pct("hit@1") + if h5 is None: + return None + return {"hit5": h5, "hit1": h1 or 0} + + +def lee_medidas(): + """El dict de medidas, con las unidades ya listas para el HUD.""" + diario = lee()[-MUESTRA:] + if not diario: + return {"n": 0} + + segs = [d["seg"] for d in diario if isinstance(d.get("seg"), (int, float)) and d["seg"] > 0] + seg_cerebro = [d["seg"] for d in diario + if d.get("via") == CEREBRO and isinstance(d.get("seg"), (int, float))] + seg_catalogo = [d["seg"] for d in diario + if d.get("via") == "catalogo" and isinstance(d.get("seg"), (int, float))] + + vias = {} + for d in diario: + vias[d.get("via", "?")] = vias.get(d.get("via", "?"), 0) + 1 + total = len(diario) + + def pct_via(v): + return round(100 * vias.get(v, 0) / total) if total else 0 + + # volumen: cuantas en las ultimas 24 h, y por hora las ultimas 12 para la traza + ahora = datetime.now() + hace24 = ahora - timedelta(hours=24) + vol24 = 0 + porhora = [0] * 12 + for d in diario: + try: + t = datetime.fromisoformat(d.get("t", "")) + except (ValueError, TypeError): + continue + if t >= hace24: + vol24 += 1 + h = int((ahora - t).total_seconds() // 3600) + if 0 <= h < 12: + porhora[11 - h] += 1 + + medidas = { + "n": total, + "lat_p50": round(_percentil(segs, 0.50), 2), + "lat_p95": round(_percentil(segs, 0.95), 2), + "lat_max": round(max(segs), 2) if segs else 0.0, + "lat_cerebro": round(sum(seg_cerebro) / len(seg_cerebro), 2) if seg_cerebro else 0.0, + "lat_catalogo": round(sum(seg_catalogo) / len(seg_catalogo), 2) if seg_catalogo else 0.0, + "pct_cerebro": pct_via("cerebro"), + "pct_catalogo": pct_via("catalogo"), + "pct_ruido": pct_via("ruido"), + "vol24": vol24, + "porhora": porhora, + } + rag = _rag() + if rag: + medidas["rag"] = rag + return medidas diff --git a/nucleo/manos/preguntar.py b/nucleo/manos/preguntar.py new file mode 100644 index 0000000..18fe2ae --- /dev/null +++ b/nucleo/manos/preguntar.py @@ -0,0 +1,147 @@ +"""Que preguntar cuando falta un dato para ejecutar una accion. + +Pulsar una accion en el panel no puede limitarse a recitar la frase que la +dispara —"diga «busca el fichero» y a continuacion lo que busca"—: eso es un +manual de instrucciones, y uno pulsa justo porque no quiere leerse el manual. +Lo natural es que pregunte lo que falta y lo haga. + + busca el fichero -> ¿Que fichero, señor? + manda la ventana a la pantalla -> ¿A que pantalla, señor? + mata el proceso -> ¿Que proceso, señor? + +## Como sale la pregunta + +Del propio catalogo, no de una lista escrita a mano ni de un modelo. Las frases +del catalogo son regulares —verbo + articulo + objeto— asi que basta con buscar +el ULTIMO articulo y quedarse con lo que va detras. + +Y se pregunta por el OBJETO, no conjugando el verbo. "¿Que fichero, señor?" es +tan natural como "¿Que fichero busco, señor?" y no obliga a conjugar: pasar de +"busca" a "busco", de "leeme" a "leo" o de "pon" a "pongo" pide un diccionario +de verbos irregulares para ganar nada. + +Si la preposicion viene delante del articulo, se conserva: sin ella, "manda la +ventana a la pantalla" preguntaria "¿Que pantalla?" y se pierde el "a", que es +justo lo que dice que la respuesta es un destino. +""" +import re +import unicodedata + +ARTICULOS = {"el", "la", "los", "las", "un", "una", "unos", "unas"} +# Las contracciones ya llevan la preposicion dentro. +CONTRACCIONES = {"al": "a", "del": "de"} +PREPOSICIONES = {"a", "de", "en", "con", "por", "para", "sobre", "hacia", "desde"} + +# Cuando no hay de donde sacarlo. Vale para "esta corriendo" o "tengo instalado", +# donde el objeto no aparece en la frase. +GENERICA = "¿El que, señor?" + + +def _sin_tildes(t: str) -> str: + t = unicodedata.normalize("NFD", t) + return "".join(c for c in t if unicodedata.category(c) != "Mn") + + +def pregunta_por(accion) -> str: + """La pregunta que rellena el hueco de una accion que espera argumento. + + La clave: el argumento va SIEMPRE al final de la frase, porque asi lo + recoge el emparejador. Asi que la pregunta se decide mirando como TERMINA. + """ + frases = getattr(accion, "frases", None) or [] + if not frases: + return GENERICA + + palabras = _sin_tildes(frases[0].lower()).split() + if not palabras: + return GENERICA + ultima = palabras[-1] + + # 1. Termina en preposicion: el hueco va justo detras. + # "busca el precio de" -> ¿El precio de que, señor? + # "busca imagenes de" -> ¿Imagenes de que, señor? + if ultima in PREPOSICIONES: + trozo = [] + # Sin la primera palabra: es el verbo, y en la pregunta sobra. + # "busca imagenes de" tiene que dar "¿Imagenes de que?", no + # "¿Busca imagenes de que?". + for p in reversed(palabras[1:-1]): + if p in ARTICULOS or p in CONTRACCIONES: + trozo.insert(0, p) + break + if p in PREPOSICIONES: + break + trozo.insert(0, p) + if len(trozo) >= 2: + break + if trozo: + frase = " ".join(trozo).capitalize() + return f"¿{frase} {ultima} que, señor?" + return GENERICA + + # 2. "busca en ": lo que falta es QUE buscar, no el sitio. + # Sin esta regla, "busca en la wikipedia" preguntaba "¿En que + # wikipedia?", que no tiene sentido: solo hay una. + if "en" in palabras: + i = palabras.index("en") + sitio = " ".join(palabras[i:]) + verbo = palabras[0] + if verbo.startswith("busca"): + return f"¿Que busco {sitio}, señor?" + return f"¿Que {sitio}, señor?" + + # 3. Lo normal: articulo + objeto al final. + # "busca el fichero" -> ¿Que fichero, señor? + # "manda la ventana a la pantalla" -> ¿A que pantalla, señor? + for i in range(len(palabras) - 1, -1, -1): + p = palabras[i] + objeto = palabras[i + 1] if i + 1 < len(palabras) else "" + if not objeto: + continue + if p in CONTRACCIONES: + return f"¿{CONTRACCIONES[p].capitalize()} que {objeto}, señor?" + if p in ARTICULOS: + anterior = palabras[i - 1] if i > 0 else "" + if anterior in PREPOSICIONES: + return f"¿{anterior.capitalize()} que {objeto}, señor?" + return f"¿Que {objeto}, señor?" + return GENERICA + + +def confirmacion(accion) -> str: + """Lo que se pregunta antes de algo que no tiene vuelta atras. + + La orden se CITA en vez de meterla en la frase. Las del catalogo estan en + imperativo —"cierra el navegador"— y encajarlas da cosas como "Voy a cierra + el navegador". Conjugarlas al infinitivo pediria un diccionario de verbos + irregulares (cierra->cerrar, pon->poner, ve->ir) para ganar poco: entre + comillas se lee bien y ademas queda claro que es literalmente la orden. + """ + frase = (getattr(accion, "frases", None) or [""])[0] + return f"«{frase}». Esto no tiene vuelta atras. ¿Confirma, señor?" + + +# Lo que cuenta como si y como no. Se comparan sin tildes y sin puntuacion, +# porque llegan de Whisper: "sí", "Si.", "SÍ" y "sip" son lo mismo. +SI = {"si", "sip", "claro", "vale", "venga", "adelante", "confirmo", "hazlo", + "por supuesto", "correcto", "afirmativo", "eso es", "dale", "ok", "okey"} +NO = {"no", "nop", "nada", "dejalo", "olvidalo", "cancela", "cancelalo", + "para", "mejor no", "negativo", "ni hablar", "espera"} + + +def es_si(texto: str): + """True si es un si, False si es un no, None si no es ni una cosa ni otra.""" + limpio = _sin_tildes(re.sub(r"[¡!¿?.,;]", "", (texto or "").lower())).strip() + if not limpio: + return None + if limpio in SI: + return True + if limpio in NO: + return False + # Tambien al principio: "si, hazlo" o "no, dejalo" + primera = limpio.split()[0] + if primera in SI: + return True + if primera in NO: + return False + return None diff --git a/nucleo/manos/seleccion.py b/nucleo/manos/seleccion.py new file mode 100644 index 0000000..887841d --- /dev/null +++ b/nucleo/manos/seleccion.py @@ -0,0 +1,95 @@ +"""Elegir de una lista por voz, y buscar dentro de un fichero (Ctrl+F). + +La capa que le faltaba a las acciones de fichero. Buscar y soltar el primer +resultado no sirve cuando hay varios: hace falta enseñar la lista y poder decir +"abre el tercero". Y para buscar DENTRO de un fichero como con Ctrl+F, hace +falta recordar cual era. + +Aqui va la logica pura —interpretar "el tercero", detectar las ordenes de +seleccion y de busqueda interna—; el estado (la ultima lista, el ultimo +fichero) y la ejecucion viven en el panel. +""" +import re + +# Como se dice cada posicion. Se aceptan el ordinal, el numero en palabra y la +# cifra, porque el reconocedor devuelve cualquiera de los tres. +ORDINALES = { + 1: ["primero", "primera", "uno", "1"], + 2: ["segundo", "segunda", "dos", "2"], + 3: ["tercero", "tercera", "tres", "3"], + 4: ["cuarto", "cuarta", "cuatro", "4"], + 5: ["quinto", "quinta", "cinco", "5"], + 6: ["sexto", "sexta", "seis", "6"], + 7: ["septimo", "septima", "siete", "7"], + 8: ["octavo", "octava", "ocho", "8"], + 9: ["noveno", "novena", "nueve", "9"], + 10: ["decimo", "decima", "diez", "10"], +} +_PALABRA_A_N = {p: n for n, ps in ORDINALES.items() for p in ps} + +# "el ultimo" es comodo y no es un numero +_ULTIMO = {"ultimo", "ultima", "final"} + + +def posicion(texto: str): + """Devuelve el numero (1..N) que se ha dicho, -1 para 'el ultimo', o None. + + Se queda con la PRIMERA posicion mencionada, para que "abre el tercero" + de 3 y no se lie con otros numeros que puedan venir detras. + """ + for palabra in texto.split(): + if palabra in _ULTIMO: + return -1 + if palabra in _PALABRA_A_N: + return _PALABRA_A_N[palabra] + return None + + +# Ordenes que actuan sobre la lista ya mostrada. El verbo dice que hacer con lo +# elegido; la posicion, cual. +_ABRIR = re.compile(r"\b(abre|abreme|abrir)\b") +_LEER = re.compile(r"\b(lee|leeme|leer|muestrame|ensename)\b") +_SELECCION = re.compile(r"\b(el|la|numero|opcion|resultado)\b") + + +def orden_de_seleccion(texto: str): + """(accion, posicion) si el texto elige de la lista, o None. + + accion: "abrir" o "leer". posicion: 1..N o -1 (ultimo). + Ejemplos: "abre el tercero", "lee el numero dos", "el ultimo", "abre el 3". + """ + pos = posicion(texto) + if pos is None: + return None + # tiene que sonar a seleccion, no a otra cosa que lleve un numero + if not (_SELECCION.search(texto) or _ABRIR.search(texto) or _LEER.search(texto)): + return None + if _LEER.search(texto): + return ("leer", pos) + # por defecto, elegir es abrir + return ("abrir", pos) + + +# Ctrl+F: buscar un termino dentro del ultimo fichero abierto. +_DENTRO = re.compile( + r"\b(busca|buscame|encuentra)\b.*\b(dentro|en el fichero|en el archivo|en el texto)\b") + + +def orden_buscar_dentro(texto: str): + """El termino a buscar dentro del fichero, o None. + + "busca dentro configuracion", "busca en el texto la palabra error". + Se queda con lo que va despues de 'dentro'/'en el ...'. + """ + if not _DENTRO.search(texto): + return None + # cortar por el separador y quedarse con el resto + for sep in ("dentro de", "dentro", "en el fichero", "en el archivo", "en el texto", + "la palabra"): + if sep in texto: + resto = texto.split(sep, 1)[1].strip() + resto = re.sub(r"^(la palabra|el termino|de|la|el|los|las)\s+", + "", resto).strip() + if resto: + return resto + return None diff --git a/nucleo/manos/sudo_root.py b/nucleo/manos/sudo_root.py new file mode 100644 index 0000000..c188457 --- /dev/null +++ b/nucleo/manos/sudo_root.py @@ -0,0 +1,134 @@ +"""Ejecutar ordenes como root de forma controlada. + +El problema que resuelve. Newelle convierte `sudo` en `sudo -S` (leer la +contraseña de la entrada estandar), pero en el panel de voz no hay una entrada +donde teclearla, asi que se queda colgado esperando una contraseña que no +llega. Y las acciones marcadas para confirmar no tenian dialogo: solo decian +"confirme antes" y no ejecutaban nada. + +Como se resuelve, y las decisiones de seguridad detras: + +- La contraseña NUNCA se escribe en disco. Vive en memoria de este proceso solo + si el usuario activa el permiso de sesion, y se pasa a `sudo -S` por stdin. +- El permiso de sesion CADUCA: a los 15 minutos de la ultima orden se olvida + sola, y tambien al cerrar el panel. No es un "root para siempre". +- CADA orden con root muestra su comando exacto en un dialogo antes de correr. + El permiso de sesion solo ahorra re-teclear la contraseña, nunca salta el ver + y aprobar lo que se va a ejecutar. La voz no puede autoaprobar root. + +Alternativa descartada: apoyarse solo en el timestamp de sudo (`sudo -v`). Es +mas limpio en teoria —lo gestiona el sistema— pero a traves de flatpak-spawn el +tty cambia entre llamadas y el cacheo no es fiable. Mantener la contraseña en +memoria del proceso, con caducidad y sin tocar disco, funciona siempre y el +usuario opta a ello de forma explicita. +""" +import re +import subprocess +import time + +# A los 15 minutos sin usarla, la contraseña en memoria se olvida. Igual que el +# valor por defecto de sudo, para no sorprender. +CADUCIDAD = 15 * 60 + +# Detecta una invocacion real de sudo: al principio del comando o justo tras un +# separador (;, |, &, &&, ||, salto de linea), no la palabra "sudo" dicha como +# argumento ("echo sudo no cuenta"). Errar aqui solo pediria una contraseña de +# mas, no es peligroso, pero molesta. +_SUDO = re.compile(r"(?:^|[;&|\n]|&&|\|\|)\s*sudo(?:\s|$)") + + +def necesita_root(comando: str) -> bool: + """True si el comando lanza algo con sudo.""" + return bool(comando and _SUDO.search(comando)) + + +class GestorRoot: + """Guarda —o no— la contraseña de sesion, y ejecuta con ella. + + Una instancia por panel. Al cerrar el panel se llama a olvida(). + """ + + def __init__(self, prefijo_host=None): + # prefijo para salir del sandbox; se pasa como argumento para poder + # probar esto sin flatpak + self._prefijo = list(prefijo_host or []) + self._password = None + self._ultimo_uso = 0.0 + + # -------------------------------------------------------------- sesion + + def sesion_activa(self) -> bool: + """Hay contraseña recordada y aun no ha caducado.""" + if self._password is None: + return False + if time.monotonic() - self._ultimo_uso > CADUCIDAD: + self.olvida() + return False + return True + + def olvida(self): + """Borra la contraseña de memoria. Se llama al cerrar el panel y al + caducar. Se sobreescribe antes de soltarla, por si acaso.""" + if self._password is not None: + self._password = "\x00" * len(self._password) + self._password = None + self._ultimo_uso = 0.0 + + def recuerda(self, password: str): + self._password = password + self._ultimo_uso = time.monotonic() + + # ------------------------------------------------------------ ejecutar + + def valida(self, password: str) -> bool: + """Comprueba la contraseña con `sudo -S -v`, sin ejecutar nada mas. + + -k borra cualquier timestamp previo para que la comprobacion sea de + verdad; -v solo actualiza credenciales. Si la contraseña es correcta + devuelve True; nunca lanza ningun comando del usuario. + """ + try: + r = subprocess.run( + self._prefijo + ["sudo", "-S", "-k", "-p", "", "-v"], + input=password + "\n", capture_output=True, text=True, + timeout=10) + return r.returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + def ejecuta(self, comando: str, password: str = None, timeout: int = 30): + """Ejecuta el comando dando la contraseña a los sudo que lleve. + + password: si se pasa, se usa esa vez; si no, la de la sesion. La + contraseña se pasa por stdin a `sudo -S`. No se registra en ningun + sitio. + + Devuelve (salida, codigo). codigo -1 = no habia contraseña disponible. + """ + clave = password if password is not None else self._password + if clave is None: + return "No hay contraseña disponible para root.", -1 + + # que todos los sudo del comando lean de stdin sin imprimir el prompt + preparado = re.sub(r"(^|[;&|]|\s)sudo(\s)", + r"\1sudo -S -p '' \2", comando) + try: + r = subprocess.run( + self._prefijo + ["bash", "-lc", preparado], + input=clave + "\n", capture_output=True, text=True, + timeout=timeout) + salida = (r.stdout or r.stderr or "").strip() + # sudo escribe su prompt/errores en stderr aunque -p sea vacio; + # se limpia lo que es ruido de sudo, no del comando + salida = "\n".join( + l for l in salida.splitlines() + if l.strip() and not l.startswith(("[sudo]", "Sorry, try again"))) + self._ultimo_uso = time.monotonic() + return salida, r.returncode + except subprocess.TimeoutExpired: + return "La orden con root ha tardado demasiado.", 124 + except OSError as e: + return f"No se pudo ejecutar: {e}", 1 + finally: + # no dejar la copia local viva mas de lo necesario + clave = "\x00" * len(clave) diff --git a/nucleo/manos/tareas.py b/nucleo/manos/tareas.py new file mode 100644 index 0000000..8dd0fe4 --- /dev/null +++ b/nucleo/manos/tareas.py @@ -0,0 +1,346 @@ +"""Tareas en paralelo: cada orden corre por su cuenta y se puede parar. + +Hasta ahora una orden era una llamada a `ejecuta()`: un `subprocess.run` con +veinte segundos de limite que bloqueaba el hilo hasta terminar. Servia porque +todos los atajos eran instantaneos, pero deja fuera justo lo que uno querria +mandar y olvidarse: un escaneo, una busqueda por todo el disco, una descarga. +Y no habia forma de arrepentirse a mitad. + +Aqui cada orden es una Tarea con nombre, estado y un proceso propio del que se +guarda el grupo, para poder matarlo entero. Tres cosas que costaron decidir: + +- **Se mata el grupo, no el proceso.** El comando real es `bash -lc "..."`, asi + que matar el bash deja vivos a sus hijos —el `find`, el `nmap`— corriendo y + comiendo disco con la tarjeta ya cerrada. Con `start_new_session=True` el + proceso estrena grupo y `killpg` se los lleva a todos. + +- **Fuera del sandbox tambien vale.** Comprobado: matar el `flatpak-spawn` + intermediario propaga la señal al proceso del host (2 procesos vivos -> 0). + No hace falta perseguir el PID del otro lado. + +- **La salida se lee segun sale, no al final.** Con `run()` no hay nada que + enseñar hasta que termina, y una tarjeta de un escaneo de tres minutos que + esta en blanco los tres minutos no informa de nada. Se lee linea a linea y se + guardan las ultimas, que es lo que cabe en la tarjeta. +""" + +import os +import shlex +import signal +import subprocess +import threading +import time +import urllib.parse + +# Lo que se guarda de la salida para enseñar en la tarjeta y para la plantilla +# de respuesta. Una tarjeta pequeña no da para mas, y guardar la salida entera +# de un `find /` es meterse megas en memoria por nada. +LINEAS_VISTA = 6 +SALIDA_MAXIMA = 600 + +# Con boton de parar, el limite deja de ser una proteccion y pasa a ser un +# estorbo: veinte segundos matan cualquier escaneo util. Se deja un tope alto +# como red para lo que se cuelgue de verdad y no vuelva nunca. +LIMITE_LARGO = 900 # 15 minutos + +# Cada cuanto se avisa al HUD mientras sale texto. Sin freno, un comando +# hablador manda cientos de mensajes por segundo al WebView y la interfaz se +# atasca dibujando texto que nadie llega a leer. +CADENCIA_AVISO = 0.25 + +CORRIENDO, HECHA, PARADA, FALLO, CADUCADA = ( + "corriendo", "hecha", "parada", "fallo", "caducada") + + +class Tarea: + """Una orden en marcha. Solo datos; quien la mueve es el gestor.""" + + def __init__(self, ident, titulo, accion_id, comando): + self.id = ident + self.titulo = titulo + self.accion_id = accion_id + self.comando = comando + # La accion de origen viaja con la tarea (y no en un diccionario + # aparte del panel) porque una tarea sin comando termina DENTRO de + # lanza(), antes de que quien la lanzo tenga el id para apuntarla: el + # aviso de "terminada" llegaria a un registro todavia vacio. + self.accion = None + self.argumento = "" + self.estado = CORRIENDO + self.inicio = time.monotonic() + self.fin = None + self.lineas = [] # las ultimas LINEAS_VISTA de salida + self.salida = "" # la salida acumulada, recortada + self.codigo = None + self._proc = None + + @property + def segundos(self) -> float: + return (self.fin or time.monotonic()) - self.inicio + + @property + def viva(self) -> bool: + return self.estado == CORRIENDO + + def resumen(self) -> dict: + """Lo que viaja al HUD. Sin objetos: esto acaba en un json.dumps.""" + return { + "id": self.id, + "titulo": self.titulo, + "accion": self.accion_id, + "estado": self.estado, + "segundos": round(self.segundos, 1), + "lineas": self.lineas[-LINEAS_VISTA:], + "codigo": self.codigo, + } + + +class GestorTareas: + """Lanza, vigila y para tareas. Un unico sitio donde vive el registro. + + al_cambiar(tarea) se llama desde el hilo de la tarea cada vez que hay algo + nuevo que enseñar. Quien lo reciba se encarga de saltar al hilo de la + interfaz: aqui no se sabe nada de GTK a proposito, para poder probarlo sin + levantar la aplicacion. + """ + + def __init__(self, al_cambiar=None, prefijo_host=()): + self._al_cambiar = al_cambiar or (lambda tarea: None) + self._prefijo_host = _con_watch_bus(prefijo_host) + self._tareas = {} + self._siguiente = 0 + self._cerrojo = threading.Lock() + + # -------------------------------------------------------------- registro + + def _nueva(self, titulo, accion_id, comando) -> Tarea: + with self._cerrojo: + self._siguiente += 1 + ident = f"t{self._siguiente}" + tarea = Tarea(ident, titulo, accion_id, comando) + self._tareas[ident] = tarea + return tarea + + def get(self, ident): + with self._cerrojo: + return self._tareas.get(ident) + + def activas(self) -> list: + with self._cerrojo: + return [t for t in self._tareas.values() if t.viva] + + def todas(self) -> list: + with self._cerrojo: + return list(self._tareas.values()) + + def _avisa(self, tarea): + try: + self._al_cambiar(tarea) + except Exception as e: # el HUD no tumba la tarea + print(f"JARVIS: aviso de tarea fallido: {e}") + + # ---------------------------------------------------------------- lanzar + + def lanza(self, accion, argumento="", titulo=None) -> Tarea: + """Arranca la accion en su propio proceso y devuelve la tarea ya viva.""" + comando = accion.comando or "" + if comando: + comando = comando.replace("{argumento}", shlex.quote(argumento)) + comando = comando.replace( + "{argumento_url}", + shlex.quote(urllib.parse.quote_plus(argumento))) + + tarea = self._nueva(titulo or _titulo_de(accion, argumento), + accion.id, comando) + tarea.accion = accion + tarea.argumento = argumento + self._avisa(tarea) + + if not comando: + # Acciones que solo hablan (no tienen comando): nacen terminadas. + self.termina(tarea.id, HECHA, "") + return tarea + + orden = ["bash", "-lc", comando] + if accion.host: + orden = self._prefijo_host + orden + + threading.Thread(target=self._corre, args=(tarea, orden), + daemon=True).start() + return tarea + + def adopta(self, titulo, accion_id="") -> Tarea: + """Una tarjeta para trabajo que se ejecuta fuera del gestor. + + Lo que pasa por el dialogo de root corre por su camino (GestorRoot, con + su contraseña), pero se merece tarjeta igual: si no, pedir algo con + sudo hace desaparecer la señal de que hay algo en marcha. + """ + tarea = self._nueva(titulo, accion_id, "") + self._avisa(tarea) + return tarea + + def _corre(self, tarea, orden): + try: + # start_new_session: el proceso estrena grupo, para poder matar el + # arbol entero y no solo el bash de arriba. + proc = subprocess.Popen( + orden, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, errors="replace", start_new_session=True) + except OSError as e: + self.termina(tarea.id, FALLO, f"No he podido ejecutarlo: {e}") + return + + tarea._proc = proc + limite = threading.Timer(LIMITE_LARGO, self._caduca, args=(tarea.id,)) + limite.daemon = True + limite.start() + + ultimo_aviso = 0.0 + acumulado = [] + try: + for linea in proc.stdout: + linea = linea.rstrip("\n") + if not linea.strip(): + continue + acumulado.append(linea) + tarea.lineas = acumulado[-LINEAS_VISTA:] + ahora = time.monotonic() + if ahora - ultimo_aviso >= CADENCIA_AVISO: + ultimo_aviso = ahora + self._avisa(tarea) + except Exception as e: + print(f"JARVIS: leyendo la salida de {tarea.id}: {e}") + finally: + limite.cancel() + + codigo = proc.wait() + salida = "\n".join(acumulado).strip() + + if tarea.estado == PARADA: # la paro alguien: no se toca + self._avisa(tarea) + return + if tarea.estado == CADUCADA: + self._avisa(tarea) + return + self.termina(tarea.id, HECHA if codigo == 0 else FALLO, salida, codigo) + + # ------------------------------------------------------------- terminar + + def termina(self, ident, estado, salida="", codigo=None): + tarea = self.get(ident) + if tarea is None: + return + tarea.estado = estado + tarea.fin = time.monotonic() + tarea.codigo = codigo + if salida: + tarea.salida = salida[:SALIDA_MAXIMA] + if not tarea.lineas: + tarea.lineas = salida.splitlines()[-LINEAS_VISTA:] + else: + tarea.salida = "\n".join(tarea.lineas)[:SALIDA_MAXIMA] + self._avisa(tarea) + + def para(self, ident) -> bool: + """Corta la tarea. Primero por las buenas, luego a la fuerza.""" + tarea = self.get(ident) + if tarea is None or not tarea.viva: + return False + + tarea.estado = PARADA + proc = tarea._proc + if proc is not None and proc.poll() is None: + _mata_grupo(proc) + tarea.fin = time.monotonic() + self._avisa(tarea) + return True + + def _caduca(self, ident): + tarea = self.get(ident) + if tarea is None or not tarea.viva: + return + tarea.estado = CADUCADA + if tarea._proc is not None and tarea._proc.poll() is None: + _mata_grupo(tarea._proc) + tarea.fin = time.monotonic() + self._avisa(tarea) + + def cierra(self, ident) -> bool: + """Quita la tarjeta. Si seguia viva, la para antes: cerrar es cerrar.""" + tarea = self.get(ident) + if tarea is None: + return False + if tarea.viva: + self.para(ident) + with self._cerrojo: + self._tareas.pop(ident, None) + return True + + def para_todas(self): + for tarea in self.activas(): + self.para(tarea.id) + + +def _con_watch_bus(prefijo) -> list: + """Que lo lanzado fuera del sandbox no sobreviva a la aplicacion. + + Una tarea larga corre en el host por flatpak-spawn, y ahi el proceso ya no + es hijo de nadie que se entere de que la app se ha cerrado: un escaneo + lanzado y olvidado seguiria comiendo maquina despues de cerrar la ventana, + sin ventana desde la que pararlo. `--watch-bus` ata su vida a la conexion + de bus de quien lo lanzo, que es justo lo que se quiere. + """ + prefijo = list(prefijo) + if not prefijo: + return prefijo + if any("flatpak-spawn" in p for p in prefijo) and "--watch-bus" not in prefijo: + # detras del propio flatpak-spawn, antes de sus argumentos + for i, p in enumerate(prefijo): + if "flatpak-spawn" in p: + prefijo.insert(i + 1, "--watch-bus") + break + return prefijo + + +def _mata_grupo(proc): + """SIGTERM al grupo, y SIGKILL a los tres segundos si sigue ahi. + + Se da margen a proposito: un `wget` a medias cierra el fichero y un `nmap` + escupe lo que llevaba. Matar a lo bruto de entrada deja restos. + """ + try: + grupo = os.getpgid(proc.pid) + except OSError: + return + try: + os.killpg(grupo, signal.SIGTERM) + except OSError: + return + + def remata(): + if proc.poll() is None: + try: + os.killpg(grupo, signal.SIGKILL) + except OSError: + pass + + t = threading.Timer(3.0, remata) + t.daemon = True + t.start() + + +def _titulo_de(accion, argumento) -> str: + """El rotulo de la tarjeta: corto, y con el argumento si lo hay. + + El id de la accion con guiones bajos ("buscar_fichero") es de programador; + en la tarjeta se lee "buscar fichero", y con lo que se pidio detras, que es + lo que distingue dos busquedas a la vez. + """ + base = (accion.id or "accion").replace("_", " ") + if argumento: + arg = argumento.strip() + if len(arg) > 22: + arg = arg[:21] + "…" + return f"{base}: {arg}" + return base diff --git a/nucleo/mejora.py b/nucleo/mejora.py new file mode 100755 index 0000000..e7a412e --- /dev/null +++ b/nucleo/mejora.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Que ha aprendido JARVIS del uso, y que conviene cambiar. + + ./mejora.py que hay en el diario + ./mejora.py --propone que acciones o frases faltan + ./mejora.py --mide cuanto acierta ahora mismo + +## En que se diferencia de la mejora nocturna que se retiro + +La de antes corria sola de madrugada, se modificaba el codigo y se commiteaba. +Nueve horas, 39 rondas, cero commits utiles. Fallo por tres cosas, y las tres +estan corregidas aqui: + + 1. **Minaba ruido.** Su fuente era un registro donde el 60 % era JARVIS + oyendose a si mismo y alucinaciones de Whisper. Ahora la guarda del eco y + el filtro de ruido limpian antes, y el diario apunta la VIA de cada frase, + asi que el ruido esta separado y etiquetado en vez de mezclado. + + 2. **Su medida tenia mas ruido que su mejora.** Generaba audio nuevo cada + ronda: ±1,9 puntos de ruido contra +1,45 de mejora acumulada, o sea que + "¿esto mejora o empeora?" se decidia a cara o cruz. Ahora se mide contra + `datos/banco.json`, que es fijo y determinista. + + 3. **Actuaba sola.** Un bucle que se reescribe y se aprueba a si mismo acaba + pasando siempre. Esto PROPONE y para: lo que se aplica lo decide una + persona mirandolo. + +Y una cuarta, de fondo: aquella tocaba el codigo. Esta solo mira el catalogo, +que es lo unico que se puede validar de verdad con el banco. +""" +import argparse +import collections +import os +import sys + +AQUI = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, AQUI) + +from manos import Manos # noqa: E402 +from manos.acciones import normaliza, parecido # noqa: E402 +from manos.diario import lee # noqa: E402 + + +def color(t, c): + return f"\033[{c}m{t}\033[0m" if sys.stdout.isatty() else t + + +# ------------------------------------------------------------------ mirar + +def resumen(lineas): + if not lineas: + print(" el diario esta vacio: usa JARVIS un rato y vuelve") + return 1 + + vias = collections.Counter(l.get("via", "?") for l in lineas) + tot = sum(vias.values()) + print(f"\n {tot} frases desde {lineas[0]['t'][:10]}\n") + print(f" {'via':14s} {'n':>5s} {'%':>7s}") + print(" " + "-" * 32) + ORDEN = ["catalogo", "cerebro", "respuesta", "ruido", "sin-nombre", + "bloqueado", "sin-cerebro"] + for v in ORDEN + [k for k in vias if k not in ORDEN]: + if v in vias: + print(f" {v:14s} {vias[v]:5d} {100*vias[v]/tot:6.1f} %") + + atendidas = vias["catalogo"] + vias["cerebro"] + if atendidas: + print(f"\n de lo ATENDIDO ({atendidas}), el catalogo cogio " + f"{100*vias['catalogo']/atendidas:.0f} %") + segs = [l.get("seg", 0) for l in lineas if l.get("via") == "catalogo"] + segc = [l.get("seg", 0) for l in lineas if l.get("via") == "cerebro"] + if segs: + print(f" catalogo: {sum(segs)/len(segs):.2f} s de media") + if segc: + print(f" cerebro: {sum(segc)/len(segc):.1f} s de media") + ahorro = (sum(segc)/len(segc) - (sum(segs)/len(segs) if segs else 0)) + print(f"\n cada accion que se añada al catalogo ahorra ~{ahorro:.0f} s") + + top = collections.Counter(l["accion"] for l in lineas + if l.get("via") == "catalogo" and l.get("accion")) + if top: + print("\n lo que mas usas:") + for a, n in top.most_common(8): + print(f" {n:4d} {a}") + return 0 + + +# --------------------------------------------------------------- proponer + +def propone(lineas, manos): + """Lo que fue al cerebro y podria ser una accion del catalogo.""" + # "sin-cerebro" cuenta igual: es lo mismo —una frase que el catalogo no + # cubrio— y solo se diferencia en que no habia modelo conectado para + # atenderla. El hueco del catalogo es el mismo. + alCerebro = [l for l in lineas if l.get("via") in ("cerebro", "sin-cerebro")] + if not alCerebro: + print("\n nada ha ido al cerebro todavia: no hay hueco que rellenar") + return 0 + + # Agrupar lo parecido: si has pedido lo mismo de tres formas, es UNA accion + # que falta, no tres. Y que lo hayas repetido es justo la señal de que + # importa. + grupos = [] + for l in alCerebro: + t = normaliza(l["oido"]) + if len(t.split()) < 2: + continue + for g in grupos: + if parecido(t, g["patron"]) > 0.62: + g["frases"].append(l["oido"]) + break + else: + grupos.append({"patron": t, "frases": [l["oido"]], + "dicho": l.get("dicho", "")}) + + grupos.sort(key=lambda g: -len(g["frases"])) + print(f"\n {len(alCerebro)} frases fueron al cerebro, en {len(grupos)} grupos.") + print(" Las que mas se repiten son las acciones que faltan:\n") + + for g in grupos[:12]: + n = len(g["frases"]) + marca = color(f"{n:3d} veces", "33" if n > 1 else "90") + print(f" {marca} {g['frases'][0][:64]}") + for f in g["frases"][1:4]: + print(f" {color('tambien: ' + f[:56], '90')}") + if g["dicho"]: + print(f" {color('contesto: ' + g['dicho'][:56], '90')}") + print() + + repetidas = [g for g in grupos if len(g["frases"]) > 1] + print(f" {len(repetidas)} se han pedido mas de una vez: por ahi empezaria.\n") + print(color(" Esto NO cambia nada. Para añadir una accion se edita\n" + " datos/_generar.py —que es la FUENTE— y se regenera.\n", "90")) + return 0 + + +# ------------------------------------------------------------------ medir + +def mide(manos): + """El acierto del emparejador contra el banco fijo.""" + import json + banco = os.path.join(AQUI, "datos", "banco.json") + try: + with open(banco, encoding="utf-8") as f: + casos = json.load(f)["casos"] + except OSError: + print(" no hay banco: pruebas/banco.py --crear") + return 1 + bien = otra = 0 + for c in casos: + a, _ = manos.busca(c["oido"]) + if a is None: + continue + if a.id == c["id"]: + bien += 1 + else: + otra += 1 + print(f"\n acierto {100*bien/len(casos):.1f} % sobre {len(casos)} frases") + print(f" ordenes que acaban en la accion equivocada: {otra}") + print(color("\n Este numero es comparable entre dias: el banco esta grabado\n" + " una vez y no cambia. Era lo que le faltaba a la mejora\n" + " nocturna, cuyo ruido de medida era mayor que su mejora.\n", "90")) + return 0 + + +def main() -> int: + p = argparse.ArgumentParser(description="Que ha aprendido JARVIS del uso") + p.add_argument("--propone", action="store_true") + p.add_argument("--mide", action="store_true") + a = p.parse_args() + + manos = Manos() + lineas = lee() + if a.mide: + return mide(manos) + if a.propone: + return propone(lineas, manos) + return resumen(lineas) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/nucleo/oido/__init__.py b/nucleo/oido/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nucleo/oido/captura.py b/nucleo/oido/captura.py new file mode 100644 index 0000000..94b3690 --- /dev/null +++ b/nucleo/oido/captura.py @@ -0,0 +1,188 @@ +"""Escuchar el microfono y recortar lo que es habla. + +## Por que pw-record y no pyaudio + +Newelle usa pyaudio, que necesita portaudio compilado. En esta maquina no esta, +asi que instalarlo pediria sudo. `pw-record` y `parec` ya vienen con PipeWire y +sirven igual: se lanzan como subproceso y se lee su salida estandar. + +Y hay una ventaja que no es casual: la fuente se elige con un argumento. Cuando +se ponga el cancelador de eco de PipeWire, apuntar ahi es cambiar una cadena, no +tocar codigo de audio. + +## La guarda contra el eco + +Esta es la razon de ser de este fichero. En Newelle, el 21 % largo de lo que +JARVIS oye y no entiende es su propia voz. Tiene mecanismo para evitarlo pero +con tres agujeros, y aqui se cierran los tres: + +1. **Se consulta a la boca en cada trozo**, no una bandera que alguien actualiza + desde otro hilo. Si `boca.hablando` es cierto, ese audio se tira sin mirar. +2. **La cola de 350 ms** vive en la boca (`COLA_MS`): cuando el reproductor + termina, el sonido sigue llegando al microfono un rato. +3. **El prebufer se vacia** al salir de "hablando". Newelle guarda 1 s circular + y lo antepone al detectar voz; ese segundo era justo el final de la frase de + JARVIS, asi que se colaba aunque la guarda funcionase. +""" +import collections +import os +import signal +import struct +import subprocess +import tempfile +import wave + +FRECUENCIA = 16000 # lo que quiere whisper.cpp +CANALES = 1 +ANCHO = 2 # s16 +TROZO = 512 # muestras: lo que pide Silero de una vez + +# Cuanto silencio hace falta para dar la frase por terminada. Muy corto corta a +# quien piensa a media frase; muy largo hace que JARVIS parezca lento. +SILENCIO_PARA_CERRAR = 0.8 +# Cuanto se guarda de ANTES de detectar voz. La primera silaba siempre llega +# antes de que el detector se entere. +PREBUFER_S = 0.5 +# Frases mas cortas que esto no son ordenes, son ruidos. +MINIMO_S = 0.35 +# Y mas largas que esto es que el detector se ha quedado enganchado. +MAXIMO_S = 25.0 + + +def _grabador(fuente=None): + """La orden para capturar crudo a la salida estandar.""" + if fuente: + pw = ["pw-record", "--target", fuente] + else: + pw = ["pw-record"] + return pw + ["--rate", str(FRECUENCIA), "--channels", str(CANALES), + "--format", "s16", "-"] + + +class Captura: + """Trozos de microfono -> frases sueltas en WAV.""" + + def __init__(self, boca=None, fuente=None, agresividad=0.5): + self.boca = boca # para saber si JARVIS esta hablando + self.fuente = fuente + self.agresividad = agresividad + self._proc = None + self._vad = None + self._energia = None + + # ------------------------------------------------------------ el detector + + def _detector(self): + if self._vad is not None or self._energia is not None: + return + try: + from pysilero_vad import SileroVoiceActivityDetector + self._vad = SileroVoiceActivityDetector() + except Exception: + # Respaldo por energia: peor, pero mejor que no escuchar. Se avisa, + # porque la diferencia se nota y conviene saber en cual se esta. + print("oido: sin Silero, usando deteccion por energia") + self._energia = True + + def _hay_voz(self, trozo: bytes) -> bool: + if self._vad is not None: + try: + return self._vad(trozo) >= self.agresividad + except Exception: + return False + muestras = struct.unpack(f"{len(trozo) // 2}h", trozo) + if not muestras: + return False + media = sum(m * m for m in muestras) / len(muestras) + return (media ** 0.5) / 32768.0 > 0.015 + + # -------------------------------------------------------------- escuchar + + def frases(self, para_cuando=None): + """Generador: devuelve la ruta de un WAV por cada frase oida. + + El WAV es temporal y de quien lo recibe: se borra cuando quiera. + """ + self._detector() + self._proc = subprocess.Popen( + _grabador(self.fuente), stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, start_new_session=True) + + bytes_trozo = TROZO * ANCHO + por_segundo = FRECUENCIA / TROZO + prebufer = collections.deque(maxlen=max(1, int(PREBUFER_S * por_segundo))) + cerrar_tras = int(SILENCIO_PARA_CERRAR * por_segundo) + tope = int(MAXIMO_S * por_segundo) + + frase, callados, hablando = [], 0, False + try: + while True: + if para_cuando and para_cuando(): + break + trozo = self._proc.stdout.read(bytes_trozo) + if not trozo or len(trozo) < bytes_trozo: + break + + # --- la guarda: si JARVIS habla, esto es su propia voz ------- + if self.boca is not None and self.boca.hablando: + # Tirar tambien lo acumulado: si estaba a media frase cuando + # JARVIS empezo a hablar, lo que quede lleva su voz encima. + prebufer.clear() + frase, callados, hablando = [], 0, False + continue + + prebufer.append(trozo) + voz = self._hay_voz(trozo) + + if voz and not hablando: + hablando = True + frase = list(prebufer) # con lo de antes de detectarla + callados = 0 + elif hablando: + frase.append(trozo) + callados = 0 if voz else callados + 1 + + if hablando and (callados >= cerrar_tras or len(frase) >= tope): + dur = len(frase) * TROZO / FRECUENCIA + ruta = self._a_wav(frase) if dur >= MINIMO_S else None + frase, callados, hablando = [], 0, False + prebufer.clear() + if ruta: + yield ruta + finally: + self.para() + + def _a_wav(self, trozos) -> str: + fd, ruta = tempfile.mkstemp(suffix=".wav", prefix="jarvis-oido-") + os.close(fd) + with wave.open(ruta, "wb") as w: + w.setnchannels(CANALES) + w.setsampwidth(ANCHO) + w.setframerate(FRECUENCIA) + w.writeframes(b"".join(trozos)) + return ruta + + def para(self): + if not self._proc: + return + try: + os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM) + self._proc.wait(timeout=3) + except Exception: + pass + self._proc = None + + +def fuentes() -> list: + """Las entradas que hay, para poder elegir la del cancelador de eco.""" + try: + r = subprocess.run(["pactl", "list", "short", "sources"], + capture_output=True, text=True, timeout=10) + except (OSError, subprocess.TimeoutExpired): + return [] + salida = [] + for linea in r.stdout.splitlines(): + campos = linea.split("\t") + if len(campos) > 1 and not campos[1].endswith(".monitor"): + salida.append(campos[1]) + return salida diff --git a/nucleo/oido/despierta.py b/nucleo/oido/despierta.py new file mode 100644 index 0000000..dd8551a --- /dev/null +++ b/nucleo/oido/despierta.py @@ -0,0 +1,143 @@ +"""La palabra de activacion: "JARVIS". + +Sin esto, el asistente obedece a cualquier cosa que se diga en la habitacion —y +a la tele—. Con esto, solo escucha de verdad durante un rato despues de oir su +nombre. + +## Por que sobre el texto y no sobre el audio + +Lo obvio seria un detector como openwakeword, que mira la forma de onda antes de +transcribir. Aqui se hace despues, sobre lo que Whisper ya escribio, y es a +proposito: + + - **Ya tenemos la transcripcion.** El audio pasa por Whisper de todas formas + porque la captura la dispara el detector de voz; no hay ahorro que ganar. + - **Un modelo menos.** openwakeword son mas dependencias, otro modelo en + memoria y otro afinado que mantener. + - **Y sobre todo: Whisper oye "JARVIS" de mil maneras.** Medido en el registro + de uso real, el nombre sale como "Jarvis", "Yarvis", "Charles", "Chavez", + "Harvest"... Sobre el texto eso se arregla comparando como SUENA, que es lo + que ya hace `manos/fonetica.py` para el catalogo. Sobre la onda habria que + reentrenar. + +Lo que se pierde: la transcripcion corre siempre, tambien cuando nadie ha +llamado. En una maquina donde Whisper va en la grafica y tarda dos segundos, es +un coste que ya estabamos pagando. + +## Como se comporta + +Se llama una vez y se queda despierto un rato, para poder encadenar ordenes sin +repetir el nombre en cada frase. Y si la frase que trae el nombre lleva ademas +la orden —"JARVIS, cuanto espacio queda"— se atiende esa misma, sin obligar a +decirlo dos veces. +""" +import difflib +import re +import time + +# Absolutos, como el resto del nucleo: jarvis.py mete su carpeta en sys.path y +# todos los paquetes son de primer nivel. Con `..manos` esto solo se podria +# importar desde fuera, y las pruebas lo cargan suelto. +from manos.acciones import normaliza +from manos.fonetica import fonetica + +NOMBRE = "jarvis" + +# Cuanto sigue escuchando tras oir su nombre. Corto obliga a repetirlo en cada +# frase; largo devuelve al problema de origen, que obedezca a la tele. +DESPIERTO_S = 25.0 + +# Lo que Whisper escribe cuando oye "JARVIS". Salen del registro real de uso, +# no de imaginarlas. +PARECIDAS = ( + "jarvis", "yarvis", "charvis", "jarbis", "yarbis", "harvis", "jervis", + "jarvi", "yarvi", "javis", "jarves", "charles", "chavez", "harvest", + "jarvix", "sharvis", "arvis", +) + +# Se compara palabra a palabra, asi que el umbral puede ser alto sin perder +# aciertos: "yarvis" contra "jarvis" da 0,83 por letra y sube por sonido. +UMBRAL = 0.80 + +_LIMPIA = re.compile(r"^[\s,.:;¡!¿?]+|[\s,.:;¡!¿?]+$") + +# Lo que suele acompañar al nombre sin ser una orden. Si al quitar el nombre +# solo queda esto, es que llamaron y ya: "oye jarvis" no es "haz oye". +LLAMADAS = {"oye", "eh", "hey", "ey", "hola", "escucha", "atiende", "perdona", + "por favor", "porfa", "venga", "a ver"} + + +def _suena_al_nombre(palabra: str) -> bool: + p = _LIMPIA.sub("", palabra) + if len(p) < 4: + return False + if p in PARECIDAS: + return True + for candidata in (NOMBRE,) + PARECIDAS[:6]: + if difflib.SequenceMatcher(None, p, candidata).ratio() >= UMBRAL: + return True + if difflib.SequenceMatcher(None, fonetica(p), fonetica(candidata)).ratio() >= UMBRAL: + return True + return False + + +def parte_el_nombre(texto: str): + """(lo llamaron, lo que queda de la frase sin el nombre). + + Se devuelve el resto porque "JARVIS, cuanto espacio queda" tiene que + ejecutarse en la misma frase: obligar a decir el nombre y luego la orden + aparte es exactamente lo que hace que un asistente canse. + """ + palabras = normaliza(texto).split() + if not palabras: + return False, "" + for i, p in enumerate(palabras): + if _suena_al_nombre(p): + sobra = [q for q in palabras[:i] + palabras[i + 1:] + if q not in LLAMADAS] + return True, " ".join(sobra).strip() + return False, "" + + +class Centinela: + """Decide si una frase hay que atenderla o dejarla pasar.""" + + def __init__(self, activo=True, ventana=DESPIERTO_S): + self.activo = activo + self.ventana = ventana + self._hasta = 0.0 + + @property + def despierto(self) -> bool: + return not self.activo or time.monotonic() < self._hasta + + def despierta(self): + self._hasta = time.monotonic() + self.ventana + + def duerme(self): + self._hasta = 0.0 + + def filtra(self, texto: str): + """(hay que atenderla, texto a atender, acaba de despertar). + + - Sin palabra de activacion configurada: se atiende todo. + - Dormido y sin nombre: se ignora. + - Dormido y con nombre: despierta. Si la frase traia ademas la orden, + se devuelve; si solo traia el nombre, se contesta con un acuse. + - Despierto: se atiende y se renueva la ventana, para poder encadenar. + """ + if not self.activo: + return True, texto, False + + llamado, resto = parte_el_nombre(texto) + + if llamado: + recien = not self.despierto + self.despierta() + return (True, resto, recien) if resto else (False, "", recien) + + if self.despierto: + self.despierta() # encadenar sin repetir el nombre + return True, texto, False + + return False, "", False diff --git a/nucleo/oido/whisper.py b/nucleo/oido/whisper.py new file mode 100644 index 0000000..b161e34 --- /dev/null +++ b/nucleo/oido/whisper.py @@ -0,0 +1,343 @@ +"""Transcribir: arrancar whisper-server y mandarle los WAV. + +Newelle dedicaba 804 lineas a esto porque tenia que descargar modelos, elegir +entre CLI y servidor, salir del sandbox y ofrecerlo todo en una pantalla de +ajustes. Nosotros ya tenemos el binario compilado con CUDA y el modelo en el +disco: queda arrancar un proceso y hacer un POST. + +## Los cuatro flags que Newelle no pasaba + +Esto es lo importante de este fichero, y sale de leer el codigo de upstream: +`whispercpp_handler.py` arranca el servidor con `-m -t --host --port -l` y nada +mas. Los defectos de `whisper-server` no son los mismos que los de +`whisper-cli`, asi que se estaba transcribiendo peor de lo que la propia +whisper.cpp hace por defecto: + + --beam-size 5 --best-of 5 whisper-server trae beam-size -1, que es + decodificacion GREEDY. El CLI usa 5. Es la causa + mas probable de transcripciones como "esta con el + tanutusto" en un audio limpio. + -sns suprime los tokens que no son habla. Es + literalmente el flag que evita los "[Musica]" y + "(aplausos)", que son el 16,2 % de las frases que + JARVIS no entendio. + --prompt + --carry-initial-prompt + vocabulario del dominio. MEDIDO Y DESCARTADO por + defecto: ver abajo. + +El prompt se genera DESDE el catalogo (`prompt_dominio`), no escrito a mano, o +se queda viejo en cuanto se añada una accion. + +## Lo que dijo la medida, que no fue lo que yo esperaba + +40 frases del catalogo dichas por Piper con tres voces, transcritas con cada +configuracion y contando cuantas llegan a su accion: + + como lo hace Newelle (greedy, sin sns) 50,0 % 0,98 s + + beam 5 55,0 % 1,01 s + + beam 5 + sns 55,0 % 1,01 s + + beam 5 + sns + prompt 52,5 % 1,12 s + +Tres lecturas, por orden de importancia: + +1. **El prompt EMPEORA.** Era el riesgo anotado —Whisper empieza a "oir" las + palabras que le has puesto donde no las hay— y se cumplio. Va apagado por + defecto; se deja el codigo porque con un prompt mas corto podria ganar, pero + hay que demostrarlo antes de encenderlo. +2. **`-sns` no se puede medir con este banco.** El audio de Piper no tiene + silencios ni musica, asi que no hay nada que suprimir. Se deja puesto porque + no cuesta nada y ataca un problema real que solo se ve con microfono: el + 16,2 % de "[Musica]" del registro de uso. +3. **El beam sube 5 puntos y cuesta 30 ms.** Es lo unico claramente a favor, y + aun asi con 40 muestras esos 5 puntos son dos frases: esta en el limite del + ruido. +""" +import atexit +import json +import os +import re +import signal +import socket +import subprocess +import time +import urllib.error +import urllib.request + +RAIZ = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +BINARIO = os.path.join(RAIZ, "voz", "whisper-cuda", "whisper.cpp", "build", "bin", + "whisper-server") +MODELOS = os.path.expanduser( + "~/.var/app/io.github.qwersyk.Newelle/config/models/whisper/whisper.cpp/models") + +# Las libs de ggml estan al lado del binario y CUDA en /usr/local. Fuera del +# flatpak esto es una linea; dentro era media pagina de flatpak-spawn. +LIBS = "/usr/local/cuda/lib64" + +# Un prompt largo no ayuda: Whisper empieza a "oir" esas palabras donde no las +# hay. Se corta. +TOPE_PROMPT = 220 + +# El prompt MINIMO, y el unico que va puesto por defecto: solo el nombre. +# +# Sin el, Whisper no escribe "JARVIS" nunca: se lo come y lo sustituye por la +# palabra española que mejor le cuadre acusticamente. Medido diciendoselo con +# Piper: +# +# "Jarvis, cuanto espacio queda" -> "¿Sabeis cuanto espacio queda?" +# "Yarvis, que hora es" -> "¿Ya viste que hora es?" +# "Jarvis pon musica" -> "Carbis Pond Musica." +# +# Y ninguna de esas se parece al nombre ni por letra ni por sonido, asi que el +# centinela no despertaba JAMAS. Era la causa de que el asistente no contestara. +# +# Con este prompt de cinco palabras, cuatro de cuatro. Y ojo al matiz, porque +# contradice lo de arriba solo en apariencia: lo que empeora es el prompt LARGO +# con todo el vocabulario del catalogo. Este cuesta una frase de ochenta —90,0 a +# 88,8 % de acierto general, dentro del ruido— y arregla el nombre entero. +PROMPT_NOMBRE = "JARVIS. Hablando con JARVIS." + + +def prompt_dominio(catalogo=None) -> str: + """Vocabulario que JARVIS espera oir, sacado del catalogo. + + Se cogen las palabras poco comunes —las que un modelo de castellano general + no espera— y no las frases enteras: el prompt es un sesgo, no una lista de + ordenes, y llenarlo de "cuanto espacio queda" haria que las oyera en + cualquier ruido. + """ + comunes = { + "abre", "cierra", "busca", "pon", "quita", "sube", "baja", "dime", + "cuanto", "cuanta", "cuantos", "que", "el", "la", "los", "las", "de", + "del", "en", "con", "por", "para", "un", "una", "y", "o", "a", "al", + "me", "se", "lo", "es", "esta", "hay", "mi", "tu", "su", + } + # Por grupos y en rueda, no por orden del catalogo. Recorriendolo en orden, + # el tope de caracteres se lo comian entero las acciones de disco —que van + # primeras— y no llegaba ni una palabra de firefox, ventanas o pentest. + por_grupo, vistas = {}, set() + for a in (catalogo.acciones if catalogo else []): + for frase in a.frases: + for p in frase.split(): + if len(p) < 4 or p in comunes or p in vistas: + continue + vistas.add(p) + por_grupo.setdefault(a.grupo or "otras", []).append(p) + + palabras = [] + grupos = list(por_grupo.values()) + for i in range(max((len(g) for g in grupos), default=0)): + for g in grupos: + if i < len(g): + palabras.append(g[i]) + # Los nombres propios importan mas que nada y no salen del catalogo tal cual + fijas = ["JARVIS", "firefox", "nmap", "ollama", "whisper", "terminal", + "escritorio", "pantalla", "wifi", "bluetooth", "github", "youtube"] + texto = ", ".join(fijas + palabras) + return texto[:TOPE_PROMPT].rsplit(",", 1)[0] + + +def _mata_huerfanos(): + """whisper-server de arranques anteriores que se quedaron con la grafica. + + Se buscan por la RUTA COMPLETA del binario, no por el nombre: `pkill -f + whisper` casaria con el propio pkill y con cualquier cosa que lleve la + palabra, y eso ya ha costado depuraciones. Y se leen los cmdline de /proc en + vez de llamar a pgrep, que es lo mismo pero sin proceso de por medio. + """ + yo = os.getpid() + for pid in os.listdir("/proc"): + if not pid.isdigit() or int(pid) == yo: + continue + try: + with open(f"/proc/{pid}/cmdline", "rb") as f: + orden = f.read().split(b"\0") + except OSError: + continue + if orden and orden[0].decode(errors="ignore") == BINARIO: + try: + os.killpg(os.getpgid(int(pid)), signal.SIGTERM) + except (OSError, ProcessLookupError): + pass + + +def _puerto_libre() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +class Oido: + """whisper-server vivo, y un metodo para transcribir un WAV.""" + + def __init__(self, modelo="small", idioma="es", hilos=8, prompt=PROMPT_NOMBRE, + beam=5, gpu=True): + self.modelo = modelo + self.idioma = idioma + self.hilos = hilos + self.prompt = prompt + self.beam = beam + self.gpu = gpu + self._proc = None + self._puerto = None + + @property + def ruta_modelo(self) -> str: + return os.path.join(MODELOS, f"ggml-{self.modelo}.bin") + + def disponible(self) -> bool: + return os.path.exists(BINARIO) and os.path.exists(self.ruta_modelo) + + # ------------------------------------------------------------- arrancar + + def arranca(self, espera=40) -> bool: + if self._proc and self._proc.poll() is None: + return True + if not self.disponible(): + return False + + # Los huerfanos de arranques anteriores, antes de nada. + # + # whisper-server se lanza en su propio grupo de procesos para poder + # matarlo entero, pero eso mismo hace que sobreviva si el nucleo muere + # de golpe (un kill, un cuelgue, cerrar la terminal). Y cada uno se + # queda con ~880 MiB de una tarjeta de 4 GB: al cuarto, el siguiente ya + # no arranca y el sintoma es "whisper no arranco", que no apunta a + # ningun sitio. Medido en esta maquina: cuatro huerfanos, 3,5 GB. + _mata_huerfanos() + + self._puerto = _puerto_libre() + orden = [ + BINARIO, + "-m", self.ruta_modelo, + "-t", str(self.hilos), + "--host", "127.0.0.1", + "--port", str(self._puerto), + "-l", self.idioma, + # los cuatro que Newelle no pasaba + "-sns", + "--beam-size", str(self.beam), + "--best-of", "5", + ] + if self.prompt: + orden += ["--prompt", self.prompt, "--carry-initial-prompt"] + if not self.gpu: + orden.append("--no-gpu") + + entorno = {**os.environ, + "LD_LIBRARY_PATH": LIBS + ":" + os.environ.get("LD_LIBRARY_PATH", "")} + self._proc = subprocess.Popen( + orden, env=entorno, cwd=os.path.dirname(BINARIO), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + # grupo propio, para poder matarlo entero como hace tareas.py + start_new_session=True) + # Y que se vaya con nosotros: sin esto se queda con su parte de la + # grafica hasta el siguiente reinicio. + atexit.register(self.para) + + # Esperar a que escuche de verdad, no a que el proceso exista: cargar + # el modelo en la grafica tarda, y un POST antes de tiempo da un error + # de conexion que parece un fallo de configuracion. + limite = time.monotonic() + espera + while time.monotonic() < limite: + if self._proc.poll() is not None: + return False + try: + with socket.create_connection(("127.0.0.1", self._puerto), 0.5): + return True + except OSError: + time.sleep(0.3) + self.para() + return False + + def para(self): + if not self._proc: + return + try: + os.killpg(os.getpgid(self._proc.pid), signal.SIGTERM) + self._proc.wait(timeout=5) + except Exception: + try: + os.killpg(os.getpgid(self._proc.pid), signal.SIGKILL) + except Exception: + pass + self._proc = None + + @property + def vivo(self) -> bool: + return bool(self._proc and self._proc.poll() is None) + + # ---------------------------------------------------------- transcribir + + def transcribe(self, wav: str) -> str: + if not self.vivo and not self.arranca(): + return "" + try: + with open(wav, "rb") as f: + datos = f.read() + except OSError: + return "" + + lim = "----jarvis" + cuerpo = ( + f"--{lim}\r\n" + f'Content-Disposition: form-data; name="file"; filename="a.wav"\r\n' + f"Content-Type: audio/wav\r\n\r\n" + ).encode() + datos + f"\r\n--{lim}--\r\n".encode() + + req = urllib.request.Request( + f"http://127.0.0.1:{self._puerto}/inference", data=cuerpo, + headers={"Content-Type": f"multipart/form-data; boundary={lim}"}) + try: + with urllib.request.urlopen(req, timeout=120) as r: + return json.loads(r.read().decode()).get("text", "").strip() + except (urllib.error.URLError, json.JSONDecodeError, OSError): + return "" + + +# --------------------------------------------------------------- el filtro + +# Lo que Whisper devuelve cuando no habia nadie hablando. No es un fallo suyo: +# se entreno con subtitulos de YouTube, donde el silencio se anota asi. +SOLO_ETIQUETA = re.compile(r"^\s*[\[\(\*].{0,40}[\]\)\*]\s*[.!¡]*\s*$") +MULETILLAS = ( + "suscribete", "suscríbete", "gracias por ver", "hasta la proxima", + "hasta la próxima", "no olvides suscribirte", "dale like", +) + +# Anotaciones que Whisper escribe cuando no hay nadie hablando. Salen de contar +# el registro real de JARVIS, no de imaginarlas: son las que mas se repiten +# entre las frases cortas que no llegan a ninguna accion. +# +# A veces vienen entre corchetes —y entonces las coge SOLO_ETIQUETA— y a veces +# sueltas, que es para lo que sirve esta lista. +ANOTACIONES = { + "musica", "música", "motor", "risas", "risa", "aplausos", "cantando", + "ruido", "silencio", "tos", "suspiro", "aplauso", "llanto", "gritos", + "ah", "ahh", "ahhh", "eh", "ehh", "mmm", "hmm", "uf", "ay", +} + +# Lo que SI se deja pasar aunque no lleve a ninguna accion: "hola", "adios", +# "gracias", "buenas". Una persona las dice de verdad y merecen respuesta; lo +# unico que cuesta dejarlas pasar es una consulta al modelo. Filtrar de mas +# aqui es peor que filtrar de menos: JARVIS parecería sordo a un saludo. + + +def es_ruido(texto: str) -> bool: + """Si esto es Whisper alucinando en vez de alguien hablando. + + Se aplica ANTES del emparejador y antes de anotar el fallo. Sin esto, el + 16,2 % del registro de "no entendido" son "[Musica]" y "(aplausos)", y la + mejora nocturna acaba generando alias a partir de ellos. + + Conservador a proposito. Lo que se cuela solo cuesta una consulta al modelo; + lo que se filtra de mas es una orden que JARVIS ignora, y eso se vive como + que no funciona. + """ + if not texto or not texto.strip(): + return True + if SOLO_ETIQUETA.match(texto): + return True + limpio = texto.lower().strip(" .,!¡?¿*-—") + if limpio in ANOTACIONES: + return True + return any(m in limpio for m in MULETILLAS) diff --git a/nucleo/pruebas/banco.py b/nucleo/pruebas/banco.py new file mode 100755 index 0000000..b8b2006 --- /dev/null +++ b/nucleo/pruebas/banco.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Banco fijo para medir el emparejador sin volver a grabar. + + banco.py --crear [n] graba y transcribe una vez (lento) + banco.py --mide puntua el catalogo actual (instantaneo) + banco.py --fallos enseña que se sigue fallando + +## Por que hace falta + +La mejora nocturna medía generando audio nuevo cada ronda: nueve horas, 39 +rondas, +1,45 puntos de mejora contra ±1,9 puntos de ruido de medida. El ruido +era mayor que la mejora, asi que "¿esto mejora o empeora?" se decidia a cara o +cruz. Hoy ha vuelto a pasar: los flags de Whisper parecian dar +5 puntos con 40 +muestras y con 150 no dan nada. + +Aqui se graba UNA vez con semilla fija y se guardan las TRANSCRIPCIONES. A +partir de ahi, probar un cambio en el emparejador es instantaneo y determinista: +la misma entrada siempre, asi que dos medidas son comparables de verdad. + +## La trampa que ya costo una conclusion equivocada + +El 24 % del catalogo son acciones que piden argumento. Diciendolas sueltas +—"busca el texto"— el emparejador NO las dispara, y hace bien: no son una orden. +Un banco que las dice sueltas mide 56 % donde la realidad es 79 %, y hace +parecer que el emparejador esta roto cuando lo que esta roto es el banco. +Por eso `--crear` les añade argumento. +""" +import argparse +import collections +import difflib +import json +import os +import random +import subprocess +import sys +import tempfile + +AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, AQUI) + +from manos.acciones import Catalogo, normaliza # noqa: E402 +from oido.whisper import Oido, es_ruido # noqa: E402 + +RAIZ = os.path.dirname(AQUI) +PIPER = os.path.join(RAIZ, "config", "jarvis-piper.sh") +CATALOGO = os.path.join(AQUI, "datos", "acciones.json") +BANCO = os.path.join(AQUI, "datos", "banco.json") + +# Fijas a proposito: son lo que hace el banco reproducible. Cambiarlas obliga a +# rehacerlo, y esa es justamente la señal de que ya no es comparable. +VOCES = ["es_ES-davefx-medium", "es_MX-claude-high", "es_ES-sharvard-medium"] +SEMILLA = 20260815 +ARGUMENTOS = ["informes", "descargas", "musica", "el proyecto", "documentos"] + + +def crear(n: int) -> int: + cat = Catalogo([CATALOGO]) + rnd = random.Random(SEMILLA) + conf = [a for a in cat.acciones if a.frases] + + casos = [] + for a in rnd.sample(conf, min(n, len(conf))): + frase = rnd.choice(a.frases) + if a.captura: + frase += " " + rnd.choice(ARGUMENTOS) + casos.append({"id": a.id, "frase": frase, "voz": rnd.choice(VOCES), + "captura": a.captura}) + + print(f" grabando {len(casos)} frases...", flush=True) + tmp = tempfile.mkdtemp(prefix="banco") + for i, c in enumerate(casos): + wav = os.path.join(tmp, f"{i:03d}.wav") + subprocess.run([PIPER, wav, c["frase"]], + env={**os.environ, "JARVIS_PIPER_VOZ": c["voz"], + "JARVIS_PIPER_TONO": "1.0"}, + capture_output=True, timeout=90) + c["wav"] = wav if os.path.exists(wav) and os.path.getsize(wav) else None + + oreja = Oido(modelo="small", beam=5) + if not oreja.arranca(): + print(" whisper no arranco") + return 2 + print(" transcribiendo...", flush=True) + for i, c in enumerate(casos): + c["oido"] = oreja.transcribe(c["wav"]) if c["wav"] else "" + c.pop("wav", None) + if (i + 1) % 40 == 0: + print(f" {i+1}/{len(casos)}", flush=True) + oreja.para() + subprocess.run(["rm", "-rf", tmp]) + + casos = [c for c in casos if c.get("oido")] + os.makedirs(os.path.dirname(BANCO), exist_ok=True) + with open(BANCO, "w", encoding="utf-8") as f: + json.dump({"semilla": SEMILLA, "modelo": "small", "casos": casos}, + f, ensure_ascii=False, indent=1) + print(f" guardado: {len(casos)} transcripciones en {BANCO}") + return 0 + + +def carga(): + try: + with open(BANCO, encoding="utf-8") as f: + return json.load(f)["casos"] + except (OSError, json.JSONDecodeError, KeyError): + return [] + + +def mide(detalle=False) -> int: + casos = carga() + if not casos: + print(" no hay banco: crealo con --crear") + return 1 + cat = Catalogo([CATALOGO]) + + bien = 0 + oye_mal, no_relaciona, otra, ruido = [], [], [], [] + for c in casos: + t = c["oido"] + if es_ruido(t): + ruido.append(c) + continue + a, _ = cat.busca(t) + if a is not None and a.id == c["id"]: + bien += 1 + continue + p = difflib.SequenceMatcher(None, normaliza(t), normaliza(c["frase"])).ratio() + if p < 0.75: + oye_mal.append((c, t, p)) + elif a is None: + no_relaciona.append((c, t, p)) + else: + otra.append((c, t, a.id)) + + n = len(casos) + print(f" {n} frases · acierto {100*bien/n:.1f} %\n") + print(f" {'que pasa':28s} {'n':>4s} {'%':>7s}") + print(" " + "-" * 44) + for nombre, k in [("bien", bien), ("oye bien, NO RELACIONA", len(no_relaciona)), + ("OYE MAL la frase", len(oye_mal)), + ("oye bien, OTRA accion", len(otra)), + ("lo tira por ruido", len(ruido))]: + print(f" {nombre:28s} {k:4d} {100*k/n:6.1f} %") + + if detalle: + print("\n --- oye bien pero no relaciona ---") + for c, t, p in no_relaciona: + print(f" {c['id']:24s} dijo: {c['frase']}") + print(f" {'':24s} oyo: {t} ({p:.2f})") + print("\n --- se la lleva otra accion (lo peor: ejecuta lo que no es) ---") + for c, t, o in otra: + print(f" {c['id']:24s} -> {o} oyo: {t}") + return 0 + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--crear", action="store_true") + p.add_argument("--mide", action="store_true") + p.add_argument("--fallos", action="store_true") + p.add_argument("n", nargs="?", type=int, default=150) + a = p.parse_args() + if a.crear: + return crear(a.n) + return mide(detalle=a.fallos) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/nucleo/pruebas/prueba_candado.py b/nucleo/pruebas/prueba_candado.py new file mode 100755 index 0000000..0fdf6d3 --- /dev/null +++ b/nucleo/pruebas/prueba_candado.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""El candado: que tape, que rechace lo que no es, y que NO obedezca. + + ./venv/bin/python pruebas/prueba_candado.py + +Lo que de verdad importa aqui no es que el panel se ponga negro —eso es +cosmetica— sino que con el candado puesto **no se ejecute nada**. El panel tapado +pero obedeciendo seria peor que no tener candado, porque da sensacion de +seguridad sin darla. + +No se prueba con la contraseña de verdad: no la tenemos ni hace falta. Se +comprueba que una mala se rechaza y que mientras tanto no pasa ni una orden. +""" +import os +import sys +import threading +import time + +AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, AQUI) + +from boca.voz import Boca # noqa: E402 +from cara.panel import Panel # noqa: E402 +from manos import Manos # noqa: E402 + +fallos = [] + + +def afirma(condicion, queja): + print(f" {'OK ' if condicion else 'MAL'} {queja}") + if not condicion: + fallos.append(queja) + + +class CaraFalsa: + """Se queda con lo que el panel manda, sin levantar ningun servidor.""" + + def __init__(self): + self.mensajes = [] + self.conectados = 1 + + def manda(self, tipo, datos): + self.mensajes.append((tipo, datos)) + + def de(self, tipo): + return [d for t, d in self.mensajes if t == tipo] + + +class BocaMuda(Boca): + """Como la de verdad pero sin sonar: la prueba no tiene que hablar.""" + + def __init__(self): + super().__init__("davefx") + self.dicho = [] + + def di(self, texto): + self.dicho.append(texto); return True + + def di_si_libre(self, texto): + self.dicho.append(texto); return True + + +def main() -> int: + manos = Manos() + cara = CaraFalsa() + boca = BocaMuda() + p = Panel(cara, boca, manos, candado=True) + + print("\n al abrir") + p.arranca() + time.sleep(0.2) + afirma(not p.desbloqueado, "nace bloqueado") + afirma(bool(cara.de("bloqueo")), "pide la contraseña nada mas abrir") + + print("\n con el candado puesto NO obedece") + antes = len(cara.mensajes) + for orden in ("prueba", "voz:siguiente", "parar"): + p.pulsado("accion", orden) + time.sleep(1.0) + afirma(not boca.dicho, f"no ha hablado ({boca.dicho})") + # Desde que la tanda va por tareas, lo que se ve al ejecutar son mensajes + # "tarea" (una tarjeta por orden), no "respuesta". + hechas = [d for t, d in cara.mensajes[antes:] if t in ("tarea", "respuesta")] + afirma(not hechas, f"no ha ejecutado nada ({len(hechas)} tareas o respuestas)") + # Solo se miran las respuestas a lo pulsado: la telemetria sigue latiendo + # en su hilo cada segundo y se cuela entre medias. + contestaciones = [t for t, _ in cara.mensajes[antes:] if t != "telemetria"] + afirma(contestaciones and all(t == "bloqueo" for t in contestaciones), + f"a cada intento contesta pidiendo la contraseña ({contestaciones})") + + print("\n una contraseña mala se rechaza") + antes = len(cara.mensajes) + p.pulsado("accion", "desbloquear:no-es-esta-contrasena-12345") + time.sleep(0.5) + afirma(not p.desbloqueado, "sigue bloqueado") + ultimos = cara.de("bloqueo")[-1:] or [{}] + afirma(ultimos[0].get("error") is True, "y lo dice como error") + afirma(not cara.de("desbloqueado"), "no manda 'desbloqueado'") + + print("\n una vacia ni se intenta") + antes = len(cara.mensajes) + p.pulsado("accion", "desbloquear:") + afirma(len(cara.mensajes) == antes, "no molesta a sudo con una clave vacia") + + print("\n sin candado, obedece") + cara2, boca2 = CaraFalsa(), BocaMuda() + p2 = Panel(cara2, boca2, manos, candado=False) + p2.arranca() + afirma(p2.desbloqueado, "nace desbloqueado") + afirma(not cara2.de("bloqueo"), "no pide contraseña") + p2.pulsado("accion", "prueba") + time.sleep(3.0) + tarjetas = {d["id"] for d in cara2.de("tarea")} + afirma(bool(tarjetas), f"ejecuta de verdad ({len(tarjetas)} tarjetas)") + afirma(any(d["estado"] == "hecha" for d in cara2.de("tarea")), + "y las tareas llegan a terminar") + + print("\n y se puede volver a echar") + p2.bloquea() + afirma(not p2.desbloqueado, "bloquea() cierra aunque arrancara sin candado") + antes = len(cara2.mensajes) + p2.pulsado("accion", "prueba") + time.sleep(0.6) + afirma(not [d for t, d in cara2.mensajes[antes:] if t in ("tarea", "respuesta")], + "y despues de cerrar ya no obedece") + + p.para(); p2.para() + print(f"\n {'todo en orden' if not fallos else str(len(fallos)) + ' fallan'}\n") + return 1 if fallos else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/nucleo/pruebas/prueba_cara.py b/nucleo/pruebas/prueba_cara.py new file mode 100755 index 0000000..2ecc0ef --- /dev/null +++ b/nucleo/pruebas/prueba_cara.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""El panel: que lo que manda el nucleo sea lo que el HUD sabe leer. + + ./venv/bin/python pruebas/prueba_cara.py + +## Por que existe esta prueba + +Montando la telemetria, `ps` devolvia el uso de CPU como cadena y el nucleo la +mandaba tal cual. El HUD hace `p.cpu.toFixed(0)`, y una cadena no tiene +`toFixed`: la excepcion abortaba el manejador ENTERO y se llevaba por delante +todo lo que se pinta despues. En pantalla se veia que CPU, RAM y grafica +salian en "--", y el fallo no apuntaba a los procesos por ningun lado. + +Ese es el problema de un puente sin contrato: un campo con el tipo equivocado +apaga media pantalla y no dice donde. Aqui se fija el contrato —que campo, de +que tipo y en que unidad— comprobando ademas que `hud.js` lo sigue usando asi, +para que enterarse no dependa de mirar la pantalla. +""" +import json +import os +import re +import sys + +AQUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, AQUI) + +from cara.panel import Telemetria # noqa: E402 + +HUD = os.path.join(AQUI, "cara", "hud") + +fallos = [] + + +def afirma(condicion, queja): + print(f" {'OK ' if condicion else 'MAL'} {queja}") + if not condicion: + fallos.append(queja) + + +def numero(v): + return isinstance(v, (int, float)) and not isinstance(v, bool) + + +def main() -> int: + print("\n el puente esta puesto") + with open(os.path.join(HUD, "index.html"), encoding="utf-8") as f: + html = f.read() + afirma("puente.js" in html, "index.html carga puente.js") + # Se comparan las etiquetas