Écrire un plugin de mémoire
Un plugin de mémoire est un backend de stockage : il persiste ce dont l’agent se souvient et répond aux requêtes de récupération. Il s’agit du type de plugin le plus complexe sur le plan du modèle de données. Alors qu’un outil possède une seule fonction et qu’un canal définit une forme de message, un backend de mémoire gère l’attribution multi-agents, les espaces de noms, les sessions, les catégories et la pondération de l’importance, et les sémantiques de mémoire du runtime (rappel par portée, export RGPD, substitution) reposent sur le fait que votre implémentation définit correctement le modèle de ligne.
Ce guide suppose les bases du plugin d’outil et le cycle de vie du warm-store du guide des canaux. Il est vérifié par rapport à wit/v0/memory.wit et à l’adaptateur hôte dans crates/zeroclaw-plugins/src/wasm_memory.rs.
État de l’intégration.
WasmMemoryimplémente le traitMemorycomplet du runtime pour le worldmemory-plugin, avec contrôle par capacité et couverture par des tests unitaires. Le runtime ne le construit pas encore comme backend configurable ; l’hôte ne dispose pas encore d’un équivalent mémoire dechannel_plugin_details(). Comme pour les canaux, construisez en vous basant sur le contrat : le world WIT et la sémantique de l’adaptateur sont les éléments qui sont figés. Le world mémoire ne dispose pas encore non plus d’un export de configuration ; ne demandez donc pasconfig_readet ajoutez-le uniquement après avoir intégré dansWasmMemoryune ABI de configuration typée et un résolveur.
Le modèle de données
Un type d’enregistrement traverse la frontière dans les deux directions, memory-entry (memory.wit). Internalisez ses champs avant de concevoir le stockage, car les méthodes optionnelles sont toutes des vues sur eux :
| Champ | Signification |
|---|---|
id | Identité de ligne. |
key | Clé de recherche. Non unique : plusieurs lignes peuvent partager une clé, une par agent. Votre stockage doit utiliser (key, agent-id) comme clé, et non key. |
content | Le texte mémorisé. |
category | core (faits à long terme), daily (journaux de session), conversation (contexte), ou custom(string). |
timestamp | Heure de création RFC 3339. Les bornes de rappel par plage de temps sont inclusives. |
session-id | Portée de conversation optionnelle. |
namespace | Limite d’isolation entre les agents ou les contextes. |
score | Pertinence de récupération 0.0-1.0 ; none pour le rappel non vectoriel. |
importance | Poids de priorisation optionnel 0.0-1.0. |
superseded-by | ID de l’entrée qui a remplacé celle-ci, le cas échéant. |
agent-alias / agent-id | Nom affiché vs identifiant de stockage brut. Utilisez agent-id pour les vérifications d’égalité de portée, agent-alias pour l’affichage. |
Le composite (key, agent-id) est l’erreur de loin la plus courante. Le contrat de base de get dit explicitement : lorsque plusieurs lignes partagent une clé, une ligne correspondante arbitraire est renvoyée, et la recherche limitée à un agent passe par get-for-agent. De même, forget supprime toutes les lignes d’une clé indépendamment de l’attribution, tandis que forget-for-agent supprime exactement la ligne (key, agent-id) et laisse les sœurs intactes.
Exports requis
Douze fonctions n’ont pas de défaut et doivent fonctionner (memory.wit, required-methods section) :
| Exporter | Notes de contrat |
|---|---|
name | Nom du backend. |
| get-memory-capabilities | Masque de bits des méthodes optionnelles ; lu une seule fois au chargement. |
store-entry | Stocker (key, content, category, session-id). Nommé store-entry car store est réservé dans wit-bindgen. |
recall | Requête + limite + session optionnelle et bornes temporelles RFC 3339 (inclusives). Une requête vide ou bare-* signifie un rappel uniquement temporel : renvoyer les entrées les plus récentes. |
| get | Par clé ; ligne arbitraire en cas de collision de clé multi-agent. |
list-entries | Filtres optionnels de catégorie et de session. Nommé d’après le list réservé à wit. |
forget | Supprime toutes les lignes pour la clé ; true si des éléments ont été supprimés. |
forget-for-agent | Supprimer uniquement la ligne (key, agent-id). |
count | Total des entrées. |
health-check | Atteignabilité. |
store-with-agent / recall-for-agents | La paire consciente de l’attribution ; voir ci-dessous. |
recall-for-agents prend une variante agent-filter : all (aucun filtre d’agent) ou some(list<string>) (restreindre aux IDs d’agents listés). Le runtime mappe sa slice Rust &[&str] comme empty-slice-means-all, donc traitez some([]) comme ne correspondant à rien, pas à tout.
Indicateurs de capacités : les 11 méthodes optionnelles
Même mécanisme que les canaux : l’hôte lit get-memory-capabilities une fois, et pour chaque drapeau non défini, il utilise la valeur par défaut du trait Rust au lieu de vous appeler. Les valeurs par défaut sont documentées en ligne dans memory.wit à côté des drapeaux, et les replis côté hôte sont visibles dans wasm_memory.rs (chaque méthode conditionnée vérifie le drapeau et emprunte le chemin de repli en cas d’absence) :
| Indicateur | Hôte de secours lorsqu’il n’est pas défini |
|---|---|
get-for-agent | Host combine get + un filtre d’égalité sur agent-id |
purge-namespace, purge-session, purge-session-for-agent, purge-agent | Host retourne “non pris en charge” |
reindex | Le hôte renvoie 0 |
store-procedural | no-ops de l’hôte |
ensure-agent-uuid | Hôte renvoie l’alias inchangé |
recall-namespaced | L’hôte appelle recall et post-filtre par namespace. |
export-entries | L’hôte appelle list-entries et post-filtre |
store-with-metadata | Host délègue à store-entry, en ignorant le namespace et l’importance |
Lisez cette dernière ligne deux fois : si vous n’implémentez pas store-with-metadata, l’espace de noms et l’importance demandés par le runtime sont silencieusement abandonnés par le repli. Un backend qui stocke des données dans des espaces de noms doit implémenter store-with-metadata, recall-namespaced et purge-namespace comme un ensemble, sinon l’isolation des espaces de noms se dégrade discrètement en post-filtrage et en écritures avec pertes.
La famille purge est votre surface de suppression de données. purge-agent prend un agent-alias (pas un ID) ; export-entries existe pour la portabilité des données du RGPD art. 20 et doit renvoyer les entrées ordonnées par date de création croissante avec les embeddings exclus. Si votre backend sert de véritables données utilisateur, implémentez les flags purge et export ; “not supported” est une réponse acceptable uniquement pour les backends jetables.
Esquisse : la forme du stockage
Le pattern de composant est celui du channel (instance chaude, état thread_local), donc seule la couche de données diffère. Un backend minimal honnête est une map en mémoire, correctement indexée :
#![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 représente les lignes non attribuées.
type Table = HashMap<(String, Option<String>), Row>;
}
Chaque méthode requise est ensuite un simple parcours :
store-entryinsère à(key, None)avec l’espace de noms"default".store-with-agentinsère à(key, agent-id)avec l’espace de noms et l’importance de l’appelant.recallfiltre par sous-chaîne/rang surcontent, applique le filtre de session, applique des bornes RFC 3339 inclusives surtimestamp, trie et tronque àlimit. Traitez la requête vide/*comme plus-récent-en-premier.recall-for-agentsajoute le parcours du filtre d’agent sur le second composant de la clé.
Un backend réel peut remplacer la map par un magasin embarqué sans modifier la structure du contrat. L’adaptateur mémoire ne lie intentionnellement pas encore wasi:http, même lorsque sa portée inclut http_client; les backends distants exigent une frontière mémoire-réseau distincte, testée au niveau des composants.
Ce que l’hôte fait autour de vous
Comprendre le comportement de l’adaptateur dans wasm_memory.rs explique plusieurs limites du contrat :
- Cache chaud, rechargé à chaque appel. Même principe que les canaux : une instance unique pour toute la durée de vie du plugin, nouvelles données à chaque appel, appels sérialisés par un mutex. Votre backend ne voit jamais d’appels concurrents.
- Capacités mises en cache au chargement. L’hôte lit vos indicateurs une fois lors de
from_wasmet plus jamais par la suite. Il n’y a pas de découverte dynamique des capacités ; le redémarrage du daemon permet de les relire. - Enveloppement des traps. Chaque site d’appel enveloppe les traps avec un contexte nommé (
memory.recall-namespaced trapped, etc.). Un trap dans un appel n’entraîne pas l’arrêt du plugin, mais des traps répétés rendent le backend inutilisable ; retournezerr(string)pour les échecs attendus au lieu de paniquer. - Les mécanismes de repli de post-filtrage s’exécutent côté hôte. Lorsque votre indicateur
recall-namespacedn’est pas défini, le filtre d’espace de noms s’exécute sur l’hôte après le retour derecall. La gestion de votrelimitinteragit avec ce mécanisme : l’hôte transmet la limite de l’appelant àrecall, ce qui fait que le post-filtrage peut sous-remplir le résultat. C’est une autre raison d’implémenter nativement les variantes avec espaces de noms.
Manifeste, construction, installation
Le manifeste est le fichier nommé manifest.toml dans le répertoire du plugin. Ses champs sont la surface serde de PluginManifest dans crates/zeroclaw-plugins/src/lib.rs, qui est la source de vérité :
| Champ | Obligatoire | Signification |
|---|---|---|
name | oui | Slug canonique unique du package et composant du package de chaque clé de configuration d’instance dérivée. Il ne constitue pas lui-même une clé de configuration d’opérateur. Utilisez 1–128 caractères ASCII en minuscules ; commencez et terminez par [a-z0-9], avec uniquement [a-z0-9._-] entre les deux. La découverte rejette les noms non valides ou en double. |
version | oui | Chaîne de version, p. ex. 0.1.0. |
description | non | Description lisible affichée par zeroclaw plugin list. |
author | non | Nom de l’auteur ou de l’organisation. |
wasm_path | pour les capacités WASM | Nom du fichier du composant, relatif au répertoire du plugin. Obligatoire sauf si la seule capacité est skill. La découverte ignore le plugin si le fichier nommé n’existe pas. |
capabilities | oui, non vide | Ce qu’est le plugin : l’un de tool, channel, memory, observer, skill (PluginCapability, sérialisé en snake_case). |
permissions | non | Services de l’hôte auxquels le code peut accéder : http_client, config_read, file_read, file_write, memory_read, memory_write (PluginPermission). Seuls les deux premiers sont appliqués aujourd’hui ; les autres sont acceptés mais inactifs. Déclarer config_read nécessite config_schema, et seuls les adaptateurs d’outils/de canaux le fournissent actuellement. |
config_schema | exactement avec config_read | Rédigez un schéma JSON Draft 2020-12 pour la configuration privée de ce plugin ; il est inclus dans les octets du manifeste canonique et est donc couvert lorsque le manifeste est signé. La racine doit être un objet avec un mappage properties et additionalProperties = false. Chaque propriété de premier niveau doit avoir un type pris en charge explicite, directement ou via un pointeur JSON local : string, boolean, integer, number, array ou object. Les consommateurs d’outils et de canaux peuvent définir x-secret = true directement sur une propriété de type chaîne de premier niveau pour la retirer de la configuration publique et l’exposer via l’import d’hôte à portée limitée secrets.get. Les outils reçoivent la configuration publique sous __config et peuvent lire les secrets pendant execute. Les canaux lisent l’objet public actuel via config.get et les secrets via secrets.get pendant configure et les appels opérationnels ; ces deux imports sont indisponibles lors de l’instanciation et de la découverte des métadonnées statiques. Les marqueurs secrets imbriqués, false ou non booléens, ainsi que les propriétés secrètes qui ne sont pas de type chaîne sont rejetés. Un schéma sans config_read, ou config_read sans schéma, est rejeté. |
signature | non | Signature Ed25519 en Base64url sur les octets canoniques du manifest. Défini lors de la signature pour la distribution. |
publisher_key | non | Clé publique Ed25519 encodée en hexadécimal du signataire. |
Déclarez uniquement les permissions effectivement utilisées par le code. Une permission non déclarée est une surface d’hôte que le composant ne peut pas atteindre ; une permission déclarée de manière superflue est une surface d’attaque que vous avez sollicitée et une charge d’audit pour quiconque révise votre plugin.
Les valeurs des opérateurs restent des chaînes dans plugins.entries et sont chiffrées lors de leur persistance, avec pour clé une chaîne zpi1_… versionnée dérivée de l’identité du package détenu par l’hôte, de la capacité et de la liaison (l’installation affiche et initialise la clé d’instance complète de la liaison d’outil par défaut) : les chaînes sont stockées telles quelles, les booléens et les nombres sont représentés par du texte scalaire JSON, et les tableaux et les objets par du texte JSON. Avant l’exécution de tout code invité, l’hôte matérialise ces chaînes selon les types du schéma du package et valide l’objet complet pour les adaptateurs d’outils et de canaux. Les propriétés non secrètes de l’outil constituent __config ; un canal obtient l’objet non secret via config.get. Une propriété marquée x-secret = true est exclue des deux surfaces publiques et n’est accessible que via secrets.get("property") dans un cadre de service autorisé. Les lectures publiques et secrètes d’un canal au cours d’un même appel partagent une révision canonique unique, et l’hôte supprime cette vue matérialisée à la fin de l’appel. Un plugin de canal conforme doit résoudre les deux à chaque point d’utilisation et ne doit pas conserver les valeurs de configuration ou d’identifiants dans l’état invité conservé à chaud ; renvoyer du texte en clair à l’invité signifie que l’hôte ne peut pas imposer la non-rétention face à du code malveillant. Si config_read a été demandé mais n’a pas été effectivement accordé, l’hôte valide un objet vide ; par conséquent, un schéma comportant des propriétés obligatoires échoue en mode sécurisé au lieu de démarrer sans la configuration requise. Si l’objet vide est valide, un outil omet __config lorsqu’il est vide et les imports config/secret du canal renvoient access-denied ; les appels effectués hors d’un cadre autorisé, les échecs de résolution et l’épuisement du budget d’appels à l’hôte renvoient unavailable.
Pour un backend mémoire : capabilities contenant memory. Ne demandez pas encore config_read : l’admission nécessite un schéma, mais le monde mémoire actuel n’a aucune exportation permettant à l’hôte de fournir l’objet résultant. Ne vous appuyez pas non plus sur http_client : une autorisation seule ne peut pas étendre l’adaptateur mémoire, qui n’expose actuellement aucune interface réseau.
Installez la cible WASI Preview 2 une fois, puis générez le composant :
rustup target add wasm32-wasip2
cargo build --release --target wasm32-wasip2
Le composant se trouve à target/wasm32-wasip2/release/<crate_name>.wasm (les tirets dans le nom du crate deviennent des underscores). Renommez-le selon ce que déclare le wasm_path de votre manifeste lorsque vous assemblez le répertoire du plugin.
[!IMPORTANT] Les fichiers
.wasmet.cwasmcompilés sont des artefacts binaires, souvent de plusieurs mégaoctets chacun. Ne les versionnez pas dans un arbre source git sans Git LFS : chaque reconstruction validée en tant que blob simple gonfle l’historique du dépôt de manière permanente, et les outils degit diff/revue peinent avec eux. Traitez-les comme n’importe quelle autre sortie de compilation : ajouteztarget/et*.wasm/*.cwasmà.gitignore, et distribuez-les plutôt via un artefact de release ou une archive de registre de plugins. Si un artefact doit vraiment vivre dans l’arbre, suivez le motif avec LFS (git lfs track "*.wasm") avant le premier commit.
Si l’hôte cible est un build d’exécution uniquement (sans backend JIT compilé), il ne peut pas compiler de .wasm au chargement ; il désérialise plutôt un .cwasm précompilé. Précompilez-le avec une wasmtime CLI dont la version correspond à celle de l’hôte et fournissez le .cwasm en tant qu’artefact wasm_path. Un artefact dont la version ne correspond pas sera rejeté par le contrôle de désérialisation de wasmtime et ne sera pas chargé à tort silencieusement.
Ces commandes nécessitent un binaire dans lequel l’hôte de plugins a été compilé. Les binaires de publication précompilés fournis par le programme d’installation sont compilés sans la fonctionnalité
plugins-wasm, donczeroclaw plugin ...y est une sous-commande non reconnue et les plugins installés ne sont jamais découverts. Compilez depuis les sources avec un backend d’exécution de plugins, par ex.cargo build --release --features plugins-wasm-cranelift.
Chaque plugin réside dans son propre sous-répertoire du répertoire des plugins (par défaut ~/.zeroclaw/plugins/, résolu via plugins.plugins_dir), contenant le manifeste et le composant dont le nom correspond au wasm_path du manifeste :
~/.zeroclaw/plugins/
└── my-plugin/
├── manifest.toml
└── my-plugin.wasm
Installer à partir d’un répertoire local (cela valide la structure du manifeste et applique la politique de signature avant de copier quoi que ce soit) :
zeroclaw plugin install ./my-plugin/
Activer le système de plugins et confirmer la découverte :
zeroclaw config set plugins.enabled true
zeroclaw plugin list
zeroclaw plugin info my-plugin
zeroclaw plugin list et zeroclaw plugin info confirment qu’un package est installé et détectable, mais la découverte n’équivaut pas à l’activation. plugins.enabled = true active l’hôte de plugins ; les fonctionnalités d’outils et de compétences découvertes automatiquement sont chargées à l’exécution uniquement lorsque plugins.auto_discover = true l’est également, et cet indicateur vaut false par défaut (refus en cas d’échec) :
zeroclaw config set plugins.auto_discover true
Ainsi, plugins.enabled = true à lui seul vous fournit les canaux que vous déclarez sous [channels.plugin.<alias>], mais aucun outil ni aucune compétence de plugin : un paquet d’outils ou de compétences peut apparaître dans zeroclaw plugin list tout en n’apportant rien à l’exécution. Les liaisons de canaux explicites sont nommées par l’opérateur plutôt que découvertes automatiquement ; elles n’ont donc pas besoin de auto_discover ; cet indicateur contrôle uniquement les outils et compétences découverts automatiquement.
Un plugin absent de zeroclaw plugin list a été ignoré lors de la découverte : consultez le journal de démarrage pour l’avertissement d’ignorance (manifeste corrompu, fichier wasm_path manquant ou rejet par la politique de signature).
Suivant
- Bundles de skills : distribution de skills uniquement en Markdown via le même mécanisme d’installation.
- Distribution de plugins