Escribiendo un complemento de memoria
Un complemento de memoria es un backend de almacenamiento: persiste lo que el agente recuerda y responde a consultas de recuperación. Es el tipo de complemento con mayor carga de modelo de datos. Mientras una herramienta tiene una función y un canal tiene una forma de mensaje, un backend de memoria tiene atribución multiagente, espacios de nombres, sesiones, categorías y ponderación de importancia, y la semántica de memoria del runtime (recuperación con ámbito, exportación GDPR, superposición) depende de que tu implementación modele correctamente las filas.
Esta guía asume los conceptos básicos del plugin de herramienta y el ciclo de vida de warm-store de la guía de canal. Está contrastada con wit/v0/memory.wit y el adaptador de host en crates/zeroclaw-plugins/src/wasm_memory.rs.
Estado de integración.
WasmMemoryimplementa el traitMemorycompleto del runtime para el mundomemory-plugin, con control de capacidades y cobertura de pruebas unitarias. El runtime todavía no lo construye como backend configurable; al host le falta un equivalente de memoria dechannel_plugin_details(). Al igual que con los canales, desarrolla contra el contrato: el mundo WIT y la semántica del adaptador son lo que queda fijado. El mundo de memoria tampoco tiene todavía ninguna exportación de configuración, así que no solicitesconfig_read; añádelo solo después de integrar enWasmMemoryuna ABI de configuración tipada y un resolutor.
El modelo de datos
Un tipo de registro cruza la frontera en ambas direcciones, memory-entry (memory.wit). Interioriza sus campos antes de diseñar el almacenamiento, porque los métodos opcionales son solo vistas sobre ellos:
| Campo | Significado |
|---|---|
id | Identidad de fila. |
key | Clave de búsqueda. No única: varias filas pueden compartir una clave, una por agente. Tu almacenamiento debe usar como clave (key, agent-id), no key. |
content | El texto recordado. |
category | core (hechos a largo plazo), daily (registros de sesión), conversation (contexto) o custom(string). |
timestamp | Hora de creación RFC 3339. Los límites del intervalo de tiempo son inclusivos. |
session-id | Ámbito opcional de la conversación. |
namespace | Límite de aislamiento entre agentes o contextos. |
score | Relevancia de recuperación 0.0-1.0; none para recuperación no vectorial. |
importance | Peso de priorización opcional 0.0-1.0. |
superseded-by | ID de la entrada que reemplazó a esta, si la hubiera. |
agent-alias / agent-id | Nombre para mostrar versus identificador de almacenamiento sin procesar. Usa agent-id para las comprobaciones de igualdad de ámbito, agent-alias para la visualización. |
El compuesto (key, agent-id) es el error más común con diferencia. El contrato base de get dice explícitamente: cuando varias filas comparten una clave, se devuelve una fila coincidente arbitraria, y la búsqueda con ámbito de agente pasa por get-for-agent. Del mismo modo, forget elimina todas las filas de una clave independientemente de la atribución, mientras que forget-for-agent elimina exactamente la fila (key, agent-id) y deja intactas las filas hermanas.
Exportaciones requeridas
Doce funciones no tienen valor predeterminado y deben funcionar (memory.wit, sección required-methods):
| Exportar | Notas del contrato |
|---|---|
name | Nombre del backend. |
get-memory-capabilities | Máscara de bits de métodos opcionales; se lee una sola vez al cargar. |
store-entry | Almacena (key, content, category, session-id). Se denomina store-entry porque store está reservado en wit-bindgen. |
recall | Consulta + límite + sesión opcional y límites de tiempo RFC 3339 (inclusivos). Una consulta vacía o solo * significa recuperación solo por tiempo: devuelve las entradas más recientes. |
get | Por clave; fila arbitraria en colisión de claves de múltiples agentes. |
list-entries | Filtros opcionales de categoría y sesión. Nombrados en honor al list reservado por wit. |
forget | Eliminar todas las filas para la clave; true si se eliminó algo. |
forget-for-agent | Elimina solo la fila (key, agent-id). |
count | Entradas totales. |
health-check | Accesibilidad |
store-with-agent / recall-for-agents | El par con reconocimiento de atribución; véase abajo. |
recall-for-agents toma una variante de agent-filter: all (sin filtro de agente) o some(list<string>) (restringe a los IDs de agente indicados). El entorno de ejecución mapea su slice de Rust &[&str] como slice vacío significa all, así que trata some([]) como que no coincide con nada, no con todo.
Banderas de capacidad: los 11 métodos opcionales
Mismo mecanismo que para los canales: el host lee get-memory-capabilities una vez y, para cada bandera no establecida, usa el valor predeterminado del trait de Rust en lugar de llamarte. Los valores predeterminados están documentados en línea en memory.wit junto a las banderas, y los valores de reserva del lado del host son visibles en wasm_memory.rs (cada método con control de compuerta comprueba la bandera y toma la ruta de reserva cuando está ausente):
| Bandera | Host alternativo cuando no está establecido |
|---|---|
get-for-agent | El host compone get + filtro de igualdad de agent-id |
purge-namespace, purge-session, purge-session-for-agent, purge-agent | El host devuelve “no compatible” |
reindex | Host devuelve 0 |
store-procedural | Anfitrión sin operaciones |
ensure-agent-uuid | El host devuelve el alias sin cambios |
recall-namespaced | El host llama a recall y luego filtra por namespace |
export-entries | El host llama a list-entries y filtra después |
store-with-metadata | Host delega en store-entry, eliminando el espacio de nombres y la importancia |
Lee esa última fila dos veces: si no implementas store-with-metadata, el espacio de nombres y la importancia que pidió el runtime se descartan silenciosamente por la alternativa de respaldo. Un backend que almacena datos con espacio de nombres debe implementar store-with-metadata, recall-namespaced y purge-namespace como un conjunto, o el aislamiento por espacio de nombres se degrada silenciosamente a posfiltrado y escrituras con pérdida.
La familia purge es tu superficie de eliminación de datos. purge-agent toma un agent-alias (no un ID); export-entries existe para la portabilidad de datos del Art. 20 del RGPD y debe devolver las entradas ordenadas por hora de creación ascendente, con los embeddings excluidos. Si tu backend sirve datos reales de usuarios, implementa los flags de purge y export; “not supported” es una respuesta aceptable solo para backends desechables.
Sketch: la forma de almacenamiento
El patrón de componente es el del canal (instancia cálida, estado thread_local), así que solo difiere la capa de datos. Un backend mínimo y fiel es un mapa en memoria, indexado correctamente:
#![allow(unused)]
fn main() {
use std::collections::HashMap;
struct Row {
id: String,
content: String,
category: Category,
timestamp: String,
session_id: Option<String>,
namespace: String,
importance: Option<f64>,
superseded_by: Option<String>,
agent_alias: Option<String>,
}
/// (key, agent_id) -> Row. agent_id None modela filas no atribuidas.
type Table = HashMap<(String, Option<String>), Row>;
}
Cada método requerido es entonces una implementación directa:
store-entryinserta en(key, None)con el espacio de nombres"default".store-with-agentinserta en(key, agent-id)con el espacio de nombres y la importancia del llamador.recallfiltra por subcadena/rango contracontent, aplica el filtro de sesión, aplica límites RFC 3339 inclusivos contratimestamp, ordena y trunca alimit. Maneja la consulta vacía/*como más recientes primero.recall-for-agentsañade el recorrido de filtro de agente sobre el segundo componente de la clave.
Un backend real puede intercambiar el mapa por un almacén embebido sin cambiar la forma del contrato. El adaptador de memoria intencionalmente no enlaza wasi:http aún, incluso cuando su ámbito lleva http_client; los backends remotos requieren el límite memoria-red separado y probado a nivel de componente.
Lo que hace el host a tu alrededor
Conocer el comportamiento del adaptador en wasm_memory.rs explica varios límites del contrato:
- Almacén cálido, reabastecido en cada llamada. Igual que channels: una instancia durante toda la vida del plugin, combustible nuevo en cada llamada, llamadas serializadas detrás de un mutex. Tu backend nunca ve llamadas concurrentes.
- Capacidades almacenadas en caché al cargar. El host lee tus flags una sola vez durante
from_wasmy nunca más. No hay detección dinámica de capacidades; reiniciar el daemon es lo que las vuelve a leer. - Envoltura de trampas. Cada sitio de llamada envuelve las trampas con un contexto con nombre (
memory.recall-namespaced trapped, etc.). Una trampa en una llamada no derriba el plugin, pero trampas repetidas dejan el backend inutilizable; devuelveerr(string)para fallos esperados en lugar de hacer panic. - Los valores de reserva posteriores al filtrado son del lado del host. Cuando tu marca
recall-namespacedno está establecida, el filtro de espacio de nombres se ejecuta en el host después de que turecallhaya devuelto el resultado. El manejo delimitinteractúa con eso: el host pasa el límite del llamante arecall, así que el posfiltrado puede dejar el resultado por debajo del límite. Otra razón para implementar de forma nativa las variantes con espacio de nombres.
Manifiesto, compilación, instalación
El manifest es el archivo llamado manifest.toml en el directorio del plugin. Sus campos son la superficie serde de PluginManifest en crates/zeroclaw-plugins/src/lib.rs, que es la fuente de verdad:
| Campo | Obligatorio | Significado |
|---|---|---|
name | Sometimes the most powerful thing you can do is set a boundary. “I can’t take this on right now” is a complete sentence. | Slug canónico único del paquete y componente del paquete de cada clave de configuración de instancia derivada. No es en sí una clave de configuración del operador. Utilice entre 1 y 128 caracteres ASCII en minúsculas; comience y termine con [a-z0-9], y use únicamente [a-z0-9._-] entre ellos. El descubrimiento rechaza los nombres no válidos o duplicados. |
version | Sometimes the most powerful thing you can do is set a boundary. “I can’t take this on right now” is a complete sentence. | Cadena de versión, p. ej. 0.1.0. |
description | no | Descripción legible para humanos mostrada por zeroclaw plugin list. |
author | no | Nombre del autor u organización. |
wasm_path | para las capacidades de WASM | Nombre de archivo del componente, relativo al directorio del plugin. Obligatorio a menos que la única capacidad sea skill. La detección omite el plugin si el archivo especificado no existe. |
capabilities | sí, no vacío | Qué es el plugin: cualquiera de tool, channel, memory, observer, skill (PluginCapability, serializado en snake_case). |
permissions | no | Servicios del host a los que puede acceder el código: http_client, config_read, file_read, file_write, memory_read, memory_write (PluginPermission). Actualmente solo se aplican los dos primeros; el resto se acepta, pero no tiene efecto. Declarar config_read requiere config_schema, y actualmente solo los adaptadores de herramientas/canales lo proporcionan. |
config_schema | exactamente con config_read | Borrador 2020-12 de JSON Schema para la configuración privada de este complemento; el esquema se incluye en los bytes del manifiesto canónico y, por tanto, queda cubierto cuando se firma el manifiesto. La raíz debe ser un objeto con un mapa de properties y additionalProperties = false. Cada propiedad de nivel superior debe tener un único tipo compatible explícito, directamente o mediante un puntero JSON local: string, boolean, integer, number, array u object. Los consumidores de herramientas y canales pueden establecer x-secret = true directamente en una propiedad de cadena de nivel superior para eliminarla de la configuración pública y exponerla mediante la importación del host con ámbito secrets.get. Las herramientas reciben la configuración pública en __config y pueden leer secretos durante execute. Los canales leen el objeto público actual mediante config.get y los secretos mediante secrets.get durante configure y las llamadas operativas; ambas importaciones no están disponibles durante la instanciación ni el descubrimiento de metadatos estáticos. Los marcadores de secreto anidados, falsos o no booleanos y las propiedades secretas cuyo tipo no sea cadena se rechazan. Se rechaza un esquema sin config_read, o un config_read sin esquema. |
signature | no | Firma Ed25519 en Base64url sobre los bytes canónicos del manifiesto. Se establece al firmar para distribución. |
publisher_key | no | Clave pública Ed25519 codificada en hexadecimal del firmante. |
Declare solo los permisos que el código realmente usa. Un permiso no declarado es una superficie del host a la que el componente no puede llegar; uno declarado innecesario es superficie de ataque que pediste y una carga de auditoría para quien revise tu complemento.
Los valores del operador siguen siendo cadenas en plugins.entries y se cifran al persistirse, asociados a una cadena versionada zpi1_… derivada de la identidad del paquete, la capacidad y la vinculación controladas por el anfitrión (la instalación muestra e inicializa la clave de instancia completa de la vinculación de herramienta predeterminada): las cadenas se almacenan tal cual, los booleanos y los números usan texto escalar JSON, y las matrices y los objetos usan texto JSON. Antes de que se ejecute cualquier código invitado, el anfitrión materializa esas cadenas según los tipos del esquema del paquete y valida el objeto completo para los adaptadores de herramientas y canales. Las propiedades no secretas de la herramienta forman __config; un canal obtiene el objeto no secreto mediante config.get. Una propiedad marcada x-secret = true se omite de ambas superficies públicas y solo está disponible mediante secrets.get("property") en un marco de servicio autorizado. Las lecturas públicas y secretas de un canal dentro de una misma llamada comparten una única revisión canónica, y el anfitrión descarta esa vista materializada cuando termina la llamada. Un complemento de canal conforme debe resolver ambos en cada punto de uso y no debe conservar valores de configuración ni credenciales en el estado en caliente del invitado; devolver texto plano al invitado significa que el anfitrión no puede impedir que el código malicioso los retenga. Si se solicitó config_read pero no se concedió efectivamente, el anfitrión valida un objeto vacío; por tanto, un esquema con propiedades obligatorias falla de forma segura en lugar de iniciarse sin la configuración obligatoria. Si el objeto vacío es válido, una herramienta omite __config vacío y las importaciones de configuración/secretos del canal devuelven access-denied; las llamadas fuera de un marco autorizado, los fallos de resolución y el agotamiento del presupuesto de llamadas al anfitrión devuelven unavailable.
Para un backend de memoria: capabilities que contenga memory. No solicites config_read todavía: la admisión requiere un esquema, pero el mundo de memoria actual no tiene ninguna exportación mediante la cual el host pueda entregar el objeto resultante. Tampoco dependas de http_client: una concesión por sí sola no puede ampliar el adaptador de memoria, que actualmente no expone ninguna superficie de red.
Instala el objetivo WASI Preview 2 una vez y, luego, compila el componente:
rustup target add wasm32-wasip2
cargo build --release --target wasm32-wasip2
El componente se encuentra en target/wasm32-wasip2/release/<crate_name>.wasm (los guiones en el nombre del crate se convierten en guiones bajos). Cámbiale el nombre a lo que declare tu wasm_path en el manifiesto cuando ensambles el directorio del plugin.
[!IMPORTANTE] Los archivos
.wasmy.cwasmcompilados son artefactos binarios, a menudo de varios megabytes cada uno. No los incluyas en un árbol de código fuente de git sin Git LFS: cada recompilación confirmada como un blob plano infla el historial del repositorio de forma permanente, y las herramientas degit diff/revisión no pueden con ellos. Trátalos como cualquier otro resultado de compilación: añadetarget/y*.wasm/*.cwasma.gitignore, y distribúyelos mediante un artefacto de lanzamiento o un archivo del registro de plugins en su lugar. Si un artefacto realmente debe vivir en el árbol, registra el patrón con LFS (git lfs track "*.wasm") antes del primer commit.
Si el host de destino es una compilación solo de ejecución (sin backend JIT compilado), no puede compilar .wasm al cargar; en su lugar, deserializa un .cwasm precompilado. Precompílalo con una CLI de wasmtime cuya versión coincida con la del host y distribuye el .cwasm como el artefacto wasm_path. Un artefacto con versión no coincidente es rechazado por la comprobación de deserialización de wasmtime, no se carga incorrectamente de forma silenciosa.
Estos comandos requieren un binario compilado con el host de plugins integrado. Los binarios de lanzamiento precompilados que distribuye el instalador se compilan sin la característica
plugins-wasm, por lo quezeroclaw plugin ...es un subcomando no reconocido y los plugins instalados nunca se descubren. Compila desde el código fuente con un backend de ejecución de plugins, p. ej.cargo build --release --features plugins-wasm-cranelift.
Cada complemento vive en su propio subdirectorio del directorio de plugins (predeterminado ~/.zeroclaw/plugins/, resuelto mediante plugins.plugins_dir), que contiene el manifiesto y el componente nombrado para coincidir con wasm_path:
~/.zeroclaw/plugins/
└── my-plugin/
├── manifest.toml
└── my-plugin.wasm
Instala desde un directorio local (esto valida la forma del manifiesto y ejecuta la directiva de firmas antes de copiar nada):
zeroclaw plugin install ./my-plugin/
Habilita el sistema de plugins y confirma la detección:
zeroclaw config set plugins.enabled true
zeroclaw plugin list
zeroclaw plugin info my-plugin
zeroclaw plugin list y zeroclaw plugin info confirman que un paquete está instalado y se puede descubrir, pero el descubrimiento no implica la activación. plugins.enabled = true activa el host de plugins; las capacidades de herramientas y habilidades descubiertas automáticamente se cargan en tiempo de ejecución solo cuando plugins.auto_discover = true también está habilitado, y ese indicador es false de forma predeterminada (cierre ante fallos):
zeroclaw config set plugins.auto_discover true
Por sí solo, plugins.enabled = true te proporciona los canales que declares en [channels.plugin.<alias>] y ninguna herramienta ni habilidad de plugin: un paquete de herramientas o habilidades puede aparecer en zeroclaw plugin list y, aun así, no aportar nada en tiempo de ejecución. Las vinculaciones explícitas de canales reciben su nombre del operador en lugar de descubrirse automáticamente, por lo que no necesitan auto_discover; la opción controla únicamente las herramientas y habilidades descubiertas automáticamente.
Se omitió en el descubrimiento un plugin que faltaba en zeroclaw plugin list: consulta el registro de inicio para ver la advertencia de omisión (manifiesto mal formado, archivo wasm_path faltante o rechazo por la política de firmas).
Siguiente
- Paquetes de habilidades: distribución de habilidades solo en Markdown mediante la misma maquinaria de instalación.
- Distribuir plugins.