aboutsummaryrefslogtreecommitdiffstats
path: root/docs/EXTENDER.md
blob: ea6d93778f4916392e5780bff92c02a86817cdad (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
# 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. Si no depende de nada, en `ToolRegistry::from_config`
(`crates/asist-core/src/tools.rs`); si necesita la red, el modelo o un
dispositivo, en `crates/asist-app/src/registry.rs`, que es donde se montan
`buscar_en_internet` y `mirar_por_la_camara`:

```rust
let mut tools = ToolRegistry::from_config(&config.tools);
tools.register(std::sync::Arc::new(Temperatura));
```

Si tarda más de un segundo, dale una frase de acuse. Se pronuncia antes de
ejecutar y es lo que evita que la espera se lea como un cuelgue:

```rust
fn acknowledgement(&self) -> Option<&str> {
    Some("Déjame que lo mire.")
}
```

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.** Con tres declaradas elige
  bien entre ellas (15 de 16 medido), pero le cuesta abstenerse: busca en
  internet cosas que ya sabe. Apartados 3 y 5 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.

## Buscar en internet

Ya está montado, con tres buscadores intercambiables.

```toml
[search]
enabled = true
backend = "tavily"     # "tavily", "ddgs" o "searxng"
api_key_env = "TAVILY_API_KEY"
command = ["../scripts/buscar-ddgs.sh", "{consulta}", "{max}"]
base_url = ""          # para searxng: "http://127.0.0.1:8888"
max_results = 5
```

Cuál elegir, con las mismas dos preguntas medidas contra cada uno:

| | «capital de Australia» | «qué tiempo hace hoy en Buenos Aires» | Clave |
|---|---|---|---|
| **tavily** | 0,54 s · *«El capital de Australia es Canberra…»* | 2,88 s · *«Hoy en Buenos Aires, cielo claro, máxima de 15°C…»* | sí |
| **ddgs** | 2,24 s · fragmentos de tres páginas | 3,59 s · fragmentos de tres páginas | no |

La diferencia que importa no es el tiempo sino la forma: **Tavily devuelve una
respuesta ya redactada** y el asistente sólo tiene que leerla, mientras que
ddgs devuelve fragmentos que el modelo tiene que resumir. Con 2B eso sale bien
la mayoría de las veces —medido en el pipeline completo, «Hoy en Buenos Aires
hace soleado esta mañana y por la tarde tendremos nubes con temperaturas
alrededor de 9°C»— pero es una oportunidad más de equivocarse.

Con clave, tavily. Sin clave, ddgs.

### Sobre DuckDuckGo

`scripts/buscar-ddgs.sh` se apoya en la librería
[`ddgs`](https://github.com/deedy5/ddgs), que es la misma que hay debajo de
`duckduckgo-mcp`. Consulta DuckDuckGo, Brave, Mojeek, Startpage y alguno más,
sin clave. Conviene instalarla de forma permanente para que cada búsqueda no
pague la resolución del paquete:

```bash
uv tool install ddgs     # el guion también funciona con uvx, pero más lento
```

Dos caminos que parecen equivalentes y **no funcionan**, comprobados aquí:

- `curl` contra `html.duckduckgo.com/html/` devuelve **HTTP 202** con una
  página anti-bot (`anomaly.js`) y ni un solo resultado.
- La API sin clave `api.duckduckgo.com` (Instant Answer) devuelve vacío para
  casi todo lo que no sea una entidad de enciclopedia: probada con «capital de
  Australia» y con «qué tiempo hace en Buenos Aires», ambas sin nada.

La librería sí funciona porque rota buscadores y cabeceras.

### Cualquier otro buscador

El backend `ddgs` es en realidad un **backend de comando**: ejecuta un programa
y lee JSON de su salida estándar. `{consulta}` y `{max}` se sustituyen antes de
ejecutar, y el extractor acepta tanto la lista suelta que devuelve ddgs como la
forma `{answer, results}`, con los campos nombrados a la manera de cada uno
(`href` o `url`, `body` o `content`).

Así que meter otro buscador —o un puente a un servidor MCP, que es un proceso
que habla JSON-RPC por stdio— no es tocar Rust, sino escribir un guion:

```toml
command = ["/ruta/a/mi-buscador.sh", "{consulta}", "{max}"]
```

Los argumentos van al `execve` tal cual, sin shell que los interprete: la
consulta sale de lo que se ha oído por el micrófono y no puede acabar
ejecutándose.

## Mirar por la cámara

También montado. El servidor carga el proyector multimodal (`--mmproj`), así
que el mismo modelo que conversa describe la imagen.

```toml
[camera]
enabled = true
device = "/dev/video0"
width = 640          # la resolución manda en la latencia: 2,9 s aquí, 7,8 s a 720p
height = 480
warmup_frames = 5    # deja que se asiente la exposición automática
save_dir = ""        # vacío = no se guarda ningún fotograma
```

Tres decisiones que conviene no deshacer sin pensarlo:

- **La pregunta viaja hasta la cámara.** El modelo rellena `pregunta` con lo
  que quiere averiguar, y esa pregunta acompaña a la imagen. Pedir una
  descripción genérica y luego interrogarla pierde el detalle que se buscaba.
- **La imagen no entra en el historial.** Una foto ocupa cientos de tokens de
  contexto y arrastrarla turno tras turno saldría carísimo para lo poco que
  aporta una vez descrita. Lo que vuelve a la conversación es el texto.
- **No se guarda nada en disco.** `save_dir` existe sólo para depurar qué está
  viendo el modelo; por defecto está vacío.

Para ver la pantalla en vez de la cámara, `grim` ya está instalado: sería la
misma herramienta cambiando cómo se obtienen los bytes del JPEG.

## 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` |
| Visión | `asist_llm::LlmClient::look` | `crates/asist-llm/src/client.rs` |
| Buscar y mirar | `asist_tools` | `crates/asist-tools/src/` |

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.

Hay un segundo cliente, `ureq`, sólo en `asist-tools`: un buscador está detrás
de HTTPS, y ahí hace falta TLS y no hace falta cortar a media descarga. Son dos
clientes porque resuelven dos problemas distintos, no por descuido.

## 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.