aboutsummaryrefslogtreecommitdiffstats
path: root/docs/EXTENDER.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/EXTENDER.md')
-rw-r--r--docs/EXTENDER.md138
1 files changed, 138 insertions, 0 deletions
diff --git a/docs/EXTENDER.md b/docs/EXTENDER.md
new file mode 100644
index 0000000..e45eb16
--- /dev/null
+++ b/docs/EXTENDER.md
@@ -0,0 +1,138 @@
+# Extender el asistente
+
+## Añadir una herramienta
+
+Las herramientas son el punto de extensión pensado para lo que venga después:
+ejecutar órdenes, consultar cosas, encender luces. Una herramienta se describe
+a sí misma en JSON Schema y sabe ejecutarse; el registro se encarga del resto.
+
+Implementa `asist_core::tools::Tool`:
+
+```rust
+use asist_core::error::{Error, Result};
+use asist_core::tools::Tool;
+use serde_json::{json, Value};
+
+pub struct Temperatura;
+
+impl Tool for Temperatura {
+ fn name(&self) -> &str { "temperatura_cpu" }
+
+ // La lee el modelo, así que se escribe para él: concreta y en español.
+ fn description(&self) -> &str {
+ "Devuelve la temperatura actual de la CPU en grados. Úsala cuando \
+ pregunten si el equipo está caliente."
+ }
+
+ fn parameters(&self) -> Value {
+ json!({ "type": "object", "properties": {}, "required": [] })
+ }
+
+ fn call(&self, _args: &Value) -> Result<String> {
+ let raw = std::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp")
+ .map_err(|e| Error::Tool { tool: self.name().into(), message: e.to_string() })?;
+ let grados = raw.trim().parse::<f32>().unwrap_or(0.0) / 1000.0;
+ Ok(format!("{grados:.1} grados"))
+ }
+
+ // Ponlo a true si cambia algo fuera del proceso.
+ fn is_side_effecting(&self) -> bool { false }
+}
+```
+
+Y regístrala en `ToolRegistry::from_config` (en `crates/asist-core/src/tools.rs`)
+o directamente antes de montar el pipeline:
+
+```rust
+let mut tools = ToolRegistry::from_config(&config.tools);
+tools.register(std::sync::Arc::new(Temperatura));
+```
+
+Tres cosas que conviene saber antes de escribir la primera:
+
+- **El error nunca tumba el turno.** `dispatch` convierte un fallo en texto y
+ se lo devuelve al modelo, que lo explica o reintenta.
+- **La salida se lee en voz alta.** Devuelve una frase, no una tabla. El
+ registro recorta a 2000 caracteres, pero mucho antes de eso ya aburre.
+- **Con este modelo, más herramientas es peor.** Qwen3.5-2B ya es frágil
+ eligiendo entre dos; ver el apartado 3 de [RENDIMIENTO.md](RENDIMIENTO.md).
+
+## Ejecutar órdenes del sistema
+
+Ya está implementado y apagado (`tools.shell = false`). Encenderlo es un cambio
+de postura de seguridad, no una comodidad: lo que llega a esa herramienta
+viene, en última instancia, de lo que se oye por el micrófono.
+
+Las cuatro barreras que tiene, y que conviene no quitar:
+
+1. **Apagada por defecto**, y la configuración se niega a arrancar con la lista
+ blanca vacía.
+2. **Lista blanca sobre el ejecutable.** Se compara `argv[0]` exacto, y las
+ rutas se rechazan: `/bin/echo` no cuela aunque `echo` esté permitido.
+3. **Sin shell de por medio.** Los argumentos van al `execve` tal cual, así que
+ `; rm -rf /` es un argumento literal y no otra orden.
+4. **Plazo máximo**, tras el cual el proceso se mata.
+
+```toml
+[tools]
+shell = true
+shell_allowlist = ["date", "uptime", "free", "df", "ls"]
+shell_timeout_secs = 10
+shell_dry_run = true # estrena la lista sin ejecutar nada
+shell_working_dir = "/home/elvis"
+```
+
+Empieza con `shell_dry_run = true`: registra lo que el modelo habría ejecutado
+sin llegar a hacerlo, que es la forma barata de descubrir qué pide de verdad.
+
+## Cambiar de motor
+
+Cada etapa habla con su motor a través de un único tipo, así que sustituir uno
+no toca el resto:
+
+| Etapa | Tipo | Fichero |
+|---|---|---|
+| Voz a texto | `asist_asr::Recognizer` | `crates/asist-asr/src/lib.rs` |
+| Modelo | `asist_llm::LlmClient` | `crates/asist-llm/src/client.rs` |
+| Texto a voz | `asist_tts::TtsClient` | `crates/asist-tts/src/lib.rs` |
+
+El cliente HTTP (`asist_core::http`) es propio y mínimo a propósito: todo es
+HTTP plano contra localhost, y lo que ninguna librería genérica da cómodo es
+**abortar una respuesta a media descarga**, que es justo lo que sostiene la
+interrupción.
+
+## Añadir una etapa al pipeline
+
+El orquestador (`crates/asist-app/src/pipeline.rs`) es una cadena de hilos
+unidos por canales. Para meter una etapa nueva —clasificar la intención,
+filtrar palabrotas, registrar la conversación— añade un hilo con
+`spawn_named` y un canal, y respeta las dos reglas que sostienen el conjunto:
+
+1. **Nada bloquea al que va delante.** Si vas más lento que tu emisor,
+ descarta trabajo; no acumules retraso.
+2. **Comprueba el turno antes de gastar esfuerzo.** `session.is_current(turn)`
+ dice si lo que estás a punto de hacer sigue interesando a alguien, o si el
+ usuario ya ha dicho otra cosa.
+
+## Cambiar la voz
+
+```bash
+# Graba unos segundos de la voz que quieras y transcríbelos a mano.
+scripts/clonar-voz.sh grabacion.wav transcripcion.txt mivoz
+```
+
+Deja `assets/voices/mivoz.{spk,rvq,txt}`. Apunta ahí `[tts.reference]` en la
+configuración. Los latentes se extraen una vez; en cada arranque se manda lo ya
+extraído y no la grabación.
+
+También sirven las voces del propio modelo: quita `[tts.reference]` y pon
+`tts.voice` con un nombre de `GET /v1/audio/voices`.
+
+## Ideas que el diseño ya admite
+
+- **Palabra de activación.** El segmentador ya entrega intervenciones sueltas:
+ bastaría con filtrar por la transcripción antes de abrir turno.
+- **Memoria entre sesiones.** `Conversation` es serializable en la práctica;
+ guardarla y recargarla al arrancar es un cambio pequeño.
+- **Multimodal.** El servidor ya carga `mmproj`, así que el modelo puede ver
+ imágenes; faltaría meterlas en el mensaje de usuario.