diff --git a/.github/workflows/verificacion.yml b/.github/workflows/verificacion.yml index 77f6e4e..4cfad2b 100644 --- a/.github/workflows/verificacion.yml +++ b/.github/workflows/verificacion.yml @@ -43,11 +43,29 @@ jobs: run: | python -m pip install --upgrade pip pip install -r perseo_core/requirements.txt - pip install pytest + # Las herramientas van FIJAS, y ruff con nombre propio. El + # 2026-09-13 este paso se puso rojo sin que nadie tocara una linea + # de estilo: ruff 0.16 cambio su conjunto de reglas por defecto y + # aparecieron 247 avisos nuevos, entre ellos RUF100 sobre los + # `# noqa: E402` que hasta entonces hacian falta. Un linter que + # cambia solo no dice si el codigo esta bien: dice que version se + # instalo esa manana. Subirla es una decision, y se toma mirando lo + # que trae, no al abrir un PR de otra cosa. + pip install pytest==9.1.1 ruff==0.15.8 vulture==2.16 - name: Pruebas run: python -m pytest + - name: Estilo + run: python -m ruff check . + + # Lo que no llama nadie. Las excepciones, con su porque, en + # verificadores/vulture_permitidos.py -- y una excepcion sin porque no vale. + - name: Codigo muerto + run: > + python -m vulture perseo_core commands verificadores pruebas + verificadores/vulture_permitidos.py --min-confidence 60 + tipos: name: Tipos y build del frontend runs-on: ubuntu-latest @@ -72,6 +90,13 @@ jobs: working-directory: RealTime run: npm test + # El equivalente de vulture para la interfaz: ficheros, exportaciones y + # dependencias que ya no usa nadie. La configuracion, comentada, en + # RealTime/knip.jsonc. + - name: Codigo muerto + working-directory: RealTime + run: npm run muertos + - name: Build working-directory: RealTime run: npm run build @@ -141,5 +166,5 @@ jobs: set -e for script in fase_a aprobaciones telegram fase_d agenda memoria pc dev web politica google estado; do echo "--- $script ---" - python "perseo_core/verificar_$script.py" + python "verificadores/verificar_$script.py" done diff --git a/AGENTS.md b/AGENTS.md index 22a38ac..56c0a2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,22 +16,29 @@ componente de React, estás en el sitio equivocado. ## Dónde está cada cosa +El núcleo va en cinco capas, de abajo arriba, y **nadie importa hacia arriba**: +`dominio/` (los tipos), `infra/` (cola, bus, política, router), `servicios/` (la +maquinaria), `agentes/` (los que atienden un trabajo) y `caras/` (API, Telegram, +la web del móvil). No es una costumbre: lo comprueba `pruebas/test_arquitectura.py`. + | Vas a tocar | Mira primero | |---|---| -| La cola, el bus, la API | `perseo_core/api.py`, `bus.py`, `almacen.py` | -| A qué agente va cada cosa | `perseo_core/agentes.py` (el router) | -| Un agente concreto | `perseo_core/.py` — se llaman como el agente | -| Qué necesita confirmación | `perseo_core/politica.py` | -| El prompt compartido | `perseo_core/identidad.py` | -| La llamada de voz | `RealTime/src/lib/gemini-live.ts` y `RealTime/src/App.tsx` | +| La cola, el bus, la API | `perseo_core/caras/api.py`, `infra/bus.py`, `infra/almacen.py` | +| A qué agente va cada cosa | `perseo_core/infra/router.py` (el router) | +| Un agente concreto | `perseo_core/agentes/.py` — se llaman como el agente | +| Qué necesita confirmación | `perseo_core/infra/politica.py` | +| El prompt compartido | `perseo_core/infra/identidad.py` | +| La llamada de voz | `RealTime/src/lib/llamada/gemini-live.ts` y `RealTime/src/App.tsx` | | El panel | `RealTime/src/components/Panel.tsx` | | Los puentes a Rust | `RealTime/src-tauri/src/commands.rs` y `nucleo.rs` | -| La web del móvil | `perseo_core/interfaz/index.html` — un solo fichero, sin build | +| La web del móvil | `perseo_core/caras/interfaz/index.html` — un solo fichero, sin build | +| Las piezas de la app | `RealTime/src/lib/` en cuatro carpetas: `audio/`, `llamada/`, `datos/`, `identidad/` | | Configuración | [`docs/CONFIGURACION.md`](docs/CONFIGURACION.md) | -Los ficheros que pasan de mil líneas —`Panel.tsx`, `dev.py`, `chat.py`, -`almacen.py`, `gemini-live.ts`, `mcp.py`, `api.py`— se leen **por rangos tras -un `grep -n`**, no de una sentada. +Hay un **techo de tamaño**: blando a 600 líneas, duro a 900, con una lista de +excepciones en `commands/arquitectura.py` que solo puede encoger. Los que hoy +siguen por encima se leen **por rangos tras un `grep -n`**, no de una sentada. +`python commands/perseo.py comprobar --arquitectura` dice cuáles son. ## Ver lo que has cambiado @@ -39,7 +46,7 @@ un `grep -n`**, no de una sentada. |---|---|---| | `RealTime/src/**` | `python commands/perseo.py actualizar` | **No** — la interfaz va incrustada dentro del binario | | `perseo_core/*.py`, `commands/*.py` | Reiniciar el núcleo: `perseo parar` y luego `perseo on` | **No** — el proceso viejo se queda con el código viejo | -| `perseo_core/interfaz/index.html` | Recargar el navegador | Sí: el núcleo lo sirve del disco | +| `perseo_core/caras/interfaz/index.html` | Recargar el navegador | Sí: el núcleo lo sirve del disco | Para el **aspecto** del panel no hace falta pagar los dos minutos de reconstrucción: la maqueta sirve las pantallas de verdad con datos de mentira @@ -55,18 +62,23 @@ Rutas de la maqueta: `/` el panel, `#tareas` el tablero, `#habitos` y ## Antes de dar algo por bueno ```bash -python -m pytest # 724 pruebas -python -m ruff check . -cd RealTime && npx tsc --noEmit && npm test # 138 pruebas -cd RealTime/src-tauri && cargo check --locked +python commands/perseo.py comprobar ``` -Las cuatro corren también en CI, en Linux y en Windows. +Eso es pytest, ruff, `tsc`, las pruebas del frontend y `cargo check`, en orden +de coste: lo que tarda segundos primero. Las cinco corren también en CI, en +Linux y en Windows. `--rapido` se salta Rust, que es la que tarda. + +Está escrito en **un solo sitio** a propósito. Antes eran cuatro bloques +copiados —aquí, en el README y en el fichero del CI— y los recuentos de pruebas +que llevaban dentro ya no coincidían en ninguno. Los números que cita la +documentación salen ahora de `perseo cuentas`, y `perseo cuentas --arreglar` los +reescribe; hay una prueba que compara. Si tocaste el comportamiento de verdad —no solo el aspecto— pasa además el -verificador que le toque: son diecisiete, están en `perseo_core/verificar_*.py` -y ninguno toca el estado real (se montan un directorio temporal y servidores de -mentira). +verificador que le toque. Están en `verificadores/`, menos el de la palabra +clave, que vive en `commands/` porque necesita micrófono. Ninguno toca el estado +real: se montan un directorio temporal y servidores de mentira. ## Trampas que cuestan una hora @@ -101,6 +113,31 @@ mentira). - **Nada de secretos en el código.** Si algo necesita una clave, se lee del entorno o de ``, y `` está en el `.gitignore`. +## Las reglas que no dependen de que te acuerdes + +Cinco cosas que antes eran costumbre y ahora las comprueba +`pruebas/test_arquitectura.py` y `pruebas/test_documentacion.py`. Si alguna se +pone roja, el arreglo **no** es tocar la prueba: + +| Regla | Qué pasa si la rompes | Dónde se afloja | +|---|---|---| +| El núcleo va en capas y nadie importa hacia arriba | rojo, con el importe señalado | el orden de `CAPAS` en `commands/arquitectura.py` | +| Ningún fichero pasa de 900 líneas | rojo, con el fichero y su cuenta | pártelo; la lista de excepciones solo encoge | +| Un componente no llama al núcleo | rojo, con el componente | pídele los datos a un gancho de `lib/datos/` | +| `docs/API.md` describe las rutas que existen, y todas | rojo, con la ruta | documéntala o bórrala | +| Los recuentos que cita el README son los de verdad | rojo, con la cifra | `perseo cuentas --arreglar` | + +Las excepciones vivas —dos ficheros grandes y diez componentes— tienen nombre y +apellidos en `commands/arquitectura.py`, y las dos que necesitan explicación la +tienen en [`docs/adr/`](docs/adr/). Una excepción sin porqué no vale: si nadie +sabe explicar por qué algo sigue ahí, la respuesta correcta es quitarlo. + +Para ver cómo va todo de un vistazo: + +```bash +python commands/perseo.py comprobar --arquitectura +``` + ## Lo que no hay que hacer - Meter lógica en una cara. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75cccc9..78287a6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,19 +17,24 @@ es más de lo que esperaba, así que gracias. ## Antes de abrir un PR ```bash -python -m pytest -python -m ruff check . -cd RealTime && npx tsc --noEmit && npm test -cd RealTime/src-tauri && cargo check --locked +python commands/perseo.py comprobar ``` -Las cuatro tienen que estar en verde. Corren también en CI, en Linux y en -Windows, así que si fallan allí y aquí no, suele ser una ruta con `\` o un -final de línea. +Eso es pytest, ruff, el buscador de código muerto, los tipos y las pruebas del +frontend, `knip` y `cargo check`, en orden de coste. Todo tiene que estar en +verde. Corre también en CI, en Linux y en Windows, así que si falla allí y aquí +no, suele ser una ruta con `\` o un final de línea. `--rapido` se salta Rust. + +Algunas de esas comprobaciones no miran lo que hace el código sino **dónde vive**: +que el núcleo siga en capas, que ningún fichero pase de novecientas líneas, que +`docs/API.md` describa las rutas que existen y que los recuentos del README sean +los de verdad. Están explicadas en [`AGENTS.md`](AGENTS.md) y las decisiones que +hay detrás, en [`docs/adr/`](docs/adr/). Si una se pone roja, el arreglo no es +tocar la prueba. Si cambias comportamiento, añade o ajusta la prueba que lo cubre. Si cambias algo del sistema entero —la cola, la política, un agente— pasa además su -verificador (`perseo_core/verificar_*.py`). +verificador (`verificadores/verificar_*.py`). ## Cómo se escribe aquí diff --git a/README.en.md b/README.en.md index a5e0322..c804256 100644 --- a/README.en.md +++ b/README.en.md @@ -8,14 +8,16 @@ voice, desktop panel, phone web app, and alerts in your pocket.** It talks to you by voice in real time, triages your inbox before you open it, -writes to your memory, delegates coding tasks to other agents — and asks for -permission before doing anything it can't undo. +writes to your memory and delegates coding tasks to other agents. It grades +everything it does by risk — in the worker, not in the prompt — and can stop +anything irreversible to wait for your yes. That stop ships switched off: +see [ADR 0005](docs/adr/0005-las-confirmaciones-estan-apagadas.md). [![Verification](https://github.com/PersusUS/Perseo/actions/workflows/verificacion.yml/badge.svg)](https://github.com/PersusUS/Perseo/actions/workflows/verificacion.yml) [![MIT licence](https://img.shields.io/badge/licence-MIT-black.svg)](LICENSE) [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-black.svg)](https://www.python.org/) [![Tauri 2](https://img.shields.io/badge/tauri-2-black.svg)](https://tauri.app/) -[![862 tests](https://img.shields.io/badge/tests-862-black.svg)](#verification) +[![919 tests](https://img.shields.io/badge/tests-919-black.svg)](#verification) [What it is](#what-it-is) · [What it looks like](#what-it-looks-like) · [How it works](#how-it-works) · [Install](#install) · [Privacy](#privacy) · @@ -46,7 +48,7 @@ Raspberry Pi tomorrow without rewriting a line of interface code. | 📬 **Mail triaged before you read it** | Every message lands in a bucket — ignore, interesting, needs action, not sure — decided by a **local** model, on your GPU | | 🧠 **Real memory** | Markdown notes in your Obsidian vault. It searches, reads and **appends**; never overwrites, never deletes | | 👤 **It knows who's talking** | Recognises voices and faces with local models, and learns people it hasn't met. Off by default | -| 🛑 **It asks first** | Three confirmation levels enforced in the worker, not in the prompt. Anything irreversible stops and waits for your yes | +| 🛑 **It grades risk, and knows who asked** | Four levels enforced in the worker, not in the prompt. Switched on, anything irreversible stops and waits for your yes — and an order from a guest stops even while you are right there. **They ship off** as of 2026-09-12: [ADR 0005](docs/adr/0005-las-confirmaciones-estan-apagadas.md) | | 📱 **It follows you to your phone** | A PWA over your home VPN: chat, queue, mail and status. No build step, one single file | | 🤖 **It delegates code** | Hands tasks to sub-agents (Claude Code or opencode) and tells you how they're going while they work | | 🔌 **It speaks MCP** | Its own client for local and remote servers: vault, browser, Windows, triaged mail, sub-agents | @@ -208,9 +210,26 @@ three levels enforced **in the worker**, before the agent runs: | `reversible` | Append to the vault, edit code | Runs, and is logged | | `irreversible` | Typing blind — and **anything not classified** | Stops and asks for a yes | +There is a fourth one, `critico` — deleting, touching the registry, killing +processes — that **always** asks, trust mode or not. + +> **And one switch above all of it.** As of 2026-09-12 +> `politica.CONFIRMACIONES` is `False`, and the right-hand column reads +> "runs" on all four rows: nothing stops, `critico` included. The table, the +> levels and their tests are all still there — the only thing that never +> happens is the stop. Why, what it costs and how to re-arm it — one line, or +> `PERSEO_CONFIRMACIONES=1` — are in [ADR 0005](docs/adr/0005-las-confirmaciones-estan-apagadas.md). + You give the yes from the web app, from the Telegram alert, or **out loud during the call**. *Trust mode* lowers irreversible to reversible while you're -sitting there, and expires on its own. +sitting there, and expires on its own: during a call it is renewed by your +voice, so it switches itself off if you walk away. + +**Who asked counts too.** Every job travels with the profile of whoever spoke — +set by the voice recognition running on your own machine — and an order from +someone who isn't you stops even with trust mode on. Your yes also covers exact +repeats of the same request for ten minutes: dictating an address is six +identical orders, and asking six times teaches you to say yes without reading. --- @@ -312,8 +331,8 @@ goes in `perseo_core/datos/`, outside git, because it carries a ``` ```bash -python -m perseo_core.autorizar_google # opens consent and stores the token -python -m perseo_core.google_api # checks the credentials work +python -m perseo_core.servicios.autorizar_google # opens consent and stores the token +python -m perseo_core.servicios.google_api # checks the credentials work PERSEO_CORREO=gmail PERSEO_AGENDA=google python -m perseo_core ``` @@ -357,11 +376,13 @@ the tables read fine in any language. None of this is checked by eye, and it's checked two ways. **Unit tests** — each piece on its own, no network, no subprocesses. They tell -you *what* broke: **862** in total. +you *what* broke: **919** in total. ```bash -python -m pytest # 724, core and commands -cd RealTime && npm test # 138, the interface +python commands/perseo.py comprobar # everything, cheapest first + +python -m pytest # 760, core and commands +cd RealTime && npm test # 159, the interface cd RealTime/src-tauri && cargo check # and that the Rust compiles ``` @@ -370,13 +391,13 @@ cd RealTime/src-tauri && cargo check # and that the Rust compiles data directory and fake servers. ```bash -python perseo_core/verificar_fase_a.py # the core: queue, restarts, SSE -python perseo_core/verificar_aprobaciones.py # the confirmation path -python perseo_core/verificar_politica.py # the levels and trust mode -python perseo_core/verificar_pc.py # injection attempts against `pc` -python perseo_core/verificar_web.py # `web`, without touching the internet -python perseo_core/verificar_biometria.py # voices and faces: learn, rename, delete -# …seventeen in total, all in perseo_core/verificar_*.py +python verificadores/verificar_fase_a.py # the core: queue, restarts, SSE +python verificadores/verificar_aprobaciones.py # the confirmation path +python verificadores/verificar_politica.py # the levels and trust mode +python verificadores/verificar_pc.py # injection attempts against `pc` +python verificadores/verificar_web.py # `web`, without touching the internet +python verificadores/verificar_biometria.py # voices and faces: learn, rename, delete +# …seventeen in total, all in verificadores/verificar_*.py ``` The first blocks run on every push @@ -412,7 +433,7 @@ details are in [`docs/PRIVACIDAD.md`](docs/PRIVACIDAD.md). Perseo works and gets used daily, but it's a personal project: built for **one** person on **one** Windows machine, and it shows. Behind it are roughly -48,600 lines, 862 tests and 17 verifiers. +49,100 lines, 919 tests and 17 verifiers. If you clone it and something won't start, open an [issue](https://github.com/PersusUS/Perseo/issues) — and if you fix it, even diff --git a/README.md b/README.md index 9727f53..8d793a1 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,15 @@ voz, panel de escritorio, web en el móvil y avisos al bolsillo.** Habla contigo por voz en tiempo real, tría tu correo antes de que lo abras, -escribe en tu memoria, encarga código a otros agentes y te pide permiso -antes de hacer algo que no tenga vuelta atrás. +escribe en tu memoria y encarga código a otros agentes. Clasifica por riesgo +todo lo que hace — en el trabajador, no en el prompt —, y puede parar lo +irreversible a esperar tu sí. Esa parada viene apagada: [ADR 0005](docs/adr/0005-las-confirmaciones-estan-apagadas.md). [![Verificación](https://github.com/PersusUS/Perseo/actions/workflows/verificacion.yml/badge.svg)](https://github.com/PersusUS/Perseo/actions/workflows/verificacion.yml) [![Licencia MIT](https://img.shields.io/badge/licencia-MIT-black.svg)](LICENSE) [![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-black.svg)](https://www.python.org/) [![Tauri 2](https://img.shields.io/badge/tauri-2-black.svg)](https://tauri.app/) -[![862 pruebas](https://img.shields.io/badge/pruebas-862-black.svg)](#verificación) +[![919 pruebas](https://img.shields.io/badge/pruebas-919-black.svg)](#verificación) [Qué es](#qué-es) · [Cómo se ve](#cómo-se-ve) · [Cómo funciona](#cómo-funciona) · [Instalación](#instalación) · [Privacidad](#privacidad) · [English](README.en.md) @@ -41,7 +42,7 @@ Raspberry Pi sin reescribir una línea de la interfaz. | 📬 **Correo triado antes de que lo leas** | Cada mensaje cae en un cajón —ignorar, interesante, requiere acción, no seguro— decidido por un modelo **local**, en tu GPU | | 🧠 **Memoria de verdad** | Notas Markdown en tu vault de Obsidian. Busca, lee y **añade**; nunca sobrescribe ni borra | | 👤 **Sabe quién habla** | Reconoce voces y caras con modelos locales, y aprende solo a quien no conoce. Apagado de fábrica | -| 🛑 **Pide permiso** | Tres niveles de confirmación aplicados en el trabajador, no en el prompt. Lo irreversible se para y espera tu sí | +| 🛑 **Clasifica por riesgo, y sabe quién lo pide** | Cuatro niveles aplicados en el trabajador, no en el prompt. Encendidos, lo irreversible se para y espera tu sí — y una orden de una visita se para aunque estés delante. **Vienen apagados** desde el 2026-09-12: [ADR 0005](docs/adr/0005-las-confirmaciones-estan-apagadas.md) | | 📱 **Te sigue al móvil** | Una PWA por la VPN de casa: chat, cola, correo y estado. Sin build y en un solo fichero | | 🤖 **Delega código** | Encarga tareas a subagentes (Claude Code u opencode) y te cuenta por dónde van mientras trabajan | | 🔌 **Habla MCP** | Cliente propio para servidores locales y remotos: vault, navegador, Windows, correo triado, subagentes | @@ -204,9 +205,27 @@ agente se ejecute: | `reversible` | Anotar en el vault, editar código | Se ejecuta y queda registrado | | `irreversible` | Teclear a ciegas, y **todo lo que no esté clasificado** | Se para y pide un sí | +Y hay un cuarto, `critico` —borrar, tocar el registro, matar procesos—, que +pregunta **siempre**, con modo confianza o sin él. + +> **Y un interruptor por encima de todo esto.** Desde el 2026-09-12, +> `politica.CONFIRMACIONES` está en `False` y la columna de la derecha dice +> «se ejecuta» en las cuatro filas: nada se para, tampoco lo `critico`. La +> tabla, los niveles y sus pruebas siguen enteros — lo único que no llega a +> aplicarse es la parada. El porqué, lo que cuesta y cómo se rearma —una +> línea, o `PERSEO_CONFIRMACIONES=1`— están en [ADR 0005](docs/adr/0005-las-confirmaciones-estan-apagadas.md). + El sí se da desde la web, desde el aviso de Telegram o **en voz alta durante la llamada**. El *modo confianza* baja lo irreversible a reversible mientras estás -delante, y caduca solo. +delante, y caduca solo: durante una llamada se renueva con tu voz, así que se +apaga sola si te levantas. + +**Quién lo pide también cuenta.** Cada trabajo viaja con el perfil de quien +habló —lo pone el reconocimiento de voz, que corre en tu ordenador—, y una +orden de alguien que no eres tú se para aunque la confianza esté encendida. Un +sí tuyo vale además para las repeticiones exactas de lo mismo durante diez +minutos: dictar una dirección son seis órdenes idénticas, y preguntar seis +veces enseña a decir que sí sin leer. --- @@ -306,8 +325,8 @@ lleva un `refresh_token`: ``` ```bash -python -m perseo_core.autorizar_google # abre el consentimiento y guarda el testigo -python -m perseo_core.google_api # comprueba que las credenciales valen +python -m perseo_core.servicios.autorizar_google # abre el consentimiento y guarda el testigo +python -m perseo_core.servicios.google_api # comprueba que las credenciales valen PERSEO_CORREO=gmail PERSEO_AGENDA=google python -m perseo_core ``` @@ -350,11 +369,13 @@ y las rutas de la API en [`docs/API.md`](docs/API.md). Nada de esto se comprueba a ojo, y se comprueba de dos maneras. **Pruebas unitarias** — cada pieza por separado, sin red y sin subprocesos. -Dicen *qué* se ha roto: **862** en total. +Dicen *qué* se ha roto: **919** en total. ```bash -python -m pytest # 724, el núcleo y los comandos -cd RealTime && npm test # 138, la interfaz +python commands/perseo.py comprobar # todo, en orden de coste + +python -m pytest # 760, el núcleo y los comandos +cd RealTime && npm test # 159, la interfaz cd RealTime/src-tauri && cargo check # y que el Rust compila ``` @@ -363,22 +384,22 @@ funciona. Ninguno toca el estado de verdad: se montan un directorio de datos temporal y servidores de mentira. ```bash -python perseo_core/verificar_fase_a.py # el núcleo: cola, reinicios, SSE -python perseo_core/verificar_aprobaciones.py # el camino de confirmación -python perseo_core/verificar_telegram.py # el canal, contra un Telegram de mentira -python perseo_core/verificar_router.py # el router, contra Ollama -python perseo_core/verificar_fase_d.py # el triaje de correo, sin Gmail -python perseo_core/verificar_agenda.py # los avisos, sin Google Calendar -python perseo_core/verificar_memoria.py # el vault, en fichero y por el plugin -python perseo_core/verificar_pc.py # intentos de inyección contra `pc` -python perseo_core/verificar_dev.py # `dev`, sin gastar suscripción -python perseo_core/verificar_web.py # `web`, sin salir a internet -python perseo_core/verificar_politica.py # los niveles y el modo confianza -python perseo_core/verificar_google.py # Gmail y Calendar, sin cuenta de Google -python perseo_core/verificar_estado.py # la pantalla de estado y sus semáforos -python perseo_core/verificar_correo_mcp.py # el servidor MCP de correo -python perseo_core/verificar_biometria.py # voces y caras: aprender, renombrar, borrar -python perseo_core/verificar_chat.py # el chat escrito y sus herramientas +python verificadores/verificar_fase_a.py # el núcleo: cola, reinicios, SSE +python verificadores/verificar_aprobaciones.py # el camino de confirmación +python verificadores/verificar_telegram.py # el canal, contra un Telegram de mentira +python verificadores/verificar_router.py # el router, contra Ollama +python verificadores/verificar_fase_d.py # el triaje de correo, sin Gmail +python verificadores/verificar_agenda.py # los avisos, sin Google Calendar +python verificadores/verificar_memoria.py # el vault, en fichero y por el plugin +python verificadores/verificar_pc.py # intentos de inyección contra `pc` +python verificadores/verificar_dev.py # `dev`, sin gastar suscripción +python verificadores/verificar_web.py # `web`, sin salir a internet +python verificadores/verificar_politica.py # los niveles y el modo confianza +python verificadores/verificar_google.py # Gmail y Calendar, sin cuenta de Google +python verificadores/verificar_estado.py # la pantalla de estado y sus semáforos +python verificadores/verificar_correo_mcp.py # el servidor MCP de correo +python verificadores/verificar_biometria.py # voces y caras: aprender, renombrar, borrar +python verificadores/verificar_chat.py # el chat escrito y sus herramientas python commands/verificar_palabra_clave.py # el detector, sin micrófono ``` @@ -416,7 +437,7 @@ El detalle, en [`docs/PRIVACIDAD.md`](docs/PRIVACIDAD.md). Perseo funciona y se usa a diario, pero es un proyecto personal: está pensado para **una** persona, en **un** ordenador con Windows, y se nota. Lo que hay -detrás son unas 48.600 líneas, 862 pruebas y 17 verificadores. +detrás son unas 49.100 líneas, 919 pruebas y 17 verificadores. Si lo clonas y algo no arranca, abre un [issue](https://github.com/PersusUS/Perseo/issues) — y si lo arreglas, mejor diff --git a/RealTime/README.md b/RealTime/README.md index 36751b5..abd7099 100644 --- a/RealTime/README.md +++ b/RealTime/README.md @@ -96,10 +96,10 @@ Para compilar: `npm run tauri build`. | Fichero | Qué hace | |---|---| -| `src/lib/gemini-live.ts` | La sesión con Gemini: conexión, reanudación, transcripciones y llamadas a herramientas | -| `src/lib/audio-manager.ts` | El micrófono: captura y encolado | -| `src/lib/audio-player.ts` | La reproducción. **No juntar los dos `AudioContext`** (16 y 24 kHz): está en la lista de intocables | -| `src/lib/camera-manager.ts`, `screen-manager.ts` | Los fotogramas de cámara y pantalla | +| `src/lib/llamada/gemini-live.ts` | La sesión con Gemini: conexión, reanudación, transcripciones y llamadas a herramientas | +| `src/lib/audio/audio-manager.ts` | El micrófono: captura y encolado | +| `src/lib/audio/audio-player.ts` | La reproducción. **No juntar los dos `AudioContext`** (16 y 24 kHz): está en la lista de intocables | +| `src/lib/llamada/camera-manager.ts`, `screen-manager.ts` | Los fotogramas de cámara y pantalla | | `src/App.tsx` | Orquesta la interfaz, la transcripción y la autollamada | | `src-tauri/src/nucleo.rs` | El cliente del núcleo | | `src-tauri/src/bandeja.rs` | El icono de la bandeja y el esconder en vez de cerrar | diff --git a/RealTime/knip.jsonc b/RealTime/knip.jsonc new file mode 100644 index 0000000..281aba9 --- /dev/null +++ b/RealTime/knip.jsonc @@ -0,0 +1,33 @@ +{ + // Qué código de la interfaz no llama nadie. El equivalente de `vulture` en el + // núcleo, y está aquí por lo mismo: el 2026-09-12 se encontraron a mano una + // pantalla entera, un ajuste y cuatro reglas de CSS que ya no usaba nadie. + // Encontrarlos a mano funciona una vez; en el CI funciona siempre. + // + // cd RealTime && npx knip + "$schema": "https://unpkg.com/knip@5/schema.json", + + // Por dónde se entra. `src/main.tsx` y `vite.config.ts` los encuentra knip + // solo; los demás no, y sin ellos daría por muerta media carpeta: + // · `maqueta/` es la maqueta del panel, que sirve las pantallas de verdad + // con datos de mentira y no pasa por la app. + // · `pruebas/` son las de vitest. + // · los dos `.mjs` son sondas que se ejecutan a mano contra la API de + // Gemini. No forman parte de la app, y se quedan a propósito. + "entry": [ + "maqueta/maqueta.tsx", + "pruebas/*.test.ts", + "vite.maqueta.config.ts", + "probar_live.mjs", + "sonda_llamada.mjs" + ], + + "project": ["src/**/*.{ts,tsx}", "maqueta/**/*.{ts,tsx}"], + + // La única excepción, y con motivo: `maqueta/tauri.ts` entra por un alias de + // Vite (`resolve.alias`), que knip no sigue. Sin esta línea lo daría por + // huérfano, y el día que alguien le hiciera caso la maqueta dejaría de + // arrancar. Si algún día hay que añadir otra excepción, que venga con su + // porqué escrito aquí: una lista sin porqués deja de leerse. + "ignore": ["maqueta/tauri.ts"] +} diff --git a/RealTime/maqueta/tauri.ts b/RealTime/maqueta/tauri.ts index b41354e..72ef627 100644 --- a/RealTime/maqueta/tauri.ts +++ b/RealTime/maqueta/tauri.ts @@ -22,8 +22,8 @@ const TRABAJOS = [ }, { id: 270, estado: 'esperando', agente: 'pc', origen: 'voz', - peticion: { accion: 'abrir_app', parametro: 'spotify' }, - confirmacion: { resumen: 'Abrir Spotify', detalle: 'Perseo quiere abrir Spotify y buscar «Loser».' }, + peticion: { accion: 'abrir_app', parametro: 'notepad' }, + confirmacion: { resumen: 'Abrir el bloc de notas', detalle: 'Perseo quiere abrir el bloc de notas y escribir en él.' }, }, { id: 269, estado: 'hecho', agente: 'correo', origen: 'disparador', diff --git a/RealTime/package-lock.json b/RealTime/package-lock.json index 2d624bd..a83da4a 100644 --- a/RealTime/package-lock.json +++ b/RealTime/package-lock.json @@ -10,17 +10,15 @@ "dependencies": { "@google/genai": "^1.47.0", "@tauri-apps/api": "^2.10.1", - "@tauri-apps/plugin-opener": "^2", - "@tauri-apps/plugin-store": "^2.4.2", "react": "^19.2.4", - "react-dom": "^19.2.4", - "tauri-plugin-screenshots-api": "^2.2.0" + "react-dom": "^19.2.4" }, "devDependencies": { "@tauri-apps/cli": "^2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "knip": "^5.66.0", "typescript": "~5.8.3", "vite": "^7.0.4", "vitest": "^4.1.10" @@ -308,6 +306,40 @@ "node": ">=6.9.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.4", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", @@ -823,6 +855,337 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.4.tgz", + "integrity": "sha512-AJxoUD2/15ESHbvpcyjU274nsAPLuOtPHCk0vKJM5pj//Fg/B1FXNWjPnXTT9PymCYYiHo4zPj0ZomXBKhoy7g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@oxc-resolver/binding-android-arm-eabi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.24.2.tgz", + "integrity": "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-android-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.24.2.tgz", + "integrity": "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.24.2.tgz", + "integrity": "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-darwin-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.24.2.tgz", + "integrity": "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@oxc-resolver/binding-freebsd-x64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.24.2.tgz", + "integrity": "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.24.2.tgz", + "integrity": "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.24.2.tgz", + "integrity": "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.24.2.tgz", + "integrity": "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-arm64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.24.2.tgz", + "integrity": "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.24.2.tgz", + "integrity": "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.24.2.tgz", + "integrity": "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.24.2.tgz", + "integrity": "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.24.2.tgz", + "integrity": "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-gnu": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.24.2.tgz", + "integrity": "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-linux-x64-musl": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.24.2.tgz", + "integrity": "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@oxc-resolver/binding-openharmony-arm64": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.24.2.tgz", + "integrity": "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@oxc-resolver/binding-wasm32-wasi": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.24.2.tgz", + "integrity": "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.24.2.tgz", + "integrity": "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@oxc-resolver/binding-win32-x64-msvc": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.24.2.tgz", + "integrity": "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1478,22 +1841,15 @@ "node": ">= 10" } }, - "node_modules/@tauri-apps/plugin-opener": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", - "integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==", - "license": "MIT OR Apache-2.0", - "dependencies": { - "@tauri-apps/api": "^2.8.0" - } - }, - "node_modules/@tauri-apps/plugin-store": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz", - "integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==", - "license": "MIT OR Apache-2.0", + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@tauri-apps/api": "^2.8.0" + "tslib": "^2.4.0" } }, "node_modules/@types/babel__core": { @@ -1796,6 +2152,19 @@ "node": "*" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2008,6 +2377,43 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fd-package-json": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz", + "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "walk-up-path": "^4.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2049,6 +2455,35 @@ "node": "^12.20 || >= 14.13" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/formatly": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz", + "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "fd-package-json": "^2.0.0" + }, + "bin": { + "formatly": "bin/index.mjs" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -2114,6 +2549,19 @@ "node": ">=6.9.0" } }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/google-auth-library": { "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", @@ -2153,6 +2601,49 @@ "node": ">= 14" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2216,6 +2707,49 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/knip": { + "version": "5.88.1", + "resolved": "https://registry.npmjs.org/knip/-/knip-5.88.1.tgz", + "integrity": "sha512-tpy5o7zu1MjawVkLPuahymVJekYY3kYjvzcoInhIchgePxTlo+api90tBv2KfhAIe5uXh+mez1tAfmbv8/TiZg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/webpro" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/knip" + } + ], + "license": "ISC", + "dependencies": { + "@nodelib/fs.walk": "^1.2.3", + "fast-glob": "^3.3.3", + "formatly": "^0.3.0", + "jiti": "^2.6.0", + "minimist": "^1.2.8", + "oxc-resolver": "^11.19.1", + "picocolors": "^1.1.1", + "picomatch": "^4.0.1", + "smol-toml": "^1.5.2", + "strip-json-comments": "5.0.3", + "unbash": "^2.2.0", + "yaml": "^2.8.2", + "zod": "^4.1.11" + }, + "bin": { + "knip": "bin/knip.js", + "knip-bun": "bin/knip-bun.js" + }, + "engines": { + "node": ">=18.18.0" + }, + "peerDependencies": { + "@types/node": ">=18", + "typescript": ">=5.0.4 <7" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -2242,6 +2776,53 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2326,6 +2907,37 @@ "node": ">=12.20.0" } }, + "node_modules/oxc-resolver": { + "version": "11.24.2", + "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.24.2.tgz", + "integrity": "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-resolver/binding-android-arm-eabi": "11.24.2", + "@oxc-resolver/binding-android-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-arm64": "11.24.2", + "@oxc-resolver/binding-darwin-x64": "11.24.2", + "@oxc-resolver/binding-freebsd-x64": "11.24.2", + "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", + "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", + "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", + "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", + "@oxc-resolver/binding-linux-x64-musl": "11.24.2", + "@oxc-resolver/binding-openharmony-arm64": "11.24.2", + "@oxc-resolver/binding-wasm32-wasi": "11.24.2", + "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", + "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" + } + }, "node_modules/p-retry": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", @@ -2419,6 +3031,27 @@ "node": ">=12.0.0" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -2459,6 +3092,17 @@ "node": ">= 4" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -2504,6 +3148,30 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -2547,6 +3215,19 @@ "dev": true, "license": "ISC" }, + "node_modules/smol-toml": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", + "integrity": "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2571,13 +3252,17 @@ "dev": true, "license": "MIT" }, - "node_modules/tauri-plugin-screenshots-api": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tauri-plugin-screenshots-api/-/tauri-plugin-screenshots-api-2.2.0.tgz", - "integrity": "sha512-xNG5yH6kl+Mk/969tFimIxZa6y6eVgarbsEVAJIpERYBlYoNfWCDdI3nW111UOQjWi6zG/RkGjUMLlVy12a3Sg==", + "node_modules/strip-json-comments": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz", + "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==", + "dev": true, "license": "MIT", - "dependencies": { - "@tauri-apps/api": ">=2.0.0-beta.6" + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/tinybench": { @@ -2624,6 +3309,27 @@ "node": ">=14.0.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -2638,6 +3344,16 @@ "node": ">=14.17" } }, + "node_modules/unbash": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unbash/-/unbash-2.2.0.tgz", + "integrity": "sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -2840,6 +3556,16 @@ } } }, + "node_modules/walk-up-path": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz", + "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==", + "dev": true, + "license": "ISC", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -2893,6 +3619,32 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz", + "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/RealTime/package.json b/RealTime/package.json index 6969b39..5b4e497 100644 --- a/RealTime/package.json +++ b/RealTime/package.json @@ -8,22 +8,21 @@ "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", - "test": "vitest run" + "test": "vitest run", + "muertos": "knip" }, "dependencies": { "@google/genai": "^1.47.0", "@tauri-apps/api": "^2.10.1", - "@tauri-apps/plugin-opener": "^2", - "@tauri-apps/plugin-store": "^2.4.2", "react": "^19.2.4", - "react-dom": "^19.2.4", - "tauri-plugin-screenshots-api": "^2.2.0" + "react-dom": "^19.2.4" }, "devDependencies": { "@tauri-apps/cli": "^2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "knip": "^5.66.0", "typescript": "~5.8.3", "vite": "^7.0.4", "vitest": "^4.1.10" diff --git a/RealTime/pruebas/audio-pcm.test.ts b/RealTime/pruebas/audio-pcm.test.ts index c2620df..9502e04 100644 --- a/RealTime/pruebas/audio-pcm.test.ts +++ b/RealTime/pruebas/audio-pcm.test.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest'; -import { decodePCM, mergeChunks } from '../src/lib/audio-pcm'; +import { decodePCM, mergeChunks } from '../src/lib/audio/audio-pcm'; /** Arma el base64 de un PCM de 16 bits con signo, que es lo que manda Gemini. */ function pcmBase64(muestras: number[]): string { diff --git a/RealTime/pruebas/aviso-llamada.test.ts b/RealTime/pruebas/aviso-llamada.test.ts index 88e917b..115977a 100644 --- a/RealTime/pruebas/aviso-llamada.test.ts +++ b/RealTime/pruebas/aviso-llamada.test.ts @@ -15,7 +15,7 @@ import { entregaEnVivo, etiquetaEncargosResueltos, etiquetaOrigen, -} from '../src/lib/aviso-llamada'; +} from '../src/lib/llamada/aviso-llamada'; describe('etiquetaOrigen', () => { it('la llamada entrante dice QUIÉN llama y quita el motivo inventado', () => { diff --git a/RealTime/pruebas/confianza.test.ts b/RealTime/pruebas/confianza.test.ts new file mode 100644 index 0000000..3e8dcf6 --- /dev/null +++ b/RealTime/pruebas/confianza.test.ts @@ -0,0 +1,45 @@ +/** + * La única regla del modo confianza: cuándo toca volver a pedirla. + * + * Vivía dentro de `App.tsx` y no la probaba nadie, que es lo que pasa con la + * lógica que comparte fichero con una pantalla. Ver `lib/llamada/confianza.ts`. + */ +import { describe, expect, it } from 'vitest'; + +import { MINUTOS_CON_IDENTIDAD, MINUTOS_SIN_IDENTIDAD, tocaRenovar } from '../src/lib/llamada/confianza'; + +const MIN = 60_000; + +describe('tocaRenovar', () => { + it('la pide cuando no hay ninguna', () => { + expect(tocaRenovar(0, 10, 1_000_000)).toBe(true); + }); + + it('no la pide recién pedida', () => { + const ahora = 1_000_000; + const hasta = ahora + 10 * MIN; + expect(tocaRenovar(hasta, 10, ahora)).toBe(false); + }); + + it('no la pide mientras quede más de la mitad', () => { + const ahora = 1_000_000; + const hasta = ahora + 10 * MIN; + // Cuatro minutos después: quedan seis de diez. + expect(tocaRenovar(hasta, 10, ahora + 4 * MIN)).toBe(false); + }); + + it('la pide justo a la mitad', () => { + const ahora = 1_000_000; + const hasta = ahora + 10 * MIN; + expect(tocaRenovar(hasta, 10, ahora + 5 * MIN)).toBe(true); + }); + + it('la pide si ya caducó', () => { + const ahora = 1_000_000; + expect(tocaRenovar(ahora - MIN, 10, ahora)).toBe(true); + }); + + it('la ventana corta es la de cuando se sabe quién habla', () => { + expect(MINUTOS_CON_IDENTIDAD).toBeLessThan(MINUTOS_SIN_IDENTIDAD); + }); +}); diff --git a/RealTime/pruebas/coordenadas.test.ts b/RealTime/pruebas/coordenadas.test.ts index 09840ff..d4cd41c 100644 --- a/RealTime/pruebas/coordenadas.test.ts +++ b/RealTime/pruebas/coordenadas.test.ts @@ -12,7 +12,7 @@ import { GeometriaPantalla, aPixeles, traducirParametroDeRaton, -} from '../src/lib/coordenadas'; +} from '../src/lib/llamada/coordenadas'; const PANTALLA: GeometriaPantalla = { ancho_imagen: 1280, @@ -69,7 +69,7 @@ describe('traducirParametroDeRaton', () => { }); it('no toca un texto que no es un punto', () => { - expect(traducirParametroDeRaton('spotify', PANTALLA)).toBe('spotify'); + expect(traducirParametroDeRaton('notepad', PANTALLA)).toBe('notepad'); }); }); diff --git a/RealTime/pruebas/habitos.test.ts b/RealTime/pruebas/habitos.test.ts index 952d374..76aebae 100644 --- a/RealTime/pruebas/habitos.test.ts +++ b/RealTime/pruebas/habitos.test.ts @@ -22,7 +22,7 @@ import { ALMACEN, MES_VACIO, clave, diasDelMes, foto, guardar, leer, porcentaje, racha, resumen, semanaDe, type Datos, -} from '../src/lib/habitos'; +} from '../src/lib/datos/habitos'; /** Un almacén a medida. Los días marcados se dan por hábito y por mes. */ function almacen( diff --git a/RealTime/pruebas/identidad-llamada.test.ts b/RealTime/pruebas/identidad-llamada.test.ts new file mode 100644 index 0000000..b3247f7 --- /dev/null +++ b/RealTime/pruebas/identidad-llamada.test.ts @@ -0,0 +1,66 @@ +/** + * Las dos reglas que decidían solas dentro de `App.tsx`: cuándo un aviso de + * identidad es nuevo, y cuándo ha cambiado quién está delante de la cámara. + * + * Vivían mezcladas con la pantalla y no las probaba nadie. Ver + * `lib/llamada/identidad-llamada.ts`. + */ +import { describe, expect, it } from 'vitest'; + +import { + NO_REPETIR_MS, + avisoNuevo, + huellaDeCaras, +} from '../src/lib/llamada/identidad-llamada'; + +const caja: [number, number, number, number] = [0, 0, 10, 10]; +const cara = (nombre: string | null) => ({ nombre, caja, confianza: 0.9 }); + +describe('avisoNuevo', () => { + const ahora = 1_000_000; + + it('el primero siempre pasa', () => { + expect(avisoNuevo({ texto: '', cuando: 0 }, '[IDENTIDAD] Habla Persus.', ahora)).toBe(true); + }); + + it('el mismo texto dentro del minuto no se repite', () => { + const ultimo = { texto: 'igual', cuando: ahora - 30_000 }; + expect(avisoNuevo(ultimo, 'igual', ahora)).toBe(false); + }); + + it('pasado el minuto vuelve a pasar', () => { + const ultimo = { texto: 'igual', cuando: ahora - NO_REPETIR_MS - 1 }; + expect(avisoNuevo(ultimo, 'igual', ahora)).toBe(true); + }); + + it('un texto distinto pasa siempre, aunque sea seguido', () => { + // Voz y cara son dos avisos distintos, y los dos importan la primera vez. + const ultimo = { texto: 'habla Persus', cuando: ahora }; + expect(avisoNuevo(ultimo, 'delante de la camara: Persus', ahora)).toBe(true); + }); +}); + +describe('huellaDeCaras', () => { + it('sin caras, sin huella', () => { + expect(huellaDeCaras([])).toBe(''); + }); + + it('las caras sin nombre no cuentan', () => { + // Sin el filtro saldria un literal «null» en el aviso al modelo. + expect(huellaDeCaras([cara(null), cara(null)])).toBe(''); + expect(huellaDeCaras([cara('Persus'), cara(null)])).toBe('Persus'); + }); + + it('el orden de deteccion no cambia la huella', () => { + const a = huellaDeCaras([cara('Persus'), cara('Javi')]); + const b = huellaDeCaras([cara('Javi'), cara('Persus')]); + expect(a).toBe(b); + expect(a).toBe('Javi, Persus'); + }); + + it('quien entra cambia la huella', () => { + expect(huellaDeCaras([cara('Persus')])).not.toBe( + huellaDeCaras([cara('Persus'), cara('Javi')]), + ); + }); +}); diff --git a/RealTime/pruebas/latencia.test.ts b/RealTime/pruebas/latencia.test.ts new file mode 100644 index 0000000..7d94ca5 --- /dev/null +++ b/RealTime/pruebas/latencia.test.ts @@ -0,0 +1,70 @@ +/** + * La cifra que dice si Perseo tarda: desde que dejas de hablar hasta que se le + * oye. Es lo que convierte el ajuste del silencio del micrófono en una decisión + * con un número delante en vez de una impresión. + * + * Se prueba la contabilidad, no el reloj: que una respuesta se mida una sola + * vez, que el audio que llega sin que nadie haya hablado no cuente, y que la + * mediana no se la lleve un pico suelto. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + hablaElUsuario, + latenciaDeRespuesta, + olvidarLatencias, + respondePerseo, +} from '../src/lib/llamada/diagnostico'; + +/** Una respuesta entera: habla, pasan `ms`, contesta. */ +function unaRespuesta(ms: number, reloj: { ahora: number }): void { + hablaElUsuario(); + reloj.ahora += ms; + respondePerseo(); +} + +describe('latencia de respuesta', () => { + const reloj = { ahora: 0 }; + + beforeEach(() => { + reloj.ahora = 1000; + vi.spyOn(performance, 'now').mockImplementation(() => reloj.ahora); + olvidarLatencias(); + }); + + it('sin medidas no inventa ninguna', () => { + expect(latenciaDeRespuesta()).toEqual({ veces: 0, ultima: 0, mediana: 0, peor: 0 }); + }); + + it('mide lo que se tarda en contestar', () => { + unaRespuesta(820, reloj); + expect(latenciaDeRespuesta()).toMatchObject({ veces: 1, ultima: 820, peor: 820 }); + }); + + it('el audio que sigue llegando no cuenta como otra respuesta', () => { + // Una respuesta son muchos trozos de audio seguidos; solo el primero + // cierra la medición. Sin esto, cada trozo apuntaría una latencia mayor y + // la mediana diría que Perseo tarda segundos. + unaRespuesta(500, reloj); + reloj.ahora += 3000; + respondePerseo(); + respondePerseo(); + expect(latenciaDeRespuesta().veces).toBe(1); + }); + + it('un pico suelto no se lleva la mediana', () => { + unaRespuesta(600, reloj); + unaRespuesta(650, reloj); + unaRespuesta(9000, reloj); + const medido = latenciaDeRespuesta(); + expect(medido.peor).toBe(9000); + expect(medido.mediana).toBe(650); + }); + + it('olvidar deja el cuaderno limpio para la llamada siguiente', () => { + unaRespuesta(700, reloj); + olvidarLatencias(); + expect(latenciaDeRespuesta().veces).toBe(0); + }); +}); diff --git a/RealTime/pruebas/microfono.test.ts b/RealTime/pruebas/microfono.test.ts new file mode 100644 index 0000000..4a68cd3 --- /dev/null +++ b/RealTime/pruebas/microfono.test.ts @@ -0,0 +1,20 @@ +/** + * La regla del micrófono: qué modos dejan el paso abierto sin apretar nada. + * + * Parece una tontería de una línea, y es la que decidía si la pantalla ponía + * «Escuchando» mientras el micrófono estaba cerrado — pasó el 2026-09-09. Ver + * `lib/llamada/microfono.ts`. + */ +import { describe, expect, it } from 'vitest'; + +import { pasoSiempreAbierto } from '../src/lib/llamada/microfono'; + +describe('pasoSiempreAbierto', () => { + it('en manos libres el paso está abierto', () => { + expect(pasoSiempreAbierto('manos-libres')).toBe(true); + }); + + it('en pulsar para hablar, no', () => { + expect(pasoSiempreAbierto('pulsar')).toBe(false); + }); +}); diff --git a/RealTime/pruebas/quien-hay.test.ts b/RealTime/pruebas/quien-hay.test.ts index 047440a..2555a76 100644 --- a/RealTime/pruebas/quien-hay.test.ts +++ b/RealTime/pruebas/quien-hay.test.ts @@ -19,7 +19,7 @@ import { etiquetaPersona, PERFIL_PERSUS_POR_DEFECTO, sinAvisoDeIdentidad, -} from '../src/lib/quien-hay'; +} from '../src/lib/identidad/quien-hay'; const SENOR = PERFIL_PERSUS_POR_DEFECTO; diff --git a/RealTime/pruebas/reconexion.test.ts b/RealTime/pruebas/reconexion.test.ts index 318c490..628fb6c 100644 --- a/RealTime/pruebas/reconexion.test.ts +++ b/RealTime/pruebas/reconexion.test.ts @@ -15,7 +15,7 @@ import { avisoDeEspera, esLimite, planificarReintento, -} from '../src/lib/reconexion'; +} from '../src/lib/llamada/reconexion'; describe('esLimite', () => { it('reconoce el 1011 que manda Gemini al pasarse de cuota', () => { diff --git a/RealTime/pruebas/tareas.test.ts b/RealTime/pruebas/tareas.test.ts index 38c0b90..6a0ed87 100644 --- a/RealTime/pruebas/tareas.test.ts +++ b/RealTime/pruebas/tareas.test.ts @@ -19,7 +19,7 @@ import { diasDesde, editar, foto, guardar, haceCuanto, leer, mover, moverDeFuera, porTitulo, restaurar, resumen, tirar, vaciarPapelera, type Columna, type Datos, -} from '../src/lib/tareas'; +} from '../src/lib/datos/tareas'; /** Un tablero a medida: títulos por columna, en orden. */ function tablero(porColumna: Record): Datos { diff --git a/RealTime/src-tauri/src/commands.rs b/RealTime/src-tauri/src/commands.rs index e79a780..43ea2b5 100644 --- a/RealTime/src-tauri/src/commands.rs +++ b/RealTime/src-tauri/src/commands.rs @@ -26,7 +26,7 @@ const ALTO_CAPTURA: u32 = 720; /// Antes se cogia `monitors.first()`, que en un equipo con dos pantallas puede /// ser cualquiera de las dos: el modelo veia una pantalla y el clic caia en la /// otra. `pyautogui` mide en la principal (ver `_dentro_de_la_pantalla` en -/// `perseo_core/pc.py`), asi que se comparte esa y no otra. +/// `perseo_core/agentes/pc.py`), asi que se comparte esa y no otra. fn pantalla_principal() -> Result { let monitores = Monitor::all().map_err(|e| e.to_string())?; monitores diff --git a/RealTime/src-tauri/src/lib.rs b/RealTime/src-tauri/src/lib.rs index 852bb1d..d06b98e 100644 --- a/RealTime/src-tauri/src/lib.rs +++ b/RealTime/src-tauri/src/lib.rs @@ -80,6 +80,7 @@ pub fn run() { commands::anotar_diagnostico, nucleo::ejecutar_herramienta, nucleo::precalentar_herramientas, + nucleo::catalogo_herramientas, nucleo::biometria_estado, nucleo::biometria_voz, nucleo::biometria_cara, diff --git a/RealTime/src-tauri/src/nucleo.rs b/RealTime/src-tauri/src/nucleo.rs index f4afbd4..0e8800e 100644 --- a/RealTime/src-tauri/src/nucleo.rs +++ b/RealTime/src-tauri/src/nucleo.rs @@ -400,11 +400,17 @@ pub(crate) async fn pedir_json( /// /// El origen es `voz` porque esta cara es la de la llamada: asi se distingue en /// la cola de la web lo que pediste hablando de lo que escribiste. +/// +/// `quien` es el perfil de la persona que acaba de hablar, si el reconocimiento +/// de voz lo sabe. Viaja hasta la politica del nucleo, que con el separa una +/// orden del dueno de una de una visita: el origen dice por que puerta entro el +/// trabajo, no de quien es la voz. #[tauri::command] pub async fn ejecutar_herramienta( app: AppHandle, tool_name: String, argumentos: String, + quien: Option, ) -> Result { let args: Value = serde_json::from_str(&argumentos).map_err(|e| format!("Argumentos JSON invalidos: {e}"))?; @@ -434,7 +440,12 @@ pub async fn ejecutar_herramienta( cliente .post(format!("{base}/trabajos")) .bearer_auth(&token) - .json(&json!({ "agente": agente, "peticion": peticion, "origen": "voz" })), + .json(&json!({ + "agente": agente, + "peticion": peticion, + "origen": "voz", + "quien": quien, + })), ) .await?; @@ -715,7 +726,7 @@ pub async fn precalentar_herramientas(app: AppHandle) -> Result<(), String> { // Biometría // // El reconocimiento de quién habla y quién sale por la cámara vive en el núcleo -// (perseo_core/biometria.py); la llamada solo transporta los mismos trozos que +// (perseo_core/servicios/biometria.py); la llamada solo transporta los mismos trozos que // ya le manda a Gemini y pinta la etiqueta que vuelve. Estos comandos son rutas // CONCRETAS y no un proxy genérico a /{ruta}, por el mismo motivo que panel.rs: // si el frontend elige la ruta entera, la ruta la escribe el frontend. @@ -725,7 +736,12 @@ pub async fn precalentar_herramientas(app: AppHandle) -> Result<(), String> { // móvil con un trabajo cada dos segundos la haría ilegible. // --------------------------------------------------------------------------- // -async fn traer_biometria(app: &AppHandle, ruta: &str) -> Result { +/// Un GET con token a una ruta concreta del nucleo. +/// +/// Se llamaba traer_biometria y desde el 2026-09-12 tambien la usa el +/// catalogo de herramientas, que no es biometria de nada. El nombre describe +/// lo que hace -traer JSON del nucleo- y no a su primer cliente. +async fn traer_del_nucleo(app: &AppHandle, ruta: &str) -> Result { let token = token(app)?; let cliente = reqwest::Client::new(); pedir_json( @@ -752,10 +768,22 @@ async fn mandar_biometria( pedir_json(peticion.bearer_auth(&token)).await } +/// El catalogo de herramientas que el nucleo declara para la llamada. +/// +/// Se declara UNA vez, en `perseo_core/servicios/catalogo.py`, y esta cara lo +/// pide al conectar. Si el nucleo no contesta, la cara abre la llamada con la +/// copia incrustada que lleva dentro: quedarse sin voz porque el catalogo tardo +/// seria mucho peor que hablar con la copia de ayer. Lo que impide que las dos +/// se separen es una prueba que las compara, no esta peticion. +#[tauri::command] +pub async fn catalogo_herramientas(app: AppHandle) -> Result { + traer_del_nucleo(&app, "/herramientas?cara=voz").await +} + /// Perfiles guardados, progreso de aprendizaje y qué motores hay hoy. #[tauri::command] pub async fn biometria_estado(app: AppHandle) -> Result { - traer_biometria(&app, "/biometria").await + traer_del_nucleo(&app, "/biometria").await } /// Un trozo de PCM 16k mono (base64): ¿de quién es la voz? diff --git a/RealTime/src-tauri/src/panel.rs b/RealTime/src-tauri/src/panel.rs index 504c6ac..628c3d6 100644 --- a/RealTime/src-tauri/src/panel.rs +++ b/RealTime/src-tauri/src/panel.rs @@ -338,7 +338,7 @@ pub async fn panel_mensaje(app: AppHandle, texto: String) -> Result Resul /// Python y no ven dentro de un navegador. /// /// Lo que viaja es el texto ya redactado, no las notas: quien cuenta es -/// `src/lib/tareas.ts` y nadie mas (ver `perseo_core/tareas.py`). +/// `src/lib/datos/tareas.ts` y nadie mas (ver `perseo_core/servicios/tareas.py`). /// /// Un fallo aqui tampoco es un fallo de la pantalla: con el nucleo apagado el /// señor Persus sigue moviendo sus notas y la copia sale con el cambio diff --git a/RealTime/src/App.tsx b/RealTime/src/App.tsx index cbf9e9a..b036630 100644 --- a/RealTime/src/App.tsx +++ b/RealTime/src/App.tsx @@ -17,7 +17,11 @@ */ import { useEffect, useState, useRef } from 'react'; -import { invoke } from '@tauri-apps/api/core'; +import * as nucleo from './lib/datos/panel'; +import { MINUTOS_CON_IDENTIDAD, MINUTOS_SIN_IDENTIDAD, useConfianza } from './lib/llamada/confianza'; +import { useIdentidad } from './lib/llamada/identidad-llamada'; +import { useMicrofono } from './lib/llamada/microfono'; +import * as puente from './lib/datos/llamada'; import { listen } from '@tauri-apps/api/event'; import { PerseoFace } from './components/PerseoFace'; import { Settings } from './components/Settings'; @@ -27,32 +31,36 @@ import { Tareas } from './components/Tareas'; import { EVENTO_CAMBIO as TAREAS_CAMBIO, aplicarOrden as aplicarOrdenTarea, - foto as fotoTareas, + espejar as espejarTareas, leer as leerTareas, - resumen as resumenTareas, -} from './lib/tareas'; + recoger as recogerTareas, +} from './lib/datos/tareas'; import { Proyectos } from './components/Proyectos'; import { Escenografia, comoReloj, type Fase } from './components/Escenografia'; import { Marco } from './components/Marco'; import { IconCamera, IconCameraOff, IconMic, IconMicOff, IconPhone, IconPhoneOff, } from './components/Iconos'; -import { geminiClient } from './lib/gemini-live'; -import { sonar, callar } from './lib/timbre'; -import { audioManager } from './lib/audio-manager'; -import { audioPlayer } from './lib/audio-player'; -import { iniciarDiagnostico, pararDiagnostico } from './lib/diagnostico'; -import { cameraManager } from './lib/camera-manager'; -import { screenManager } from './lib/screen-manager'; -import { vigilante, type CaraDetectada } from './lib/identidad'; -import { avisoCaras, avisoHablante, esElSenor, sinAvisoDeIdentidad } from './lib/quien-hay'; +import { geminiClient } from './lib/llamada/gemini-live'; +import { sonar, callar } from './lib/audio/timbre'; +import { audioManager } from './lib/audio/audio-manager'; +import { audioPlayer } from './lib/audio/audio-player'; +import { + iniciarDiagnostico, + latenciaDeRespuesta, + olvidarLatencias, + pararDiagnostico, +} from './lib/llamada/diagnostico'; +import { cameraManager } from './lib/llamada/camera-manager'; +import { screenManager } from './lib/llamada/screen-manager'; +import { vigilante } from './lib/identidad/identidad'; +import { esElSenor, sinAvisoDeIdentidad } from './lib/identidad/quien-hay'; import { defaultConfig, cargarAjustesPersistidos, type AspectoLive, type EstiloHabitos, - type ModoMicro, -} from './lib/config'; +} from './lib/datos/config'; // ── Types ── // `abierto` marca un mensaje que aún está recibiendo fragmentos de transcripción. @@ -78,7 +86,7 @@ const MAX_MENSAJES = 400; // tope de memoria de una sesión * el núcleo y esto la recoge. Ocho segundos porque la conversación escrita * suele pasar con la app delante y esperar medio minuto a ver aparecer la nota * que acabas de pedir se siente roto; y es una petición a localhost, no a - * internet. Ver `perseo_core/tareas.py`. */ + * internet. Ver `perseo_core/servicios/tareas.py`. */ const ESPERA_ORDENES_TAREAS = 8000; /** Cuánto se espera, sin que nadie toque nada, antes de mandarle al núcleo la @@ -112,20 +120,6 @@ function App() { const [isSpeaking, setIsSpeaking] = useState(false); const [cameraStream, setCameraStream] = useState(null); const [isMuted, setIsMuted] = useState(false); - // Manos libres o pulsar para hablar (ModoMicro en lib/config.ts). Vive aquí - // y no dentro de Ajustes porque manda sobre la barra de controles: en - // «pulsar» el botón de silenciar deja el sitio al de hablar. - const [modoMicro, setModoMicro] = useState(defaultConfig.modoMicro); - // El modo con el que se ABRIÓ la sesión en curso. El ajuste puede cambiar a - // mitad de llamada y esa llamada sigue siendo la que era: el servidor solo - // acepta señales de turno si se le pidió con la detección apagada, así que - // sin esto la barra espaciadora hablaría con una sesión que no escucha. - const modoMicroRef = useRef(defaultConfig.modoMicro); - // Si el botón de hablar está pulsado AHORA MISMO. Se duplica en una - // referencia porque los atajos de teclado se registran una sola vez y - // leerían el valor del cierre — el de cuando se montaron. - const [pulsando, setPulsando] = useState(false); - const pulsandoRef = useRef(false); const [showSettings, setShowSettings] = useState(false); // El volumen NO es estado de React. Llega a 60 por segundo desde el // reproductor (`audio-player.ts` lo mide con un rAF), y con `useState` eso @@ -165,8 +159,6 @@ function App() { // Reconocimiento de personas (biometría local): quién habla ahora y qué // caras hay en el último fotograma analizado. Ambas cosas las decide el // núcleo; aquí solo se pintan. Ver lib/identidad.ts. - const [hablante, setHablante] = useState(null); - const [caras, setCaras] = useState([]); const transcriptRef = useRef(null); const cameraVideoRef = useRef(null); @@ -177,31 +169,36 @@ function App() { // Y solo con sesión viva: si llega un resultado cuando el socket ya cayó, se // descarta — encolarlo era que saliera horas después en otra llamada. const conectadoRef = useRef(false); - const ultimaIdentidad = useRef<{ texto: string; cuando: number }>({ texto: '', cuando: 0 }); - // Los últimos avisos mandados, para reconocerlos si el modelo los lee en voz - // alta y quitarlos de la transcripción. Tres bastan: un aviso viejo ya no - // puede estar saliendo por la boca de Perseo. - const avisosRecientes = useRef([]); - const carasVistas = useRef(''); - const avisarIdentidad = (texto: string) => { - if (!conectadoRef.current) return; - const ahora = Date.now(); - if ( - ultimaIdentidad.current.texto === texto && - ahora - ultimaIdentidad.current.cuando < 60_000 - ) { - return; - } - ultimaIdentidad.current = { texto, cuando: ahora }; - avisosRecientes.current = [texto, ...avisosRecientes.current].slice(0, 3); - geminiClient.informarIdentidad(texto); - }; - // Espejo del estado para poder leerlo desde callbacks sin recrearlos. // Antes había dos almacenes en paralelo (un ref y un estado) y se // desincronizaban: el ref se vaciaba en 'disconnected', que es justo el evento // que emite handleReconnect antes de reconectar, así que el historial que se // inyectaba al reconectar siempre estaba vacío. + // El modo confianza de esta llamada. Lo de dentro —el reloj, la ventana, la + // regla de no martillear al núcleo— vive en `lib/llamada/confianza.ts`, que + // se prueba sin React. + const confianza = useConfianza(); + // Quién habla y quién se ve. Las tres reglas que había sueltas por aquí + // —no repetir un aviso, no mandarlo sin socket, no avisar de las mismas + // caras cada cuatro segundos— viven en `lib/llamada/identidad-llamada.ts`, + // donde dos de ellas se prueban sin React. + const identidad = useIdentidad({ + perfilDueno: () => defaultConfig.perfilPersus, + informar: texto => geminiClient.informarIdentidad(texto), + anotar: texto => addTranscript('system', texto), + alOirAlDueno: () => confianza.renovar(MINUTOS_CON_IDENTIDAD), + haySocket: () => conectadoRef.current, + }); + // El micrófono: en qué modo está y si el paso está abierto. Sus cuatro + // reglas —rearmar al reconectar, no abrir sin socket, no cerrar lo que no + // estaba abierto, y quitarle el foco a los botones— viven en + // `lib/llamada/microfono.ts`. + const micro = useMicrofono({ + transmitir: abierto => audioManager.transmitir(abierto), + haySocket: () => conectadoRef.current, + abrirTurno: () => geminiClient.abrirTurno(), + cerrarTurno: () => geminiClient.cerrarTurno(), + }); const conversacionRef = useRef([]); // Dónde empieza el episodio en curso: la transcripción anterior a esa marca // no viaja al prompt ni en reconexión. Lo pide el arreglo del 2026-08-24 — @@ -217,11 +214,8 @@ function App() { if (utiles.length === 0) return; try { - await invoke('ejecutar_herramienta', { - toolName: 'guardar_conversacion', - argumentos: JSON.stringify({ - mensajes: utiles.map(m => ({ tipo: m.type, texto: m.text })), - }), + await puente.ejecutarHerramienta('guardar_conversacion', { + mensajes: utiles.map(m => ({ tipo: m.type, texto: m.text })), }); console.log(`[Historial] Conversación guardada (${utiles.length} mensajes).`); } catch (e) { @@ -235,7 +229,7 @@ function App() { if (state === 'connected') { if (sesionDesde.current === null) sesionDesde.current = Date.now(); audioManager.start(); // Reactivar el micrófono al reconectar - armarMicro(); + micro.armar(); if (defaultConfig.cameraEnabled) cameraManager.start(); // Reconocimiento de personas: solo si está encendido en Ajustes. Se // rearma en cada reconexión porque 'disconnected' lo apaga siempre. @@ -243,36 +237,27 @@ function App() { // La vista de la pantalla ya no se pide: si el ajuste no dice lo // contrario, Perseo la ve desde el primer segundo de la llamada. Es lo // que hace de esto un agente — mirar sin que le den las cosas. - if (defaultConfig.pantallaAuto) { - screenManager.start(); - } else if (defaultConfig.screenEnabled) { - screenManager.start(); - } + if (defaultConfig.pantallaAuto) screenManager.start(); // Confianza automática en llamada (N-3): si hay enlace de voz hay una // persona delante, y lo irreversible deja de pedir un sí que ya está - // oyendo. El techo de 60 min es red de seguridad por si la app muere - // sin pasar por el colgado; cada reconexión lo rearma. - invoke('panel_confianza', { minutos: 60 }).catch(e => - console.warn('[Confianza] No se pudo activar:', e) + // oyendo. La ventana es corta cuando el reconocimiento puede decir + // quién habla —se renueva sola con su voz— y larga cuando no. + confianza.olvidar(); + confianza.renovar( + defaultConfig.identidadActivada ? MINUTOS_CON_IDENTIDAD : MINUTOS_SIN_IDENTIDAD ); addTranscript('system', 'Conectado.'); conectadoRef.current = true; } else if (state === 'disconnected' || state === 'error') { // Un turno abierto que se queda a medias no se cierra solo: sin esto, // el botón de hablar seguiría encendido sobre una llamada muerta. - pulsandoRef.current = false; - setPulsando(false); + micro.soltarTurno(); audioManager.stop(); cameraManager.stop(); screenManager.stop(); vigilante.desactivar(); conectadoRef.current = false; - setHablante(null); - setCaras([]); - // Nueva llamada, presentación nueva: sin esto, si la misma cara sigue - // delante al reconectar nadie se lo diría otra vez al modelo. - carasVistas.current = ''; - ultimaIdentidad.current = { texto: '', cuando: 0 }; + identidad.olvidar(); setCameraStream(null); setIsSpeaking(false); // La conversación NO se borra aquí: 'disconnected' también se emite en @@ -296,14 +281,7 @@ function App() { ? `«${etiqueta}» era usted: perfil guardado como ${nombre}.` : `«${etiqueta}» ya tiene nombre: ${nombre}.`, ); - setHablante(previo => (previo === etiqueta ? nombre : previo)); - setCaras(previas => - previas.map(c => (c.nombre === etiqueta ? { ...c, nombre } : c)), - ); - // El aviso de caras solo sale cuando cambia quién está delante: sin - // limpiar la huella, el nombre nuevo no llegaría al modelo hasta que - // alguien entrara o saliera del encuadre. - carasVistas.current = ''; + identidad.renombrar(etiqueta, nombre); }; geminiClient.onAprobacionPendiente = (id, pregunta) => { @@ -316,6 +294,10 @@ function App() { }; // La herramienta `ver_pantalla`: enciende o apaga la captura aquí, que es // donde vive. La frase que devuelve es la que Perseo cuenta por voz. + // Cada herramienta viaja con quién acaba de hablar. La etiqueta caduca sola + // a los 4,5 s de silencio (lib/identidad.ts), así que esto no es «quién + // estuvo en la llamada» sino «de quién es la voz que acaba de pedir esto». + geminiClient.quienHabla = () => identidad.hablanteRef.current; geminiClient.onVerPantalla = (activar) => { if (activar) { screenManager.start(); return 'Empiezo a ver su pantalla.'; } screenManager.stop(); @@ -329,37 +311,8 @@ function App() { // dijo qué cuando lo lea alguien dentro de un rato. audioManager.onTrozoPCM = (trozo) => vigilante.consumirAudio(trozo); cameraManager.onFotograma = (base64) => vigilante.consumirFotograma(base64); - vigilante.onHablante = (nombre) => { - setHablante(nombre); - if (nombre) { - addTranscript('system', `Habla ${nombre}.`); - // El aviso dice quién habla Y qué trato le toca. Un nombre a secas - // dejaba al modelo llamando «señor Persus» a cualquiera que pasara por - // delante de la cámara. Ver lib/quien-hay.ts. - avisarIdentidad(avisoHablante(nombre, defaultConfig.perfilPersus)); - } - }; - vigilante.onCaras = (lista) => { - setCaras(lista); - // Solo cuando cambia QUIÉN está delante, no cada fotograma: el modelo - // no necesita el mismo aviso cada cuatro segundos. Las caras sin nombre - // se descartan del aviso: sin el filtro saldría un literal «null». - const nombres = lista - .map(c => c.nombre) - .filter((n): n is string => !!n) - .sort(); - const clave = nombres.join(', '); - if (clave) { - if (clave !== carasVistas.current) { - carasVistas.current = clave; - const aviso = avisoCaras(nombres, defaultConfig.perfilPersus); - if (aviso) avisarIdentidad(aviso); - } - } else { - carasVistas.current = ''; - } - }; - + identidad.enganchar(); + audioPlayer.onVolumeChange = (v) => { volumenCrudo.current = v; ultimaMedida.current = Date.now(); @@ -409,7 +362,7 @@ function App() { try { await cargarAjustesPersistidos(); setAspecto(defaultConfig.aspectoLive); - const clave = await invoke('obtener_api_key'); + const clave = await puente.apiKey(); defaultConfig.geminiApiKey = clave; if (!clave) addTranscript('system', 'No hay API Key configurada. Pulsa ⚙ para añadirla.'); } catch (e) { @@ -448,7 +401,7 @@ function App() { if (!apiKeyReady || connectionState !== 'disconnected') return; let cancelado = false; - invoke('consumir_autollamada').then(motivo => { + puente.consumirAutollamada().then(motivo => { if (cancelado || !motivo) return; atenderMarcador(motivo, handleCall); }).catch(e => console.warn('[AutoLlamada] No se pudo comprobar la señal:', e)); @@ -526,12 +479,7 @@ function App() { // app, y las órdenes esperan en su cola hasta la próxima vuelta. useEffect(() => { const recoger = async () => { - try { - const recogido = await invoke<{ ordenes?: unknown[] }>('tareas_recoger'); - for (const orden of recogido?.ordenes ?? []) aplicarOrdenTarea(orden); - } catch { - // Núcleo apagado o sin token todavía: se reintenta a la vuelta siguiente. - } + for (const orden of await recogerTareas()) aplicarOrdenTarea(orden); }; recoger(); const t = setInterval(recoger, ESPERA_ORDENES_TAREAS); @@ -554,9 +502,7 @@ function App() { const espejar = () => { clearTimeout(espera); espera = setTimeout(() => { - const datos = leerTareas(); - invoke('tareas_espejo', { texto: resumenTareas(datos), foto: fotoTareas(datos) }) - .catch(e => console.debug('[Tareas] El núcleo no recogió la copia:', e)); + void espejarTareas(leerTareas()); }, ESPERA_ESPEJO_TAREAS); }; espejar(); @@ -598,14 +544,14 @@ function App() { // trozo: la marca llega partida entre fragmentos. Ver lib/quien-hay.ts. const ultimo = prev[prev.length - 1]; if (ultimo && ultimo.type === rol && ultimo.abierto) { - const texto = sinAvisoDeIdentidad(ultimo.text + delta, avisosRecientes.current); + const texto = sinAvisoDeIdentidad(ultimo.text + delta, identidad.avisos.current); return [...prev.slice(0, -1), { ...ultimo, text: texto }]; } return [ ...prev, { id: `${Date.now()}-${Math.random()}`, - text: sinAvisoDeIdentidad(delta, avisosRecientes.current), + text: sinAvisoDeIdentidad(delta, identidad.avisos.current), type: rol, abierto: true, hora: ahoraCorta(), @@ -691,56 +637,22 @@ function App() { inicioEpisodio.current = conversacionRef.current.length; // El cuaderno de la llamada, para poder mirar después por qué se oyó como // se oyó. Ver lib/diagnostico.ts. - iniciarDiagnostico(`micrófono ${defaultConfig.modoMicro}, pantalla ${defaultConfig.pantallaAuto ? 'automática' : 'apagada'}`); + olvidarLatencias(); + iniciarDiagnostico(`micrófono ${defaultConfig.modoMicro}, silencio ${defaultConfig.silencioMs} ms, pantalla ${defaultConfig.pantallaAuto ? 'automática' : 'apagada'}`); audioPlayer.initialize(); audioManager.start(); - armarMicro(); + micro.armar(); geminiClient.connect(); }; - /** - * Deja el micrófono como pide el modo: de par en par en manos libres, mudo - * hasta que se pulse en «pulsar para hablar». Se llama al llamar y en cada - * reconexión, porque `audioManager.stop()` devuelve el paso abierto. - */ - const armarMicro = () => { - const modo = defaultConfig.modoMicro; - modoMicroRef.current = modo; - setModoMicro(modo); - audioManager.transmitir(modo !== 'pulsar'); - pulsandoRef.current = false; - setPulsando(false); - }; - - /** Se aprieta el botón de hablar: se avisa al modelo y se abre el paso. */ - const empezarAHablar = () => { - if (modoMicroRef.current !== 'pulsar') return; - if (!conectadoRef.current || pulsandoRef.current) return; - pulsandoRef.current = true; - setPulsando(true); - // Y que ningún botón se quede con el foco mientras se habla: pulsar - // «Llamar» se lo deja al de colgar, y la barra espaciadora lo activaría - // por su cuenta. Cinturón además del `preventDefault` de las dos teclas. - const enfocado = document.activeElement as HTMLElement | null; - if (enfocado && enfocado.tagName === 'BUTTON') enfocado.blur(); - // El aviso primero y el audio después: con la detección automática - // apagada, un trozo que llegue antes del `activityStart` se tira. - geminiClient.abrirTurno(); - audioManager.transmitir(true); - }; - - /** Se suelta: se cierra el paso y se le dice al modelo que conteste. */ - const dejarDeHablar = () => { - if (!pulsandoRef.current) return; - pulsandoRef.current = false; - setPulsando(false); - audioManager.transmitir(false); - geminiClient.cerrarTurno(); - }; - const handleHangup = async () => { - pararDiagnostico(`${audioPlayer.diagnostico().vecesSeca} veces seca la cola`); - dejarDeHablar(); + const tardanza = latenciaDeRespuesta(); + pararDiagnostico( + `${audioPlayer.diagnostico().vecesSeca} veces seca la cola; ` + + `respuesta mediana ${tardanza.mediana} ms, peor ${tardanza.peor} ms ` + + `(${tardanza.veces} medidas)` + ); + micro.dejar(); geminiClient.disconnect(); audioManager.stop(); audioPlayer.clearQueue(); @@ -752,11 +664,7 @@ function App() { setPendientes([]); // Y se apaga la confianza que encendió la llamada (N-3): sin persona // delante, lo irreversible vuelve a preguntar. - try { - await invoke('panel_confianza', {}); - } catch (e) { - console.warn('[Confianza] No se pudo apagar:', e); - } + await confianza.apagar(); // Colgar sí cierra la conversación de verdad: aquí es donde se guarda y se // limpia, no en cada 'disconnected'. Ver y. @@ -780,7 +688,7 @@ function App() { // Sin esto la barra además pulsa el botón que tenga el foco, y el // teclado colgaba la llamada mientras se hablaba. e.preventDefault(); - empezarAHablar(); + micro.empezar(); }; const arriba = (e: KeyboardEvent) => { if (e.code !== 'Space') return; @@ -789,11 +697,11 @@ function App() { // solo el keydown dejaba el click vivo — se hablaba, se soltaba y el // teclado pulsaba «Colgar». Ver también el desenfoque de `empezarAHablar`. if (!escribiendo(e.target)) e.preventDefault(); - dejarDeHablar(); + micro.dejar(); }; // Cambiar de ventana con la barra apretada dejaba el turno abierto para // siempre: la tecla se suelta donde ya no lo oye nadie. - const fuera = () => dejarDeHablar(); + const fuera = () => micro.dejar(); window.addEventListener('keydown', abajo); window.addEventListener('keyup', arriba); window.addEventListener('blur', fuera); @@ -815,7 +723,7 @@ function App() { const responderPendiente = async (id: number, decision: 'aprobar' | 'rechazar') => { setPendientes(prev => prev.filter(p => p.id !== id)); try { - await invoke('panel_responder', { id, decision }); + await nucleo.responder(id, decision); addTranscript('system', decision === 'aprobar' ? `Aprobado el #${id}.` : `Rechazado el #${id}.`); } catch (e) { addTranscript('system', `No se pudo responder al #${id}: ${e}`); @@ -837,7 +745,7 @@ function App() { // «Escuchando» solo cuando el micrófono está abierto de verdad: pulsando // para hablar y con el botón suelto, el paso está cerrado y la pantalla se // quedaba clavada en «Escuchando» mientras no oía nada (2026-09-09). - const microAbierto = modoMicro !== 'pulsar' || pulsando; + const microAbierto = micro.abierto; const fase: Fase = !isActive ? 'reposo' : connectionState === 'connecting' ? 'conectando' : isSpeaking ? 'hablando' @@ -856,7 +764,7 @@ function App() { : isSpeaking ? 'Hablando' // Pulsando, «Escuchando» sería mentira la mayor parte del tiempo: el // micrófono está cerrado hasta que se aprieta, y la pantalla lo dice. - : isConnected && modoMicro === 'pulsar' ? (pulsando ? 'Le escucho' : 'Pulse para hablar') + : isConnected && micro.modo === 'pulsar' ? (micro.pulsando ? 'Le escucho' : 'Pulse para hablar') : isConnected ? 'Escuchando' : ''; @@ -942,7 +850,7 @@ function App() { onAjustes={() => setShowSettings(true)} // La pestaña vuelve a estar viva (encargo del señor Persus, 2026-08-24): // pulsar una ficha arranca los servidores del proyecto — modo - // `servicio` en perseo_core/proyectos.py — y la pestaña del navegador + // `servicio` en perseo_core/servicios/proyectos.py — y la pestaña del navegador // se abre sola cuando el puerto contesta. onProyectos={() => setShowProyectos(v => !v)} proyectosAbiertos={showProyectos} @@ -958,7 +866,7 @@ function App() { {/* Etiquetas de reconocimiento: la caja la da YuNet sobre 640x480, que es exactamente lo que captura camera-manager, así que los porcentajes caen donde toca sin medir el vídeo real. */} - {caras.map((cara, i) => ( + {identidad.caras.map((cara, i) => (
- {hablante} + {identidad.hablante}
)} @@ -1052,20 +960,20 @@ function App() { {/* En «pulsar para hablar» el botón de silenciar no pinta nada: el micrófono ya está cerrado de serie. Su sitio lo ocupa el de hablar, que se mantiene apretado —o la barra espaciadora—. */} - {modoMicro === 'pulsar' ? ( + {micro.modo === 'pulsar' ? ( ) : ( - - ) : ( - <> - - - - )} - - )} - -); - -/** Una barra con su número. Misma información que en el móvil, misma forma: - * dos dibujos distintos del mismo dato acaban discrepando. */ -const Barra: React.FC<{ etiqueta: string; porcentaje: number; detalle?: string }> = ({ - etiqueta, porcentaje, detalle, -}) => ( -
-
- {etiqueta} - {Math.round(porcentaje)}% -
-
-
= 90 ? ' malo' : porcentaje >= 70 ? ' aviso' : '')} - style={{ width: `${Math.min(100, Math.max(0, porcentaje))}%` }} - /> -
- {detalle &&
{detalle}
} -
-); - -/** Lo que un asistente debería saber sin que se lo preguntes. Va lo primero de - * la pestaña porque es lo único que cambia lo que haces ahora. */ -/** La línea de lectura de arriba del todo: reloj, tiempo encendido y un punto - * que late. - * - * No dice nada que no esté ya en las cifras de debajo, y aun así hace falta: - * es lo que convierte una pantalla de datos en un puesto encendido. El punto - * late porque un panel quieto y un panel colgado se ven igual. */ -const Lectura: React.FC<{ encendido: string; generado?: string; version?: string }> = ({ - encendido, generado, version, -}) => { - const [reloj, setReloj] = useState(() => new Date()); - useEffect(() => { - const t = setInterval(() => setReloj(new Date()), 1000); - return () => clearInterval(t); - }, []); - - const hora = reloj.toLocaleTimeString('es-ES', { hour12: false }); - // El desfase entre el reloj y el último vistazo al núcleo: si esto crece, la - // pantalla dejó de refrescarse y el resto de números son de hace rato. - const desde = generado ? Math.max(0, Math.round((reloj.getTime() - new Date(generado).getTime()) / 1000)) : null; - - // En desarrollo no hay marca que comparar: se construye en cada recarga. - const desfasada = !EN_DESARROLLO && !!version && version !== CONSTRUCCION; - - return ( -
- - PERSEO // NÚCLEO ACTIVO - · - {hora} - · - EN PIE {encendido} - {desde !== null && ( - <> - · - DATOS DE HACE {desde}s - - )} - {/* Dos marcas, no una: la que lleva esta app dentro y la que el núcleo - tiene sellada. Si no coinciden, esta ventana es de una construcción - anterior y hay que pasar `perseo actualizar`. Ver lib/version.ts. */} - · - - VERSIÓN {EN_DESARROLLO ? 'DESARROLLO' : CONSTRUCCION} - {desfasada && ` · EL NÚCLEO DICE ${version}`} - -
- ); -}; - -/** La línea de una magnitud en el tiempo, dibujada a mano en SVG. - * - * Un número dice si la CPU está alta **ahora**; la línea dice si lleva diez - * minutos así, que es la pregunta que uno se hace de verdad mirando esto. Sin - * ejes ni rejilla: el eje va de 0 a 100 siempre, así que dos líneas se comparan - * entre sí sin leer un solo número. + * La decisión entera, con lo que haría falta para deshacerla: + * `docs/adr/0006-dos-pantallas-para-el-mismo-panel.md`. */ -const Linea: React.FC<{ puntos: number[]; etiqueta: string; valor: string }> = ({ - puntos, - etiqueta, - valor, -}) => { - const ancho = 240; - const alto = 34; - // Con una sola muestra no hay línea que dibujar; se repite para que salga - // una recta en vez de un hueco, que en una pantalla que se acaba de abrir - // parece que la telemetría no va. - const serie = puntos.length === 1 ? [puntos[0], puntos[0]] : puntos; - const paso = serie.length > 1 ? ancho / (serie.length - 1) : ancho; - const y = (v: number) => alto - (Math.max(0, Math.min(100, v)) / 100) * alto; - const camino = serie.map((v, i) => `${i === 0 ? 'M' : 'L'}${(i * paso).toFixed(1)},${y(v).toFixed(1)}`).join(' '); - const relleno = `${camino} L${ancho},${alto} L0,${alto} Z`; - - return ( -
-
- {etiqueta} - {valor} -
- - {/* La mitad de la escala, para tener contra qué leer la línea sin ejes. */} - - - - {/* Dónde está *ahora*: sin esto, en una línea plana no se sabe cuál es - el extremo vivo y cuál el viejo. */} - - -
- ); -}; - -/** El día: lo que hay en la agenda y el correo que nadie ha resuelto. - * - * Los dos datos ya estaban en el sistema —en el calendario y en el triaje— y - * había que ir a buscarlos a dos sitios. Aquí se leen de una mirada, que es - * para lo que sirve un tablero. */ -const ElDia: React.FC<{ p: any }> = ({ p }) => { - const eventos: any[] = p.eventos?.length ? p.eventos : p.proximo_evento ? [p.proximo_evento] : []; - const pendientes = Object.entries(p.correo ?? {}) as [string, number][]; - const total = pendientes.reduce((n, [, c]) => n + c, 0); - - const hora = (e: any) => - e?.momento - ? new Date(e.momento).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }) - : '--:--'; - - return ( -
-
El día
- {eventos.length ? ( - eventos.map((e, i) => ( -
- {hora(e)} - {e.titulo ?? '(sin título)'} -
- )) - ) : ( -
Nada en la agenda de las próximas 24 h.
- )} -
- {total - ? `${total} correo${total === 1 ? '' : 's'} sin resolver` + - (p.correo?.requiere_accion ? ` · ${p.correo.requiere_accion} requieren acción` : '') - : 'Correo al día'} -
-
- ); -}; - -const Presencia: React.FC<{ p: any }> = ({ p }) => { - return ( -
-
Ahora mismo
-
- {p.haciendo ? `Trabajando: #${p.haciendo.id} · ${p.haciendo.agente}` : 'Sin nada entre manos'} -
- {!!p.esperando_un_si && ( -
{p.esperando_un_si} esperando un sí
- )} - {/* El correo y la agenda se cuentan en «El día», justo debajo: repetirlos - aquí era la misma frase dos veces en la misma pantalla. */} -
- ); -}; - -/** La máquina donde vive el núcleo — lo único de esta pantalla que no habla de - * Perseo. Si un día el núcleo se muda a la Raspberry, describe la Raspberry. */ -const Maquina: React.FC<{ m: any }> = ({ m }) => { - const historial: any[] = Array.isArray(m.historial) ? m.historial : []; - return ( -
-
- Máquina - {m.bateria && ( - - {m.bateria.porcentaje}%{m.bateria.enchufado ? ' · enchufada' : ''} - - )} -
-
- - - -
- {historial.length > 0 && ( -
- h.cpu)} - /> - h.memoria)} - /> -
- )} - {m.red?.legible &&
Red · {m.red.legible}
} -
- ); -}; - -/** Una nota del vault. El contenido se pide solo al desplegarla: una búsqueda - * devuelve diez, y traerlas enteras para leer una es tirar el trabajo. */ -const NotaVault: React.FC<{ n: any }> = ({ n }) => { - const [contenido, setContenido] = useState(null); - - const abrir = async (e: React.SyntheticEvent) => { - if (!e.currentTarget.open || contenido !== null || !n.ruta) return; - setContenido('Leyendo…'); - try { - const r = await encolarYEsperar('memoria', { accion: 'leer', ruta: n.ruta }); - setContenido(r?.contenido ?? '(vacía)'); - } catch (err: any) { - setContenido('No se pudo leer: ' + err); - } - }; - - return ( -
- {n.titulo || n.ruta || '(sin título)'} - {n.ruta &&
{n.ruta}
} - {n.extracto &&
{n.extracto}
} - {contenido !== null &&
{contenido}
} -
- ); -}; - -const TarjetaTrabajo: React.FC<{ - t: Trabajo; - onResponder: (id: number, d: string) => void; - /** Lo que la pestaña de agentes cuelga debajo: la bitácora del encargo. La - * cola no la enseña —ahí se mira el ciclo de vida, no el paso a paso—. */ - extra?: React.ReactNode; -}> = ({ t, onResponder, extra }) => { - const notas: any[] = Array.isArray(t.resultado?.notas) ? t.resultado.notas : []; - const clasificados: any[] = Array.isArray(t.resultado?.clasificados) ? t.resultado.clasificados : []; - - return ( -
-
- {ESTADO_LEGIBLE[t.estado] ?? t.estado} - {` #${t.id} · ${t.agente} · ${t.origen}`} -
-
{resumirPeticion(t)}
- - {/* Lo que está haciendo AHORA. Un encargo de código tarda minutos y sin - esto la tarjeta dice «en curso» y nada más durante todo ese rato. */} - {t.progreso &&
{t.progreso}
} - - {(t.resultado || t.error) && ( -
- {t.error ? ( - `Error: ${t.error}` - ) : notas.length ? ( - <> -
{t.resultado.titular ?? 'Sin resultados'}
- {notas.slice(0, 3).map((n, i) => )} - {notas.length > 3 && ( -
y {notas.length - 3} más — búscalas en Memoria
- )} - - ) : clasificados.length ? ( - <> -
{t.resultado.titular ?? 'Nada que destacar'}
- {clasificados - .filter(c => c.clase !== 'ignorar') - .map((c, i) => )} - - ) : typeof t.resultado?.contenido === 'string' ? ( - // Leer una nota devuelve la nota entera: doce mil caracteres de - // Markdown para decir que se leyó un fichero. - `Leída ${t.resultado.ruta ?? ''} — ${t.resultado.contenido.length} caracteres` - ) : ( - resumirResultado(t.resultado) - )} -
- )} - - {t.estado === 'esperando' && t.confirmacion && ( -
-
{t.confirmacion.resumen ?? '¿Confirmas?'}
- {t.confirmacion.detalle &&
{t.confirmacion.detalle}
} -
- )} - - {ESTADOS_ABIERTOS.has(t.estado) && ( -
- {t.estado === 'esperando' && ( - <> - - - - )} - -
- )} - - {extra} -
- ); -}; - -/** Un paso de la bitácora de un encargo. */ -type Paso = { - tipo: string; - titulo: string; - detalle: string; - agente: string; - ok: boolean; - momento: string; -}; - -type Actividad = { - id: number; - vivo: boolean; - estado?: string; - pasos: Paso[]; - agentes: { id: string; titulo: string; pasos: number; fallos: number }[]; -}; - -const NOMBRES_DE_PASO: Record = { - herramienta: 'hace', - resultado: 'sale', - dice: 'dice', - piensa: 'piensa', - subagente: 'subagente', - fin: 'fin', - error: 'error', -}; - -/** La bitácora de un encargo: qué hizo el agente principal y qué hizo cada - * subagente, paso a paso y con el detalle a mano. - * - * Existe por el 2026-08-25: dos encargos seguidos dijeron HECHO —«16 vueltas»— - * sin haber abierto la app que se les pidió, y no había forma de saber qué - * habían hecho durante esas dieciséis vueltas sin abrir el registro del - * núcleo desde otro ordenador. Ahora se abre aquí, y se puede entrar dentro de - * cada subagente. */ -const Bitacora: React.FC<{ id: number; vivo: boolean }> = ({ id, vivo }) => { - const [actividad, setActividad] = useState(null); - const [mirado, setMirado] = useState('todo'); - const [fallo, setFallo] = useState(''); - - const cargar = useCallback(async () => { - try { - setActividad(await invoke('panel_actividad', { id })); - setFallo(''); - } catch (e: any) { - setFallo(String(e)); - } - }, [id]); - - useEffect(() => { - cargar(); - // Un encargo terminado ya no cambia: sondearlo sería repintar encima de lo - // que estás leyendo cada tres segundos, sin nada nuevo que enseñar. - if (!vivo) return; - const t = setInterval(cargar, 3000); - return () => clearInterval(t); - }, [cargar, vivo]); - - if (fallo) return
No se pudo leer la actividad: {fallo}
; - if (!actividad) return
Leyendo la actividad…
; - - const visibles = actividad.pasos.filter(p => mirado === 'todo' || p.agente === mirado); - - return ( -
- {actividad.agentes.length > 1 && ( -
- - {actividad.agentes.map(a => ( - - ))} -
- )} - -
- {visibles.length === 0 && ( -
- Sin pasos apuntados. Los motores de consola no cuentan nada hasta el final. -
- )} - {visibles.map((p, i) => ( -
- {(p.momento || '').slice(11, 19)} - {NOMBRES_DE_PASO[p.tipo] ?? p.tipo} -
-
{p.titulo || '(sin título)'}
- {p.detalle && p.detalle !== p.titulo && ( -
- detalle -
{p.detalle}
-
- )} -
-
- ))} -
-
- ); -}; - -/** El chat escrito, la otra mitad de hablar. - * - * Ya no va por `/mensaje` (el router local contestaba sin herramientas ni - * memoria): ahora cada turno es un trabajo para el agente `chat`, que piensa - * con Gemini y usa las MISMAS herramientas que la voz —agenda, buzón triado, - * memoria, web, PC, MCP, subagentes—. La vista solo encola y sondea: las caras - * no piensan. */ -const ChatTab: React.FC = () => { - const [sesiones, setSesiones] = useState([]); - const [sesion, setSesion] = useState(null); - const [mensajes, setMensajes] = useState([]); - const [turno, setTurno] = useState('libre'); - const [escrito, setEscrito] = useState(''); - const [aviso, setAviso] = useState(''); - /** El carril por el que scrollea la conversación y si estábamos abajo. - * Empujar a abajo en cada trozo arrastraba a quien había subido a releer: - * solo se sigue al final cuando ya se estaba cerca o al enviar. */ - const hiloRef = useRef(null); - const pegadoAbajoRef = useRef(true); - - const cargarSesiones = useCallback(async (preferir?: number) => { - try { - const datos = await invoke<{ sesiones: Sesion[] }>('chat_sesiones'); - setSesiones(datos.sesiones); - setSesion(actual => { - if (actual != null && datos.sesiones.some(s => s.id === actual)) return actual; - if (preferir != null && datos.sesiones.some(s => s.id === preferir)) return preferir; - return datos.sesiones[0]?.id ?? null; - }); - } catch (e: any) { - setAviso(String(e)); - } - }, []); - - /** Sondea mientras hay un turno en marcha. El texto de Perseo crece en la - * base; aquí se ve crecer en pantalla. Cuando el semáforo vuelve a «libre», - * el sondeo se para solo. */ - useEffect(() => { - if (sesion == null) return; - let vivo = true; - let temporizador: number | undefined; - - const mirar = async () => { - try { - const datos = await invoke('chat_sesion', { id: sesion }); - if (!vivo) return; - setMensajes(datos.mensajes); - setTurno(datos.turno); - setAviso(''); - if (datos.turno !== 'ocupado') return; - } catch (e: any) { - if (vivo) setAviso(String(e)); - } - temporizador = window.setTimeout(mirar, 700); - }; - - mirar(); - return () => { vivo = false; if (temporizador) clearTimeout(temporizador); }; - }, [sesion, turno]); - - // La última burbuja a la vista, pero solo si ya se miraba abajo: llegar un - // trozo nuevo no autoriza a secuestrar el scroll de quien subió a releer. - useEffect(() => { - const hilo = hiloRef.current; - if (!hilo || !pegadoAbajoRef.current) return; - hilo.scrollTop = hilo.scrollHeight; - }, [mensajes, aviso]); - - // Cambiar de conversación empieza abajo: la posición de scroll era de la - // otra charla y no significa nada aquí. - useEffect(() => { - pegadoAbajoRef.current = true; - const hilo = hiloRef.current; - if (hilo) hilo.scrollTop = hilo.scrollHeight; - }, [sesion]); - - const enviar = async (e: React.FormEvent) => { - e.preventDefault(); - const texto = escrito.trim(); - if (!texto || sesion == null || turno === 'ocupado') return; - setEscrito(''); - // Lo que acabas de mandar se ve siempre, aunque estuvieras leyendo arriba. - pegadoAbajoRef.current = true; - try { - await invoke('chat_hablar', { id: sesion, texto }); - const datos = await invoke('chat_sesion', { id: sesion }); - setMensajes(datos.mensajes); - setTurno(datos.turno); - setAviso(''); - } catch (err: any) { - setAviso(String(err)); - } - }; - - const nueva = async () => { - try { - const nueva_sesion = await invoke('chat_crear'); - await cargarSesiones(nueva_sesion.id); - } catch (err: any) { - setAviso(String(err)); - } - }; - - const borrar = async (id: number) => { - try { - await invoke('chat_borrar', { id }); - await cargarSesiones(); - } catch (err: any) { - setAviso(String(err)); - } - }; - - useEffect(() => { cargarSesiones(); }, [cargarSesiones]); - - const ocupado = turno === 'ocupado'; - const pensando = ocupado && (!mensajes.length || mensajes[mensajes.length - 1].estado === 'escribiendo'); - - return ( -
- - -
{ - const hilo = hiloRef.current; - if (!hilo) return; - pegadoAbajoRef.current = hilo.scrollHeight - hilo.scrollTop - hilo.clientHeight < 120; - }} - > - {mensajes.length === 0 && ( -
- Escríbele. Tiene las mismas manos que en la llamada: agenda, buzón - triado, memoria, web, PC, MCP y subagentes. Lo que haga aparece en la cola. -
- )} - {mensajes.map(m => ( -
-
- {m.rol === 'usuario' ? 'Señor Persus' : 'Perseo'} - - {new Date(m.momento).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })} - -
-
- {m.texto} - {m.estado === 'escribiendo' && m.texto && } -
- {m.herramientas?.length > 0 && ( -
- {m.herramientas.map((h, i) => {h})} -
- )} -
- ))} - {pensando && ( -
- )} - {aviso &&
{aviso}
} -
- setEscrito(e.target.value)} - placeholder={ocupado ? 'Perseo está escribiendo…' : 'Escribe a Perseo…'} - disabled={ocupado} - /> - -
-
-
- ); -}; - -/** Los encargos de código, lanzados desde el panel. - * - * Un encargo se escribe COMO SE HABLA: «En Armario, añade un README con - * opencode». El núcleo entiende el proyecto y el motor desde el propio - * texto — la vista no pregunta nada, solo manda el encargo. Las caras no - * piensan: el que lee y decide es `dev.py`. - * - * El señor Persus tachó los desplegables el 2026-08-24 —*«no me gusta cómo - * se ve el elegir opciones»*— y pidió que el dónde se dijera en la entrada. - * Antes de eso había dos selects que nadie sabía rellenar. */ -const EJEMPLOS_ENCARGO = [ - 'En CVScraper: ejecuta los tests, arregla los que fallen y cuenta qué pasaba.', - 'Añade un README con qué es este proyecto y cómo arrancarlo.', - 'Arranca la app de Armario y déjala escuchando para poder usarla desde el móvil.', -]; - -/** Con qué SISTEMA trabaja un encargo. Lo que ya no se elige es la carpeta. - * - * **opencode va primero y es lo que sale puesto**: es el que no gasta - * suscripción, y el señor Persus lo dijo con todas las letras el 2026-08-26 - * —«usar Claude es secundario, quiero la opción gratuita»—. Claude sigue ahí - * para el encargo que lo merezca, un escalón por debajo. */ -const SISTEMAS_AGENTE: [string, string][] = [ - ['opencode', 'opencode · gratis'], - ['sdk', 'Claude · SDK'], - ['claude', 'Claude · consola'], - ['', 'El configurado por defecto'], -]; -/** Los modelos GRATIS de opencode Zen que **contestan**, de más rápido a menos. - * - * Salen de `opencode models opencode` y de probarlos uno a uno el 2026-08-28. - * El orden no es capricho: los dos que encabezaban esta lista —los nemotron— - * no devolvían una sola línea en cien segundos, y un modelo que no contesta no - * da error: se cuelga hasta el tope de 900 s. Desde esta pantalla eso se veía - * como un encargo que no termina nunca, que es justo lo que pasaba. - * - * Es la MISMA lista que `perseo_core/dev.py`, `perseo_core/interfaz/index.html` - * y `commands/subagentes_mcp.py`. Si cambia una, cambian todas: se vuelven a - * sacar del mismo comando y se vuelven a probar. */ -const MODELOS_OPENCODE: [string, string][] = [ - ['opencode/big-pickle', 'big-pickle · 200k'], - ['opencode/hy3-free', 'hy3 · 190k'], - ['opencode/muse-spark-1.2-contributor-free', 'muse-spark · 1M'], - ['opencode/ling-3.0-flash-fin-free', 'ling-3.0-flash'], - ['opencode/mimo-v2.5-free', 'mimo-v2.5 · 200k (lento)'], - // No contestaban el 2026-08-28: cien segundos sin una línea. Al final, para - // que nadie los coja sin pedirlos. Ver `perseo_core/dev.py`. - ['opencode/nemotron-3-ultra-free', 'nemotron-3-ultra · 1M (no contestaba)'], - ['opencode/nemotron-3.5-lightning-free', 'nemotron-3.5-lightning (no contestaba)'], - ['', 'El que tenga configurado opencode'], -]; - -const MODELOS_CLAUDE: [string, string][] = [ - ['', 'Modelo por defecto'], - ['opus', 'opus'], - ['sonnet', 'sonnet'], - ['haiku', 'haiku'], -]; - -/** Y con qué modelo. Cada sistema tiene los suyos. */ -const MODELOS_AGENTE: Record = { - opencode: MODELOS_OPENCODE, - sdk: MODELOS_CLAUDE, - claude: MODELOS_CLAUDE, - '': MODELOS_CLAUDE, -}; - -const AgentesTab: React.FC<{ onEncargado: () => void }> = ({ onEncargado }) => { - const [tarea, setTarea] = useState(''); - const [aviso, setAviso] = useState(''); - const [encargos, setEncargos] = useState([]); - // Se arranca en opencode y en su primer modelo gratis: lo que no cuesta - // suscripción es lo que debe salir puesto, no lo que hay que ir a buscar. - const [motor, setMotor] = useState(SISTEMAS_AGENTE[0][0]); - const [modelo, setModelo] = useState(MODELOS_OPENCODE[0][0]); - /** Qué bitácoras están abiertas. Fuera del render de cada tarjeta: la lista - * se recarga sola y cerrar lo que estás leyendo sería inservible. */ - const [abiertos, setAbiertos] = useState>(new Set()); - - const cargar = useCallback(async () => { - try { - const datos = await invoke<{ trabajos: Trabajo[] }>('panel_trabajos', { limite: 50 }); - setEncargos(datos.trabajos.filter(t => t.agente === 'dev')); - } catch (e: any) { - setAviso(String(e)); - } - }, []); - - useEffect(() => { - cargar(); - const t = setInterval(cargar, REFRESCO); - return () => clearInterval(t); - }, [cargar]); - - const lanzar = async (e: React.FormEvent) => { - e.preventDefault(); - const texto = tarea.trim(); - if (!texto) { setAviso('Escribe primero qué tiene que hacer.'); return; } - // Sin `directorio`: el núcleo trabaja desde la carpeta del usuario y el - // agente entra en el proyecto que haga falta. Elegir la raíz era el - // impuesto de cada encargo, y mandaba una URL cuando el proyecto era un - // servicio (2026-08-26). - const peticion: Record = { texto }; - if (motor) peticion.motor = motor; - if (modelo) peticion.modelo = modelo; - try { - const trabajo = await invoke('panel_encolar', { agente: 'dev', peticion }); - setTarea(''); - setAviso(`Encargo #${trabajo.id} en marcha. Puede tardar minutos: trabaja solo.`); - onEncargado(); - } catch (err: any) { - setAviso('No se pudo lanzar: ' + err); - } - }; - - // Tres montones, leídos como los lee una persona: lo que va, lo que espera - // tu decisión y lo que ya terminó. La cola cruda está en su pestaña; aquí - // solo importa el ciclo de vida de UN encargo. - const enMarcha = encargos.filter(t => t.estado === 'pendiente' || t.estado === 'en_curso'); - const esperando = encargos.filter(t => t.estado === 'esperando'); - const terminados = encargos.filter(t => !ESTADOS_ABIERTOS.has(t.estado)); - - const tarjeta = (t: Trabajo) => ( - { - invoke('panel_responder', { id, decision: d }).then(cargar).catch(() => {}); - }} - extra={ -
- - {abiertos.has(t.id) && } -
- } - /> - ); - - return ( - <> -
-
Encargos de código
-
- Escribe el encargo como se habla: el proyecto va en el texto - («en CVScraper…»). El agente trabaja desde tu carpeta de usuario y - puede entrar en cualquier proyecto: no hay que elegir raíz. Con qué - trabaja se elige abajo. -
-
- -
-
- {/* Al cambiar de sistema, el modelo pasa a ser el PRIMERO del nuevo y - no vacío: con opencode, vacío significa «el que tenga configurado», - que puede ser de pago. */} - - -
-