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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* style: fix prettier and cargo fmt formatting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: sanil jain <jainsanil18@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
sanil-23
2026-04-03 17:23:23 +05:30
committed by GitHub
co-authored by sanil jain Claude Opus 4.6
parent bd8d3ed15a
commit 4027f50c9e
6 changed files with 519 additions and 2 deletions
@@ -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<string | null>(null);
const [clearError, setClearError] = useState<string | null>(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 (
<div className="overflow-hidden h-full flex flex-col">
<SettingsHeader title="Memory Debug" showBackButton={true} onBack={navigateBack} />
@@ -240,6 +273,71 @@ const MemoryDebugPanel = () => {
</div>
</SectionCard>
<SectionCard
title="Clear Namespace"
priority="tools"
icon={
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
}>
<div className="space-y-3">
<p className="text-xs text-stone-400">
Delete all documents within a namespace. This is a destructive operation and cannot be
undone.
</p>
<label className="block text-xs text-stone-300">
Namespace
{namespaces.length > 0 ? (
<select
value={clearNamespaceInput}
onChange={e => setClearNamespaceInput(e.target.value)}
className="mt-1 w-full rounded border border-stone-600 bg-black/30 px-3 py-2 text-sm text-white">
<option value="">-- select a namespace --</option>
{namespaces.map(ns => (
<option key={ns} value={ns}>
{ns}
</option>
))}
</select>
) : (
<input
value={clearNamespaceInput}
onChange={e => setClearNamespaceInput(e.target.value)}
className="mt-1 w-full rounded border border-stone-600 bg-black/30 px-3 py-2 text-sm text-white"
placeholder="e.g. skill:gmail:user@example.com"
/>
)}
</label>
<PrimaryButton
variant="outline"
onClick={() => void handleClearNamespace()}
loading={clearLoading}
disabled={!clearNamespaceInput.trim()}
className="border-coral-500/50 text-coral-300 hover:bg-coral-500/10">
Clear Namespace
</PrimaryButton>
{clearSuccess && (
<div className="text-xs text-sage-300 border border-sage-500/30 bg-sage-500/10 rounded p-2">
{clearSuccess}
</div>
)}
{clearError && (
<div className="text-xs text-coral-300 border border-coral-500/30 bg-coral-500/10 rounded p-2">
{clearError}
</div>
)}
</div>
</SectionCard>
<SectionCard
title="Query & Recall"
priority="tools"
+13
View File
@@ -282,6 +282,19 @@ export async function memoryDeleteDocument(
});
}
export async function memoryClearNamespace(
namespace: string
): Promise<{ cleared: boolean; namespace: string }> {
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,
+30
View File
@@ -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<RpcOutcome<serde_json
Ok(RpcOutcome::single_log(result, "memory document deleted"))
}
pub async fn clear_namespace(
params: ClearNamespaceParams,
) -> Result<RpcOutcome<ClearNamespaceResult>, String> {
let client = active_memory_client().await?;
log::debug!(
"[memory] clear_namespace RPC: namespace={}",
params.namespace
);
client.clear_namespace(&params.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<RpcOutcome<String>, String> {
let client = active_memory_client().await?;
let result = client
+43 -2
View File
@@ -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<ControllerSchema> {
schemas("kv_list_namespace"),
schemas("graph_upsert"),
schemas("graph_query"),
schemas("clear_namespace"),
]
}
@@ -140,6 +142,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
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<String, Value>) -> ControllerFuture {
})
}
fn handle_clear_namespace(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let payload = parse_params::<ClearNamespaceParams>(params)?;
to_json(rpc::clear_namespace(payload).await?)
})
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
+4
View File
@@ -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,
@@ -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"
);
}
}