Skip to main content

mnemo_amp/
wire.rs

1//! AMP / memorywire wire format.
2//!
3//! AMP ("Agent Memory Protocol" / *memorywire*) models an agent's
4//! memory surface as **5 operations** — `remember` / `recall` /
5//! `forget` / `merge` / `expire` — over **4 memory types** —
6//! `episodic` / `semantic` / `procedural` / `working`. The wire shape
7//! is a single self-describing JSON envelope validated against a
8//! JSON-Schema 2020-12 document (see [`schema`]).
9//!
10//! This module is transport-agnostic: it only defines the request
11//! ([`AmpEnvelope`]) and response ([`AmpResult`]) shapes plus the
12//! schema. The mapping onto a real [`MnemoEngine`](mnemo_core::query::MnemoEngine)
13//! lives in [`crate::store`].
14
15use serde::{Deserialize, Serialize};
16
17/// The five AMP operations. 1:1 with the cross-adapter conformance
18/// suite's op axis.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum AmpOp {
22    Remember,
23    Recall,
24    Forget,
25    Merge,
26    Expire,
27}
28
29impl AmpOp {
30    pub fn as_str(self) -> &'static str {
31        match self {
32            AmpOp::Remember => "remember",
33            AmpOp::Recall => "recall",
34            AmpOp::Forget => "forget",
35            AmpOp::Merge => "merge",
36            AmpOp::Expire => "expire",
37        }
38    }
39
40    /// All five ops, in canonical order.
41    pub const ALL: [AmpOp; 5] = [
42        AmpOp::Remember,
43        AmpOp::Recall,
44        AmpOp::Forget,
45        AmpOp::Merge,
46        AmpOp::Expire,
47    ];
48}
49
50/// The four AMP memory types. Map 1:1 onto
51/// [`mnemo_core::model::memory::MemoryType`].
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum AmpMemoryType {
55    Episodic,
56    Semantic,
57    Procedural,
58    Working,
59}
60
61impl AmpMemoryType {
62    pub fn as_str(self) -> &'static str {
63        match self {
64            AmpMemoryType::Episodic => "episodic",
65            AmpMemoryType::Semantic => "semantic",
66            AmpMemoryType::Procedural => "procedural",
67            AmpMemoryType::Working => "working",
68        }
69    }
70
71    /// `true` for the long-term tiers (`semantic` / `procedural`) that
72    /// the HITL diff-and-approve hook gates by default. Episodic and
73    /// working memories are short-lived and bypass approval.
74    pub fn is_long_term(self) -> bool {
75        matches!(self, AmpMemoryType::Semantic | AmpMemoryType::Procedural)
76    }
77
78    /// All four memory types, in canonical order.
79    pub const ALL: [AmpMemoryType; 4] = [
80        AmpMemoryType::Episodic,
81        AmpMemoryType::Semantic,
82        AmpMemoryType::Procedural,
83        AmpMemoryType::Working,
84    ];
85}
86
87/// A single AMP request envelope.
88///
89/// Every field except `op` and `memory_type` is optional; which
90/// fields are *meaningful* depends on `op` (e.g. `query` for
91/// `recall`, `memory_ids` for `forget` / `merge` / `expire`,
92/// `content` for `remember`). [`crate::store`] enforces the
93/// per-op requirements and returns a typed error on a malformed
94/// envelope rather than panicking.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct AmpEnvelope {
97    /// AMP protocol version. Currently `"amp/1"`.
98    #[serde(default = "default_amp_version")]
99    pub amp_version: String,
100    pub op: AmpOp,
101    pub memory_type: AmpMemoryType,
102    /// Agent scope. Falls back to the engine default when omitted.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub agent_id: Option<String>,
105    /// `remember` payload.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub content: Option<String>,
108    /// `recall` query string.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub query: Option<String>,
111    /// Target memory ids for `forget` / `merge` / `expire`.
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub memory_ids: Vec<String>,
114    /// `recall` top-k. Defaults to 5 (the conformance suite's
115    /// recall@5).
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub top_k: Option<usize>,
118    /// Free-form tags attached on `remember` / used to scope `recall`.
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub tags: Vec<String>,
121    /// `expire`: seconds from now after which the memory expires. When
122    /// omitted (or `0`), `expire` takes effect immediately.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub ttl_seconds: Option<u64>,
125    /// Optional opaque metadata round-tripped onto the stored record.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub metadata: Option<serde_json::Value>,
128}
129
130fn default_amp_version() -> String {
131    "amp/1".to_string()
132}
133
134impl AmpEnvelope {
135    /// Construct a minimal envelope for `op` over `memory_type`.
136    pub fn new(op: AmpOp, memory_type: AmpMemoryType) -> Self {
137        Self {
138            amp_version: default_amp_version(),
139            op,
140            memory_type,
141            agent_id: None,
142            content: None,
143            query: None,
144            memory_ids: Vec::new(),
145            top_k: None,
146            tags: Vec::new(),
147            ttl_seconds: None,
148            metadata: None,
149        }
150    }
151}
152
153/// One recalled item in an [`AmpResult`].
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
155pub struct AmpHit {
156    pub id: String,
157    pub content: String,
158    pub memory_type: AmpMemoryType,
159    pub score: f32,
160    pub tags: Vec<String>,
161}
162
163/// The response envelope returned by every AMP op.
164#[derive(Debug, Clone, Serialize, Deserialize)]
165pub struct AmpResult {
166    pub op: AmpOp,
167    pub ok: bool,
168    /// Ids written / affected (`remember` → \[new id\]; `merge` → \[merged
169    /// id\]; `forget` / `expire` → affected ids).
170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171    pub ids: Vec<String>,
172    /// `recall` hits, highest score first.
173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
174    pub hits: Vec<AmpHit>,
175    /// `true` when a HITL approval hook gated this write and approved
176    /// it; `false`/absent when no approval was required.
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub approved: Option<bool>,
179    /// Human-readable diagnostic; empty on success.
180    #[serde(default, skip_serializing_if = "String::is_empty")]
181    pub detail: String,
182}
183
184impl AmpResult {
185    pub fn ok(op: AmpOp) -> Self {
186        Self {
187            op,
188            ok: true,
189            ids: Vec::new(),
190            hits: Vec::new(),
191            approved: None,
192            detail: String::new(),
193        }
194    }
195
196    pub fn rejected(op: AmpOp, detail: impl Into<String>) -> Self {
197        Self {
198            op,
199            ok: false,
200            ids: Vec::new(),
201            hits: Vec::new(),
202            approved: Some(false),
203            detail: detail.into(),
204        }
205    }
206}
207
208/// The AMP envelope JSON-Schema 2020-12 document.
209///
210/// Returned as a `serde_json::Value` so callers can serve it from a
211/// `.well-known/amp-schema.json` endpoint, validate inbound envelopes,
212/// or diff it against another adapter's schema in the cross-adapter
213/// conformance suite. The `enum` lists pin the 5-op × 4-type surface.
214pub fn schema() -> serde_json::Value {
215    serde_json::json!({
216        "$schema": "https://json-schema.org/draft/2020-12/schema",
217        "$id": "https://mnemo.dev/schemas/amp/1/envelope.json",
218        "title": "AMP memory envelope",
219        "description": "AMP / memorywire request envelope: 5 operations over 4 memory types.",
220        "type": "object",
221        "required": ["op", "memory_type"],
222        "additionalProperties": false,
223        "properties": {
224            "amp_version": { "type": "string", "default": "amp/1" },
225            "op": {
226                "type": "string",
227                "enum": ["remember", "recall", "forget", "merge", "expire"]
228            },
229            "memory_type": {
230                "type": "string",
231                "enum": ["episodic", "semantic", "procedural", "working"]
232            },
233            "agent_id": { "type": ["string", "null"] },
234            "content": { "type": ["string", "null"] },
235            "query": { "type": ["string", "null"] },
236            "memory_ids": {
237                "type": "array",
238                "items": { "type": "string", "format": "uuid" }
239            },
240            "top_k": { "type": ["integer", "null"], "minimum": 1 },
241            "tags": { "type": "array", "items": { "type": "string" } },
242            "ttl_seconds": { "type": ["integer", "null"], "minimum": 0 },
243            "metadata": { "type": ["object", "null"] }
244        },
245        "allOf": [
246            {
247                "if": { "properties": { "op": { "const": "remember" } } },
248                "then": { "required": ["content"] }
249            },
250            {
251                "if": { "properties": { "op": { "const": "recall" } } },
252                "then": { "required": ["query"] }
253            },
254            {
255                "if": { "properties": { "op": { "const": "forget" } } },
256                "then": { "required": ["memory_ids"] }
257            },
258            {
259                "if": { "properties": { "op": { "const": "merge" } } },
260                "then": { "required": ["memory_ids"] }
261            },
262            {
263                "if": { "properties": { "op": { "const": "expire" } } },
264                "then": { "required": ["memory_ids"] }
265            }
266        ]
267    })
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn op_and_type_axes_are_complete() {
276        assert_eq!(AmpOp::ALL.len(), 5);
277        assert_eq!(AmpMemoryType::ALL.len(), 4);
278        // 5 ops × 4 types = the 20-cell AMP surface.
279        assert_eq!(AmpOp::ALL.len() * AmpMemoryType::ALL.len(), 20);
280    }
281
282    #[test]
283    fn envelope_round_trips_through_json() {
284        let mut env = AmpEnvelope::new(AmpOp::Remember, AmpMemoryType::Semantic);
285        env.content = Some("the capital of France is Paris".into());
286        env.tags = vec!["geo".into()];
287        let s = serde_json::to_string(&env).unwrap();
288        let back: AmpEnvelope = serde_json::from_str(&s).unwrap();
289        assert_eq!(back.op, AmpOp::Remember);
290        assert_eq!(back.memory_type, AmpMemoryType::Semantic);
291        assert_eq!(
292            back.content.as_deref(),
293            Some("the capital of France is Paris")
294        );
295        assert_eq!(back.amp_version, "amp/1");
296    }
297
298    #[test]
299    fn schema_is_2020_12_and_pins_the_surface() {
300        let s = schema();
301        assert_eq!(s["$schema"], "https://json-schema.org/draft/2020-12/schema");
302        let ops = s["properties"]["op"]["enum"].as_array().unwrap();
303        assert_eq!(ops.len(), 5);
304        let types = s["properties"]["memory_type"]["enum"].as_array().unwrap();
305        assert_eq!(types.len(), 4);
306    }
307
308    #[test]
309    fn long_term_classification() {
310        assert!(AmpMemoryType::Semantic.is_long_term());
311        assert!(AmpMemoryType::Procedural.is_long_term());
312        assert!(!AmpMemoryType::Episodic.is_long_term());
313        assert!(!AmpMemoryType::Working.is_long_term());
314    }
315}