Skip to content

Commit 07c0e7f

Browse files
authored
feat(memory): add session-scoped purge (#186)
## Summary - add `session_id` and optional `memory_types` support to `POST /v1/memories/purge` - implement indexed session purge in service/storage and expose it through MCP local/remote paths - update session-cleanup templates and add DB/API/MCP end-to-end coverage ## Testing - cargo clippy -p memoria-api -p memoria-service -p memoria-storage -p memoria-mcp -- -D warnings - DATABASE_URL=mysql://root:111@localhost:6001/memoria_test SQLX_OFFLINE=true EMBEDDING_PROVIDER=mock cargo test -p memoria-service --test service_unit test_purge_by_session_id_filters_memory_type -- --nocapture - DATABASE_URL=mysql://root:111@localhost:6001/memoria_test SQLX_OFFLINE=true EMBEDDING_PROVIDER=mock cargo test -p memoria-api --test api_db_verify test_purge_by_session_id_with_memory_types_verify_db -- --nocapture - DATABASE_URL=mysql://root:111@localhost:6001/memoria_test SQLX_OFFLINE=true EMBEDDING_PROVIDER=mock cargo test -p memoria-api --test api_e2e test_remote_purge_by_session_id -- --nocapture - DATABASE_URL=mysql://root:111@localhost:6001/memoria_test SQLX_OFFLINE=true EMBEDDING_PROVIDER=mock cargo test -p memoria-mcp --test core_tools_e2e test_purge_session_id_with_memory_types -- --nocapture Fixes #182 Approved by: @XuPeng-SH
1 parent 1f99f39 commit 07c0e7f

21 files changed

Lines changed: 678 additions & 61 deletions

memoria/crates/memoria-api/src/models.rs

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,9 +92,71 @@ pub struct CorrectByQueryRequest {
9292
pub struct PurgeRequest {
9393
pub memory_ids: Option<Vec<String>>,
9494
pub topic: Option<String>,
95+
pub session_id: Option<String>,
96+
pub memory_types: Option<Vec<String>>,
9597
pub reason: Option<String>,
9698
}
9799

100+
pub enum PurgeSelector {
101+
MemoryIds(Vec<String>),
102+
Topic(String),
103+
Session {
104+
session_id: String,
105+
memory_types: Option<Vec<MemoryType>>,
106+
},
107+
None,
108+
}
109+
110+
impl PurgeRequest {
111+
pub fn selector(&self) -> Result<PurgeSelector, String> {
112+
let memory_ids = self.memory_ids.as_ref().filter(|ids| !ids.is_empty());
113+
let memory_types = self.memory_types.as_ref().filter(|types| !types.is_empty());
114+
let topic = self
115+
.topic
116+
.as_deref()
117+
.map(str::trim)
118+
.filter(|s| !s.is_empty());
119+
let session_id = self
120+
.session_id
121+
.as_deref()
122+
.map(str::trim)
123+
.filter(|s| !s.is_empty());
124+
if memory_types.is_some() && session_id.is_none() {
125+
return Err("memory_types requires session_id".to_string());
126+
}
127+
128+
let selector_count = usize::from(memory_ids.is_some())
129+
+ usize::from(topic.is_some())
130+
+ usize::from(session_id.is_some());
131+
if selector_count > 1 {
132+
return Err("provide only one of memory_ids, topic, or session_id".to_string());
133+
}
134+
135+
if let Some(ids) = memory_ids {
136+
return Ok(PurgeSelector::MemoryIds(ids.clone()));
137+
}
138+
if let Some(topic) = topic {
139+
return Ok(PurgeSelector::Topic(topic.to_string()));
140+
}
141+
if let Some(session_id) = session_id {
142+
let memory_types = memory_types
143+
.map(|types| {
144+
types
145+
.iter()
146+
.map(|memory_type| parse_memory_type(memory_type))
147+
.collect::<Result<Vec<_>, _>>()
148+
})
149+
.transpose()?
150+
.filter(|types| !types.is_empty());
151+
return Ok(PurgeSelector::Session {
152+
session_id: session_id.to_string(),
153+
memory_types,
154+
});
155+
}
156+
Ok(PurgeSelector::None)
157+
}
158+
}
159+
98160
#[derive(Serialize)]
99161
pub struct MemoryResponse {
100162
pub memory_id: String,
@@ -231,3 +293,43 @@ pub fn parse_memory_type(s: &str) -> Result<MemoryType, String> {
231293
pub fn parse_trust_tier(s: &str) -> Result<TrustTier, String> {
232294
TrustTier::from_str(s).map_err(|e| e.to_string())
233295
}
296+
297+
#[cfg(test)]
298+
mod tests {
299+
use super::{PurgeRequest, PurgeSelector};
300+
301+
#[test]
302+
fn purge_selector_ignores_empty_arrays() {
303+
let request = PurgeRequest {
304+
memory_ids: Some(vec![]),
305+
topic: None,
306+
session_id: Some("sess-1".to_string()),
307+
memory_types: Some(vec![]),
308+
reason: None,
309+
};
310+
311+
match request.selector().unwrap() {
312+
PurgeSelector::Session {
313+
session_id,
314+
memory_types,
315+
} => {
316+
assert_eq!(session_id, "sess-1");
317+
assert!(memory_types.is_none());
318+
}
319+
_ => panic!("expected session selector"),
320+
}
321+
}
322+
323+
#[test]
324+
fn purge_selector_empty_memory_types_do_not_require_session() {
325+
let request = PurgeRequest {
326+
memory_ids: None,
327+
topic: None,
328+
session_id: None,
329+
memory_types: Some(vec![]),
330+
reason: None,
331+
};
332+
333+
assert!(matches!(request.selector().unwrap(), PurgeSelector::None));
334+
}
335+
}

memoria/crates/memoria-api/src/routes/memory.rs

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -371,38 +371,51 @@ pub async fn purge_memories(
371371
AuthUser { user_id, is_master }: AuthUser,
372372
Json(req): Json<PurgeRequest>,
373373
) -> ApiResult<PurgeResponse> {
374-
let result = if let Some(ids) = &req.memory_ids {
375-
if !is_master {
376-
for id in ids {
377-
let mem = state
378-
.service
379-
.get_for_user(&user_id, id)
380-
.await
381-
.map_err(api_err)?
382-
.ok_or_else(|| (StatusCode::NOT_FOUND, format!("Memory not found: {id}")))?;
383-
if mem.user_id != user_id {
384-
return Err((StatusCode::FORBIDDEN, format!("Not your memory: {id}")));
374+
let selector = req
375+
.selector()
376+
.map_err(|e| (StatusCode::UNPROCESSABLE_ENTITY, e))?;
377+
let result = match selector {
378+
PurgeSelector::MemoryIds(ids) => {
379+
if !is_master {
380+
for id in &ids {
381+
let mem = state
382+
.service
383+
.get_for_user(&user_id, id)
384+
.await
385+
.map_err(api_err)?
386+
.ok_or_else(|| {
387+
(StatusCode::NOT_FOUND, format!("Memory not found: {id}"))
388+
})?;
389+
if mem.user_id != user_id {
390+
return Err((StatusCode::FORBIDDEN, format!("Not your memory: {id}")));
391+
}
385392
}
386393
}
394+
let id_refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
395+
state
396+
.service
397+
.purge_batch(&user_id, &id_refs)
398+
.await
399+
.map_err(api_err)?
387400
}
388-
let id_refs: Vec<&str> = ids.iter().map(|s| s.as_str()).collect();
389-
state
401+
PurgeSelector::Topic(topic) => state
390402
.service
391-
.purge_batch(&user_id, &id_refs)
403+
.purge_by_topic(&user_id, &topic)
392404
.await
393-
.map_err(api_err)?
394-
} else if let Some(topic) = &req.topic {
395-
state
405+
.map_err(api_err)?,
406+
PurgeSelector::Session {
407+
session_id,
408+
memory_types,
409+
} => state
396410
.service
397-
.purge_by_topic(&user_id, topic)
411+
.purge_by_session_id(&user_id, &session_id, memory_types.as_deref())
398412
.await
399-
.map_err(api_err)?
400-
} else {
401-
memoria_service::PurgeResult {
413+
.map_err(api_err)?,
414+
PurgeSelector::None => memoria_service::PurgeResult {
402415
purged: 0,
403416
snapshot_name: None,
404417
warning: None,
405-
}
418+
},
406419
};
407420
Ok(Json(PurgeResponse {
408421
purged: result.purged,

memoria/crates/memoria-api/tests/api_db_verify.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -572,6 +572,80 @@ async fn test_purge_by_topic_batch_perf_verify_db() {
572572
println!("✅ purge by topic batch: 10 memories purged in one call");
573573
}
574574

575+
#[tokio::test]
576+
#[serial]
577+
async fn test_purge_by_session_id_with_memory_types_verify_db() {
578+
let (base, client, pool) = spawn_server().await;
579+
let uid = uid();
580+
let target_session = format!("session:test-smp-{}", uuid::Uuid::new_v4().simple());
581+
let other_session = format!("session:test-smp-{}", uuid::Uuid::new_v4().simple());
582+
583+
for (content, memory_type, session_id) in [
584+
("target working alpha", "working", target_session.as_str()),
585+
("target working beta", "working", target_session.as_str()),
586+
("target semantic keep", "semantic", target_session.as_str()),
587+
("other working keep", "working", other_session.as_str()),
588+
] {
589+
client
590+
.post(format!("{base}/v1/memories"))
591+
.header("X-User-Id", &uid)
592+
.json(&json!({
593+
"content": content,
594+
"memory_type": memory_type,
595+
"session_id": session_id,
596+
}))
597+
.send()
598+
.await
599+
.unwrap();
600+
}
601+
assert_eq!(db_count_active(&pool, &uid).await, 4);
602+
603+
let r = client
604+
.post(format!("{base}/v1/memories/purge"))
605+
.header("X-User-Id", &uid)
606+
.json(&json!({
607+
"session_id": target_session,
608+
"memory_types": ["working"],
609+
"reason": "session complete"
610+
}))
611+
.send()
612+
.await
613+
.unwrap();
614+
assert_eq!(r.status(), 200);
615+
let body: Value = r.json().await.unwrap();
616+
assert_eq!(body["purged"], 2);
617+
618+
let rows = sqlx::query(
619+
"SELECT content, memory_type, session_id FROM mem_memories \
620+
WHERE user_id = ? AND is_active > 0 ORDER BY content ASC",
621+
)
622+
.bind(&uid)
623+
.fetch_all(&pool)
624+
.await
625+
.unwrap();
626+
let remaining: Vec<(String, String, String)> = rows
627+
.into_iter()
628+
.map(|row| {
629+
(
630+
row.get("content"),
631+
row.get("memory_type"),
632+
row.get("session_id"),
633+
)
634+
})
635+
.collect();
636+
assert_eq!(remaining.len(), 2);
637+
assert!(remaining.iter().any(|(content, memory_type, session_id)| {
638+
content == "target semantic keep"
639+
&& memory_type == "semantic"
640+
&& session_id == &target_session
641+
}));
642+
assert!(remaining.iter().any(|(content, memory_type, session_id)| {
643+
content == "other working keep" && memory_type == "working" && session_id == &other_session
644+
}));
645+
646+
println!("✅ purge by session_id: working memories removed without touching other active rows");
647+
}
648+
575649
// ═══════════════════════════════════════════════════════════════════════════════
576650
// 4. BATCH STORE — verify all rows in DB with correct types
577651
// ═══════════════════════════════════════════════════════════════════════════════

memoria/crates/memoria-api/tests/api_e2e.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2454,6 +2454,91 @@ async fn test_remote_purge_by_topic() {
24542454
println!("✅ remote purge by topic: {t}");
24552455
}
24562456

2457+
#[tokio::test]
2458+
async fn test_remote_purge_by_session_id() {
2459+
use memoria_mcp::remote::RemoteClient;
2460+
let (base, _) = spawn_api_for_remote().await;
2461+
let uid = uid();
2462+
let remote = RemoteClient::new(&base, None, uid.clone(), None);
2463+
let target_session = format!("session:test-smp-{}", uuid::Uuid::new_v4().simple());
2464+
let other_session = format!("session:test-smp-{}", uuid::Uuid::new_v4().simple());
2465+
2466+
remote
2467+
.call(
2468+
"memory_store",
2469+
json!({"content": "remote working alpha", "memory_type": "working", "session_id": target_session}),
2470+
)
2471+
.await
2472+
.unwrap();
2473+
remote
2474+
.call(
2475+
"memory_store",
2476+
json!({"content": "remote working beta", "memory_type": "working", "session_id": target_session}),
2477+
)
2478+
.await
2479+
.unwrap();
2480+
remote
2481+
.call(
2482+
"memory_store",
2483+
json!({"content": "remote semantic keep", "memory_type": "semantic", "session_id": target_session}),
2484+
)
2485+
.await
2486+
.unwrap();
2487+
remote
2488+
.call(
2489+
"memory_store",
2490+
json!({"content": "remote other keep", "memory_type": "working", "session_id": other_session}),
2491+
)
2492+
.await
2493+
.unwrap();
2494+
2495+
let r = remote
2496+
.call(
2497+
"memory_purge",
2498+
json!({"session_id": target_session, "memory_types": ["working"]}),
2499+
)
2500+
.await
2501+
.unwrap();
2502+
let t = r["content"][0]["text"].as_str().unwrap_or("");
2503+
assert!(t.contains("Purged 2"), "got: {t}");
2504+
2505+
let list = remote
2506+
.call("memory_list", json!({"limit": 10}))
2507+
.await
2508+
.unwrap();
2509+
let list_text = list["content"][0]["text"].as_str().unwrap_or("");
2510+
assert!(
2511+
list_text.contains("remote semantic keep"),
2512+
"got: {list_text}"
2513+
);
2514+
assert!(list_text.contains("remote other keep"), "got: {list_text}");
2515+
assert!(
2516+
!list_text.contains("remote working alpha"),
2517+
"got: {list_text}"
2518+
);
2519+
assert!(
2520+
!list_text.contains("remote working beta"),
2521+
"got: {list_text}"
2522+
);
2523+
println!("✅ remote purge by session_id: {t}");
2524+
}
2525+
2526+
#[tokio::test]
2527+
async fn test_remote_purge_rejects_invalid_memory_types_locally() {
2528+
use memoria_mcp::remote::RemoteClient;
2529+
let remote = RemoteClient::new("http://127.0.0.1:9", None, uid(), None);
2530+
2531+
let err = remote
2532+
.call(
2533+
"memory_purge",
2534+
json!({"session_id": "sess-target", "memory_types": ["not_a_real_type"]}),
2535+
)
2536+
.await
2537+
.unwrap_err();
2538+
assert!(err.to_string().contains("Invalid memory type"), "{err}");
2539+
println!("✅ remote purge rejects invalid memory_types before API call");
2540+
}
2541+
24572542
// ── Episodic memory tests ─────────────────────────────────────────────────────
24582543

24592544
#[tokio::test]

memoria/crates/memoria-cli/templates/claude_rule.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Do NOT call `memory_store` for:
4040
`working` memories are session-scoped temporary context. They **persist and will be retrieved in future sessions** unless explicitly cleaned up.
4141

4242
**When to purge working memories:**
43-
- Task or debug session is complete → `memory_purge(topic="<task keyword>", reason="task complete")`
43+
- Task or debug session is complete → `memory_purge(session_id="<session_id>", memory_types=["working"], reason="task complete")`
4444
- You stored a working memory that turned out to be wrong → `memory_purge(memory_id="...", reason="incorrect conclusion")`
4545
- User says "start fresh", "forget what we tried", "let's try a different approach"
4646
- Only purge completed tasks — leave active task working memories for next session

memoria/crates/memoria-cli/templates/claude_session_lifecycle.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ When the conversation is winding down (user says thanks, goodbye, or stops engag
3939
### 1. Clean up working memories
4040

4141
```
42-
memory_purge(topic="<task keyword>", reason="session complete")
42+
memory_purge(session_id="<session_id>", memory_types=["working"], reason="session complete")
4343
```
4444

4545
Only purge working memories for tasks that are actually done. Leave active task working memories for next session.

memoria/crates/memoria-cli/templates/codex_agents.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Do NOT call `memory_store` for:
4444
`working` memories are session-scoped temporary context. They **persist and will be retrieved in future sessions** unless explicitly cleaned up.
4545

4646
**When to purge working memories:**
47-
- Task or debug session is complete → `memory_purge(topic="<task keyword>", reason="task complete")`
47+
- Task or debug session is complete → `memory_purge(session_id="<session_id>", memory_types=["working"], reason="task complete")`
4848
- You stored a working memory that turned out to be wrong → `memory_purge(memory_id="...", reason="incorrect conclusion")`
4949
- User says "start fresh", "forget what we tried", "let's try a different approach"
5050
- Only purge completed tasks — leave active task working memories for next session
@@ -192,7 +192,7 @@ When the conversation is winding down (user says thanks, goodbye, or stops engag
192192
### 1. Clean up working memories
193193

194194
```
195-
memory_purge(topic="<task keyword>", reason="session complete")
195+
memory_purge(session_id="<session_id>", memory_types=["working"], reason="session complete")
196196
```
197197

198198
Only purge working memories for tasks that are actually done. Leave active task working memories for next session.

0 commit comments

Comments
 (0)