From 8e574cf9cc7d082999e3cbe5c86a29147629808f Mon Sep 17 00:00:00 2001 From: DocTator Date: Mon, 18 May 2026 19:18:36 -0400 Subject: [PATCH] feat(api): expose memory_type on create and update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The POST /v1/memories handler hardcoded MemoryType::Pinned as the default for direct content creation, and PUT /v1/memories/{id} had no way to change the type at all. As a result every API-created memory ended up pinned, and there was no recovery path short of delete-and- recreate. This commit: - Adds memory_type: Option to CreateMemoryBody. When provided, the value is parsed via MemoryType::FromStr (returns Validation/400 on unknown variants). When absent the existing default of Pinned is preserved so this is not a breaking change. - Adds memory_type: Option to UpdateMemoryBody with the same parse-and-validate flow. Tests: - test_create_memory_with_type: POST with memory_type=insight returns insight; POST without it still returns pinned (default-preserved). - test_update_memory_type: PUT memory_type=insight on an existing pinned memory demotes it. - test_update_memory_type_invalid: PUT with an unknown type returns 400 Bad Request rather than silently accepting. Note for reviewers: the create-side default of Pinned is preserved here intentionally to avoid a behavior change in a single PR. Whether Insight would be a better default is worth a separate design discussion — happy to open a follow-up issue if the maintainer wants to push on it. Co-Authored-By: Claude Opus 4.7 --- omem-server/src/api/handlers/memory.rs | 15 ++- omem-server/src/api/mod.rs | 139 +++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) diff --git a/omem-server/src/api/handlers/memory.rs b/omem-server/src/api/handlers/memory.rs index 04b2d19..27f8e00 100644 --- a/omem-server/src/api/handlers/memory.rs +++ b/omem-server/src/api/handlers/memory.rs @@ -37,6 +37,7 @@ pub struct CreateMemoryBody { #[serde(default)] pub tags: Option>, pub source: Option, + pub memory_type: Option, } #[derive(Deserialize)] @@ -95,6 +96,7 @@ pub struct UpdateMemoryBody { pub content: Option, pub tags: Option>, pub state: Option, + pub memory_type: Option, } #[derive(Serialize)] @@ -201,10 +203,15 @@ pub async fn create_memory( return Err(OmemError::Validation("content cannot be empty".to_string())); } + let memory_type = match body.memory_type { + Some(s) => s.parse().map_err(OmemError::Validation)?, + None => MemoryType::Pinned, + }; + let mut memory = Memory::new( &content, Category::Preferences, - MemoryType::Pinned, + memory_type, &auth.tenant_id, ); memory.tags = body.tags.unwrap_or_default(); @@ -525,6 +532,12 @@ pub async fn update_memory( .map_err(|e: String| OmemError::Validation(e))?; } + if let Some(memory_type_str) = body.memory_type { + memory.memory_type = memory_type_str + .parse() + .map_err(|e: String| OmemError::Validation(e))?; + } + memory.updated_at = chrono::Utc::now().to_rfc3339(); let vector = if need_reembed { diff --git a/omem-server/src/api/mod.rs b/omem-server/src/api/mod.rs index 0898975..797b993 100644 --- a/omem-server/src/api/mod.rs +++ b/omem-server/src/api/mod.rs @@ -483,6 +483,145 @@ mod tests { assert_eq!(json["tags"][0], "new-tag"); } + #[tokio::test] + async fn test_create_memory_with_type() { + let (app, _dir) = setup_app().await; + let api_key = create_test_tenant(&app).await; + + // Create with explicit memory_type=insight. + let create_resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/memories") + .header("content-type", "application/json") + .header("x-api-key", &api_key) + .body(Body::from(r#"{"content":"an insight","memory_type":"insight"}"#)) + .expect("request"), + ) + .await + .expect("response"); + let bytes = create_resp.into_body().collect().await.expect("body").to_bytes(); + let created: serde_json::Value = serde_json::from_slice(&bytes).expect("json"); + assert_eq!(created["memory_type"], "insight"); + + // Default (no memory_type) still becomes pinned for backwards compat. + let default_resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/memories") + .header("content-type", "application/json") + .header("x-api-key", &api_key) + .body(Body::from(r#"{"content":"default"}"#)) + .expect("request"), + ) + .await + .expect("response"); + let bytes = default_resp.into_body().collect().await.expect("body").to_bytes(); + let default_created: serde_json::Value = serde_json::from_slice(&bytes).expect("json"); + assert_eq!(default_created["memory_type"], "pinned"); + } + + #[tokio::test] + async fn test_update_memory_type() { + let (app, _dir) = setup_app().await; + let api_key = create_test_tenant(&app).await; + + // Create a pinned memory (default). + let create_resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/memories") + .header("content-type", "application/json") + .header("x-api-key", &api_key) + .body(Body::from(r#"{"content":"originally pinned"}"#)) + .expect("request"), + ) + .await + .expect("response"); + + let bytes = create_resp + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let created: serde_json::Value = serde_json::from_slice(&bytes).expect("json"); + let memory_id = created["id"].as_str().expect("id"); + assert_eq!(created["memory_type"], "pinned"); + + // Demote to insight. + let update_resp = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/v1/memories/{memory_id}")) + .header("content-type", "application/json") + .header("x-api-key", &api_key) + .body(Body::from(r#"{"memory_type":"insight"}"#)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(update_resp.status(), StatusCode::OK); + + let bytes = update_resp + .into_body() + .collect() + .await + .expect("body") + .to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).expect("json"); + assert_eq!(json["memory_type"], "insight"); + } + + #[tokio::test] + async fn test_update_memory_type_invalid() { + let (app, _dir) = setup_app().await; + let api_key = create_test_tenant(&app).await; + + let create_resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/memories") + .header("content-type", "application/json") + .header("x-api-key", &api_key) + .body(Body::from(r#"{"content":"test"}"#)) + .expect("request"), + ) + .await + .expect("response"); + let bytes = create_resp.into_body().collect().await.expect("body").to_bytes(); + let created: serde_json::Value = serde_json::from_slice(&bytes).expect("json"); + let memory_id = created["id"].as_str().expect("id"); + + // Unknown type should fail validation, not silently accept. + let update_resp = app + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/v1/memories/{memory_id}")) + .header("content-type", "application/json") + .header("x-api-key", &api_key) + .body(Body::from(r#"{"memory_type":"bogus"}"#)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(update_resp.status(), StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn test_search_memories() { let (app, _dir) = setup_app().await;