1use serde::{Deserialize, Serialize};
41use uuid::Uuid;
42
43use crate::error::{Error, Result};
44use crate::model::memory::{MemoryType, Scope, SourceType};
45use crate::query::MnemoEngine;
46use crate::query::remember::RememberRequest;
47use crate::storage::MemoryFilter;
48
49pub const EXPERIENCE_PLAN_TAG: &str = "__experience_plan__";
52pub const PLAN_METADATA_KEY: &str = "experience_plan";
54pub const DEFAULT_SIMILARITY_THRESHOLD: f32 = 0.7;
56pub const DEFAULT_SUCCESS_THRESHOLD: f32 = 0.5;
59const PLAN_SCAN_LIMIT: usize = 1000;
61
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
64pub struct PlanPayload {
65 pub query: String,
67 pub signature_tokens: Vec<String>,
69 pub steps: Vec<String>,
71 pub chunk_ids: Vec<String>,
73 pub outcome_score: f32,
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct RememberPlanRequest {
80 pub query: String,
82 pub steps: Vec<String>,
84 pub chunk_ids: Vec<String>,
86 pub outcome_score: f32,
88 pub agent_id: Option<String>,
90 pub scope: Option<Scope>,
93 pub org_id: Option<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct RememberPlanResponse {
100 pub id: Option<Uuid>,
102 pub signature: String,
104 pub stored: bool,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct RecallPlanRequest {
112 pub query: String,
114 pub agent_id: Option<String>,
116 pub org_id: Option<String>,
118 pub similarity_threshold: Option<f32>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct CachedPlan {
126 pub id: Uuid,
128 pub query: String,
130 pub steps: Vec<String>,
132 pub chunk_ids: Vec<String>,
134 pub outcome_score: f32,
136 pub similarity: f32,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
142pub struct RecallPlanResponse {
143 pub plan: Option<CachedPlan>,
145 pub candidates_considered: usize,
147}
148
149pub fn signature_tokens(query: &str) -> Vec<String> {
153 let mut tokens: Vec<String> = query
154 .split(|c: char| !c.is_alphanumeric())
155 .filter(|t| t.chars().count() >= 3)
156 .map(|t| t.to_lowercase())
157 .collect();
158 tokens.sort();
159 tokens.dedup();
160 tokens
161}
162
163pub fn jaccard(a: &[String], b: &[String]) -> f32 {
166 if a.is_empty() && b.is_empty() {
167 return 0.0;
168 }
169 let mut intersection = 0usize;
170 for t in a {
171 if b.contains(t) {
172 intersection += 1;
173 }
174 }
175 let union = a.len() + b.len() - intersection;
176 if union == 0 {
177 0.0
178 } else {
179 intersection as f32 / union as f32
180 }
181}
182
183async fn plan_visible_to(
186 engine: &MnemoEngine,
187 record: &crate::model::memory::MemoryRecord,
188 agent_id: &str,
189) -> bool {
190 match record.scope {
191 Scope::Public | Scope::Global => true,
192 Scope::Private => record.agent_id == agent_id,
193 Scope::Shared => {
194 record.agent_id == agent_id
195 || engine
196 .storage
197 .check_permission(record.id, agent_id, crate::model::acl::Permission::Read)
198 .await
199 .unwrap_or(false)
200 }
201 }
202}
203
204pub async fn execute_remember_plan(
206 engine: &MnemoEngine,
207 request: RememberPlanRequest,
208) -> Result<RememberPlanResponse> {
209 if !engine.experience_memory_enabled {
210 return Err(Error::Validation(
211 "experience memory mode is disabled; enable it with MnemoEngine::with_experience_memory()".to_string(),
212 ));
213 }
214 let tokens = signature_tokens(&request.query);
215 let signature = tokens.join(" ");
216
217 let is_success = request.outcome_score >= DEFAULT_SUCCESS_THRESHOLD;
221 if !is_success {
222 return Ok(RememberPlanResponse {
223 id: None,
224 signature,
225 stored: false,
226 });
227 }
228
229 let payload = PlanPayload {
230 query: request.query.clone(),
231 signature_tokens: tokens,
232 steps: request.steps,
233 chunk_ids: request.chunk_ids,
234 outcome_score: request.outcome_score.clamp(0.0, 1.0),
235 };
236 let metadata = serde_json::json!({ PLAN_METADATA_KEY: payload });
237
238 let mut rr = RememberRequest::new(request.query);
241 rr.agent_id = request.agent_id;
242 rr.org_id = request.org_id;
243 rr.scope = Some(request.scope.unwrap_or(Scope::Private));
244 rr.memory_type = Some(MemoryType::Procedural);
245 rr.importance = Some(request.outcome_score.clamp(0.0, 1.0));
246 rr.tags = Some(vec![EXPERIENCE_PLAN_TAG.to_string()]);
247 rr.metadata = Some(metadata);
248 rr.source_type = Some(SourceType::System);
249
250 let resp = engine.remember(rr).await?;
251 Ok(RememberPlanResponse {
252 id: Some(resp.id),
253 signature,
254 stored: true,
255 })
256}
257
258pub async fn execute_recall_plan(
260 engine: &MnemoEngine,
261 request: RecallPlanRequest,
262) -> Result<RecallPlanResponse> {
263 if !engine.experience_memory_enabled {
264 return Ok(RecallPlanResponse {
265 plan: None,
266 candidates_considered: 0,
267 });
268 }
269 let agent_id = request
270 .agent_id
271 .clone()
272 .unwrap_or_else(|| engine.default_agent_id.clone());
273 let threshold = request
274 .similarity_threshold
275 .unwrap_or(DEFAULT_SIMILARITY_THRESHOLD);
276 let query_sig = signature_tokens(&request.query);
277
278 let filter = MemoryFilter {
281 agent_id: None,
282 memory_type: None,
283 scope: None,
284 tags: Some(vec![EXPERIENCE_PLAN_TAG.to_string()]),
285 min_importance: None,
286 org_id: request.org_id.clone(),
287 thread_id: None,
288 include_deleted: false,
289 };
290 let records = engine
291 .storage
292 .list_memories(&filter, PLAN_SCAN_LIMIT, 0)
293 .await?;
294
295 let mut considered = 0usize;
296 let mut best: Option<CachedPlan> = None;
297 for record in &records {
298 if record.is_deleted() || record.quarantined {
299 continue;
300 }
301 if !plan_visible_to(engine, record, &agent_id).await {
302 continue;
303 }
304 let Some(raw) = record.metadata.get(PLAN_METADATA_KEY) else {
305 continue;
306 };
307 let Ok(payload) = serde_json::from_value::<PlanPayload>(raw.clone()) else {
308 continue;
309 };
310 considered += 1;
311 let sim = jaccard(&query_sig, &payload.signature_tokens);
312 if sim >= threshold && best.as_ref().map(|b| sim > b.similarity).unwrap_or(true) {
313 best = Some(CachedPlan {
314 id: record.id,
315 query: payload.query,
316 steps: payload.steps,
317 chunk_ids: payload.chunk_ids,
318 outcome_score: payload.outcome_score,
319 similarity: sim,
320 });
321 }
322 }
323
324 Ok(RecallPlanResponse {
325 plan: best,
326 candidates_considered: considered,
327 })
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333
334 #[test]
335 fn signature_is_order_independent_and_normalized() {
336 let a = signature_tokens("How do I Reset my PASSWORD?");
337 let b = signature_tokens("password reset — how to do it");
338 assert!(a.contains(&"reset".to_string()));
340 assert!(a.contains(&"password".to_string()));
341 assert!(jaccard(&a, &b) > 0.0);
342 let mut sorted = a.clone();
344 sorted.sort();
345 sorted.dedup();
346 assert_eq!(a, sorted);
347 }
348
349 #[test]
350 fn jaccard_bounds() {
351 let a = signature_tokens("alpha bravo charlie");
352 assert_eq!(jaccard(&a, &a), 1.0);
353 let disjoint = signature_tokens("xray yankee zulu");
354 assert_eq!(jaccard(&a, &disjoint), 0.0);
355 assert_eq!(jaccard(&[], &[]), 0.0);
356 }
357}