From 4027f50c9e1d33e168df0250282126521878911a Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Fri, 3 Apr 2026 17:23:23 +0530 Subject: [PATCH] feat(memory): add bulk namespace clear operation (#205) (#301) * fix: patch whisper-rs-sys for Windows MSVC static CRT (/MT) The upstream whisper-rs-sys builds whisper.cpp via CMake which defaults to /MD (dynamic CRT), but Rust and all other C deps use /MT (static CRT). This causes LNK2038/LNK1169 linker errors on Windows. Patch whisper-rs-sys from tinyhumansai/whisper-rs-sys fork which adds config.static_crt(true) and overrides all per-config CMake flags (Debug/Release/MinSizeRel/RelWithDebInfo) from /MD to /MT. Closes #273 * feat: add bulk namespace clear operation for memory Add memory_clear_namespace RPC that atomically deletes all documents, vector chunks, KV entries, and graph relations in a namespace. Includes Tauri command wrapper and three tests verifying cleanup, no-op on empty namespaces, and cross-namespace safety. Closes #205 Co-Authored-By: Claude Opus 4.6 (1M context) * feat(memory): add Clear Namespace UI to MemoryDebugPanel (#205) Adds namespace selector, confirmation dialog, success/error feedback, and auto-refresh after clearing. Co-Authored-By: Claude Opus 4.6 (1M context) * style: fix prettier and cargo fmt formatting Co-Authored-By: Claude Opus 4.6 (1M context) * fix: unwrap RpcOutcome envelope in memoryClearNamespace callCoreRpc returns { result: T }, but memoryClearNamespace was returning the raw envelope instead of unwrapping .result like other callers do. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: sanil jain Co-authored-by: Claude Opus 4.6 (1M context) --- .../settings/panels/MemoryDebugPanel.tsx | 98 ++++++ app/src/utils/tauriCommands.ts | 13 + src/openhuman/memory/ops.rs | 30 ++ src/openhuman/memory/schemas.rs | 45 ++- src/openhuman/memory/store/client.rs | 4 + .../memory/store/unified/documents.rs | 331 ++++++++++++++++++ 6 files changed, 519 insertions(+), 2 deletions(-) diff --git a/app/src/components/settings/panels/MemoryDebugPanel.tsx b/app/src/components/settings/panels/MemoryDebugPanel.tsx index 0a2fc7f97..c720e5834 100644 --- a/app/src/components/settings/panels/MemoryDebugPanel.tsx +++ b/app/src/components/settings/panels/MemoryDebugPanel.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { + memoryClearNamespace, type MemoryDebugDocument, memoryDeleteDocument, memoryListDocuments, @@ -36,6 +37,11 @@ const MemoryDebugPanel = () => { const [queryLoading, setQueryLoading] = useState(false); const [recallLoading, setRecallLoading] = useState(false); + const [clearNamespaceInput, setClearNamespaceInput] = useState(''); + const [clearLoading, setClearLoading] = useState(false); + const [clearSuccess, setClearSuccess] = useState(null); + const [clearError, setClearError] = useState(null); + const maxChunks = useMemo(() => { const parsed = Number(maxChunksInput); if (!Number.isFinite(parsed) || parsed <= 0) return 10; @@ -136,6 +142,33 @@ const MemoryDebugPanel = () => { } }, [maxChunks, namespaceInput]); + const handleClearNamespace = useCallback(async () => { + const ns = clearNamespaceInput.trim(); + if (!ns) return; + + const confirmed = window.confirm( + `This will permanently delete ALL documents in namespace "${ns}". This action cannot be undone.\n\nContinue?` + ); + if (!confirmed) return; + + setClearLoading(true); + setClearError(null); + setClearSuccess(null); + try { + const result = await memoryClearNamespace(ns); + if (result.cleared) { + setClearSuccess(`Namespace "${result.namespace}" cleared successfully.`); + } else { + setClearSuccess(`Clear request completed for "${result.namespace}" (nothing to clear).`); + } + await refreshAll(); + } catch (error) { + setClearError(error instanceof Error ? error.message : String(error)); + } finally { + setClearLoading(false); + } + }, [clearNamespaceInput, refreshAll]); + return (
@@ -240,6 +273,71 @@ const MemoryDebugPanel = () => {
+ + + + }> +
+

+ Delete all documents within a namespace. This is a destructive operation and cannot be + undone. +

+ + + + void handleClearNamespace()} + loading={clearLoading} + disabled={!clearNamespaceInput.trim()} + className="border-coral-500/50 text-coral-300 hover:bg-coral-500/10"> + Clear Namespace + + + {clearSuccess && ( +
+ {clearSuccess} +
+ )} + {clearError && ( +
+ {clearError} +
+ )} +
+
+ { + if (!isTauri()) { + throw new Error('Not running in Tauri'); + } + const response = await callCoreRpc<{ result: { cleared: boolean; namespace: string } }>({ + method: 'openhuman.memory_clear_namespace', + params: { namespace }, + }); + return response.result; +} + export async function memoryQueryNamespace( namespace: string, query: string, diff --git a/src/openhuman/memory/ops.rs b/src/openhuman/memory/ops.rs index d8735c9d4..9768a0949 100644 --- a/src/openhuman/memory/ops.rs +++ b/src/openhuman/memory/ops.rs @@ -503,6 +503,17 @@ pub struct NamespaceOnlyParams { pub namespace: String, } +#[derive(Debug, Deserialize)] +pub struct ClearNamespaceParams { + pub namespace: String, +} + +#[derive(Debug, Serialize)] +pub struct ClearNamespaceResult { + pub cleared: bool, + pub namespace: String, +} + #[derive(Debug, Deserialize)] pub struct DeleteDocParams { pub namespace: String, @@ -656,6 +667,25 @@ pub async fn doc_delete(params: DeleteDocParams) -> Result Result, String> { + let client = active_memory_client().await?; + log::debug!( + "[memory] clear_namespace RPC: namespace={}", + params.namespace + ); + client.clear_namespace(¶ms.namespace).await?; + let msg = format!("memory namespace '{}' cleared", params.namespace); + Ok(RpcOutcome::single_log( + ClearNamespaceResult { + cleared: true, + namespace: params.namespace, + }, + &msg, + )) +} + pub async fn context_query(params: QueryNamespaceParams) -> Result, String> { let client = active_memory_client().await?; let result = client diff --git a/src/openhuman/memory/schemas.rs b/src/openhuman/memory/schemas.rs index 77dc7475b..b37b9d052 100644 --- a/src/openhuman/memory/schemas.rs +++ b/src/openhuman/memory/schemas.rs @@ -4,8 +4,9 @@ use serde_json::{Map, Value}; use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use crate::openhuman::memory::rpc::{ - self, DeleteDocParams, GraphQueryParams, GraphUpsertParams, IngestDocParams, KvGetDeleteParams, - KvSetParams, NamespaceOnlyParams, PutDocParams, QueryNamespaceParams, RecallNamespaceParams, + self, ClearNamespaceParams, DeleteDocParams, GraphQueryParams, GraphUpsertParams, + IngestDocParams, KvGetDeleteParams, KvSetParams, NamespaceOnlyParams, PutDocParams, + QueryNamespaceParams, RecallNamespaceParams, }; use crate::openhuman::memory::{ DeleteDocumentRequest, EmptyRequest, ListDocumentsRequest, ListMemoryFilesRequest, @@ -43,6 +44,7 @@ pub fn all_controller_schemas() -> Vec { schemas("kv_list_namespace"), schemas("graph_upsert"), schemas("graph_query"), + schemas("clear_namespace"), ] } @@ -140,6 +142,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("graph_query"), handler: handle_graph_query, }, + RegisteredController { + schema: schemas("clear_namespace"), + handler: handle_clear_namespace, + }, ] } @@ -872,6 +878,34 @@ pub fn schemas(function: &str) -> ControllerSchema { }], }, + // ----- bulk operations ----- + "clear_namespace" => ControllerSchema { + namespace: "memory", + function: "clear_namespace", + description: + "Delete all documents, vector chunks, KV entries, and graph relations for a namespace.", + inputs: vec![FieldSchema { + name: "namespace", + ty: TypeSchema::String, + comment: "Namespace to clear completely.", + required: true, + }], + outputs: vec![ + FieldSchema { + name: "cleared", + ty: TypeSchema::Bool, + comment: "True when the namespace was cleared.", + required: true, + }, + FieldSchema { + name: "namespace", + ty: TypeSchema::String, + comment: "The namespace that was cleared.", + required: true, + }, + ], + }, + // ----- fallback ----- _other => ControllerSchema { namespace: "memory", @@ -1063,6 +1097,13 @@ fn handle_graph_query(params: Map) -> ControllerFuture { }) } +fn handle_clear_namespace(params: Map) -> ControllerFuture { + Box::pin(async move { + let payload = parse_params::(params)?; + to_json(rpc::clear_namespace(payload).await?) + }) +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/src/openhuman/memory/store/client.rs b/src/openhuman/memory/store/client.rs index b1ca4c5a2..941b6c379 100644 --- a/src/openhuman/memory/store/client.rs +++ b/src/openhuman/memory/store/client.rs @@ -105,6 +105,10 @@ impl MemoryClient { self.inner.delete_document(namespace, document_id).await } + pub async fn clear_namespace(&self, namespace: &str) -> Result<(), String> { + self.inner.clear_namespace(namespace).await + } + pub async fn clear_skill_memory( &self, skill_id: &str, diff --git a/src/openhuman/memory/store/unified/documents.rs b/src/openhuman/memory/store/unified/documents.rs index 4211c7402..900730201 100644 --- a/src/openhuman/memory/store/unified/documents.rs +++ b/src/openhuman/memory/store/unified/documents.rs @@ -272,6 +272,76 @@ impl UnifiedMemory { Ok(out.into_iter().collect()) } + /// Delete all documents, vector chunks, KV entries, and graph relations + /// for the given namespace in a single transaction. Also removes the + /// on-disk markdown directory (`namespaces/{ns}/docs/`). + pub async fn clear_namespace(&self, namespace: &str) -> Result<(), String> { + let ns = Self::sanitize_namespace(namespace); + log::debug!("[memory] clear_namespace: starting for namespace={ns}"); + + { + let conn = self.conn.lock(); + let tx = conn + .unchecked_transaction() + .map_err(|e| format!("clear_namespace begin tx: {e}"))?; + + let doc_count = tx + .execute( + "DELETE FROM memory_docs WHERE namespace = ?1", + rusqlite::params![ns], + ) + .map_err(|e| format!("clear_namespace delete memory_docs: {e}"))?; + log::debug!("[memory] clear_namespace: deleted {doc_count} rows from memory_docs"); + + let chunk_count = tx + .execute( + "DELETE FROM vector_chunks WHERE namespace = ?1", + rusqlite::params![ns], + ) + .map_err(|e| format!("clear_namespace delete vector_chunks: {e}"))?; + log::debug!("[memory] clear_namespace: deleted {chunk_count} rows from vector_chunks"); + + let kv_count = tx + .execute( + "DELETE FROM kv_namespace WHERE namespace = ?1", + rusqlite::params![ns], + ) + .map_err(|e| format!("clear_namespace delete kv_namespace: {e}"))?; + log::debug!("[memory] clear_namespace: deleted {kv_count} rows from kv_namespace"); + + let graph_count = tx + .execute( + "DELETE FROM graph_namespace WHERE namespace = ?1", + rusqlite::params![ns], + ) + .map_err(|e| format!("clear_namespace delete graph_namespace: {e}"))?; + log::debug!( + "[memory] clear_namespace: deleted {graph_count} rows from graph_namespace" + ); + + tx.commit() + .map_err(|e| format!("clear_namespace commit tx: {e}"))?; + } + + // Remove on-disk markdown files for this namespace. + let docs_dir = self.namespace_dir(&ns).join("docs"); + if docs_dir.exists() { + std::fs::remove_dir_all(&docs_dir).map_err(|e| { + format!( + "clear_namespace remove docs dir {}: {e}", + docs_dir.display() + ) + })?; + log::debug!( + "[memory] clear_namespace: removed docs directory {}", + docs_dir.display() + ); + } + + log::debug!("[memory] clear_namespace: completed for namespace={ns}"); + Ok(()) + } + pub async fn delete_document( &self, namespace: &str, @@ -316,3 +386,264 @@ impl UnifiedMemory { Ok(json!({"deleted": deleted, "namespace": ns, "documentId": document_id })) } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use serde_json::json; + use tempfile::TempDir; + + use crate::openhuman::memory::{ + embeddings::NoopEmbedding, NamespaceDocumentInput, UnifiedMemory, + }; + + fn make_doc_input( + namespace: &str, + key: &str, + title: &str, + content: &str, + ) -> NamespaceDocumentInput { + NamespaceDocumentInput { + namespace: namespace.to_string(), + key: key.to_string(), + title: title.to_string(), + content: content.to_string(), + source_type: "doc".to_string(), + priority: "medium".to_string(), + tags: vec![], + metadata: json!({}), + category: "core".to_string(), + session_id: None, + document_id: None, + } + } + + #[tokio::test] + async fn clear_namespace_removes_all_data_and_preserves_other_namespaces() { + let tmp = TempDir::new().unwrap(); + let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + + // --- Populate "test:cleanup" namespace --- + + // 3 documents + memory + .upsert_document(make_doc_input( + "test:cleanup", + "doc-a", + "Document A", + "Content of document A for cleanup.", + )) + .await + .unwrap(); + memory + .upsert_document(make_doc_input( + "test:cleanup", + "doc-b", + "Document B", + "Content of document B for cleanup.", + )) + .await + .unwrap(); + memory + .upsert_document(make_doc_input( + "test:cleanup", + "doc-c", + "Document C", + "Content of document C for cleanup.", + )) + .await + .unwrap(); + + // 2 KV entries + memory + .kv_set_namespace("test:cleanup", "pref-1", &json!({"theme": "dark"})) + .await + .unwrap(); + memory + .kv_set_namespace("test:cleanup", "pref-2", &json!({"lang": "en"})) + .await + .unwrap(); + + // 2 graph relations + memory + .graph_upsert_namespace( + "test:cleanup", + "Alice", + "knows", + "Bob", + &json!({"source": "test"}), + ) + .await + .unwrap(); + memory + .graph_upsert_namespace( + "test:cleanup", + "Bob", + "works_at", + "Acme", + &json!({"source": "test"}), + ) + .await + .unwrap(); + + // --- Populate "test:other" namespace (control) --- + + memory + .upsert_document(make_doc_input( + "test:other", + "other-doc", + "Other Document", + "Content of document in the other namespace.", + )) + .await + .unwrap(); + memory + .kv_set_namespace("test:other", "other-key", &json!({"value": true})) + .await + .unwrap(); + memory + .graph_upsert_namespace( + "test:other", + "X", + "relates_to", + "Y", + &json!({"source": "other"}), + ) + .await + .unwrap(); + + // --- Verify pre-conditions --- + + let cleanup_docs = memory.list_documents(Some("test:cleanup")).await.unwrap(); + assert_eq!( + cleanup_docs["count"].as_u64().unwrap(), + 3, + "test:cleanup should have 3 documents before clear" + ); + + let cleanup_kv = memory.kv_list_namespace("test:cleanup").await.unwrap(); + assert_eq!( + cleanup_kv.len(), + 2, + "test:cleanup should have 2 KV entries before clear" + ); + + let cleanup_graph = memory + .graph_relations_namespace("test:cleanup", None, None) + .await + .unwrap(); + assert_eq!( + cleanup_graph.len(), + 2, + "test:cleanup should have 2 graph relations before clear" + ); + + let other_docs = memory.list_documents(Some("test:other")).await.unwrap(); + assert_eq!( + other_docs["count"].as_u64().unwrap(), + 1, + "test:other should have 1 document before clear" + ); + + // --- Execute clear_namespace --- + + memory.clear_namespace("test:cleanup").await.unwrap(); + + // --- Assert: "test:cleanup" is empty --- + + let cleanup_docs_after = memory.list_documents(Some("test:cleanup")).await.unwrap(); + assert_eq!( + cleanup_docs_after["count"].as_u64().unwrap(), + 0, + "test:cleanup documents should be empty after clear" + ); + + let cleanup_kv_after = memory.kv_list_namespace("test:cleanup").await.unwrap(); + assert!( + cleanup_kv_after.is_empty(), + "test:cleanup KV entries should be empty after clear" + ); + + let cleanup_graph_after = memory + .graph_relations_namespace("test:cleanup", None, None) + .await + .unwrap(); + assert!( + cleanup_graph_after.is_empty(), + "test:cleanup graph relations should be empty after clear" + ); + + // --- Assert: "test:other" is untouched (critical) --- + + let other_docs_after = memory.list_documents(Some("test:other")).await.unwrap(); + assert_eq!( + other_docs_after["count"].as_u64().unwrap(), + 1, + "test:other document must still exist after clearing test:cleanup" + ); + + let other_kv_after = memory.kv_list_namespace("test:other").await.unwrap(); + assert_eq!( + other_kv_after.len(), + 1, + "test:other KV entry must still exist after clearing test:cleanup" + ); + + let other_graph_after = memory + .graph_relations_namespace("test:other", None, None) + .await + .unwrap(); + assert_eq!( + other_graph_after.len(), + 1, + "test:other graph relation must still exist after clearing test:cleanup" + ); + } + + #[tokio::test] + async fn clear_namespace_on_empty_namespace_is_noop() { + let tmp = TempDir::new().unwrap(); + let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + + // Clearing a namespace that has never been used should succeed without error. + memory.clear_namespace("nonexistent").await.unwrap(); + + let docs = memory.list_documents(Some("nonexistent")).await.unwrap(); + assert_eq!(docs["count"].as_u64().unwrap(), 0); + } + + #[tokio::test] + async fn clear_namespace_removes_on_disk_markdown_files() { + let tmp = TempDir::new().unwrap(); + let memory = UnifiedMemory::new(tmp.path(), Arc::new(NoopEmbedding), None).unwrap(); + + memory + .upsert_document(make_doc_input( + "test:diskcheck", + "disk-doc", + "Disk Doc", + "This doc has a markdown file on disk.", + )) + .await + .unwrap(); + + let docs_dir = tmp + .path() + .join("memory") + .join("namespaces") + .join("test_diskcheck") + .join("docs"); + assert!( + docs_dir.exists(), + "docs directory should exist after upsert" + ); + + memory.clear_namespace("test:diskcheck").await.unwrap(); + + assert!( + !docs_dir.exists(), + "docs directory should be removed after clear_namespace" + ); + } +}