Files
openhuman/src-tauri/src/memory/mod.rs
T
M3gA-Mind 0f5700921f refactor: update memory recall methods to return context as JSON values
- Changed the return type of `recall_skill_context` to `Option<serde_json::Value>` for better flexibility.
- Updated logging to provide clearer insights during memory recall operations.
- Simplified context handling in `chat_send_inner` and `recall_memory` functions to improve readability.
2026-03-26 22:16:10 +05:30

436 lines
17 KiB
Rust

//! TinyHumans Neocortex persistent memory layer.
//!
//! Wraps `TinyHumanMemoryClient` with helpers for skill data-sync.
//! The client is initialised at runtime with the user's JWT token (from Redux
//! `authSlice.token`) via the `init_memory_client` Tauri command, not from env vars.
use std::sync::Arc;
use tinyhumansai::{
DeleteMemoryParams, InsertMemoryParams, Priority, QueryMemoryParams, RecallMemoryParams,
SourceType, TinyHumanConfig, TinyHumansMemoryClient,
};
use uuid::Uuid;
/// Shared, cloneable handle to the memory client.
pub type MemoryClientRef = Arc<MemoryClient>;
pub struct MemoryClient {
inner: TinyHumansMemoryClient,
}
impl MemoryClient {
/// Construct from a JWT token (sourced from `authSlice.token` in the Redux store).
/// Returns `None` if the token is empty or client construction fails.
pub fn from_token(jwt_token: String) -> Option<Self> {
log::info!("[memory] from_token: entry (token_len={})", jwt_token.trim().len());
if jwt_token.trim().is_empty() {
log::warn!("[memory] from_token: exit — token is empty, returning None");
return None;
}
let config = if let Ok(base_url) = std::env::var("ALPHAHUMAN_BASE_URL")
.or_else(|_| std::env::var("TINYHUMANS_BASE_URL"))
{
log::info!("[memory] from_token: constructing client (base_url={base_url}, source=memory_env)");
TinyHumanConfig::new(jwt_token).with_base_url(base_url)
} else {
let backend_url = crate::utils::config::get_backend_url();
log::info!(
"[memory] from_token: constructing client (base_url={backend_url}, source=app_backend_url)"
);
TinyHumanConfig::new(jwt_token).with_base_url(backend_url)
};
match TinyHumansMemoryClient::new(config) {
Ok(inner) => {
log::info!("[memory] from_token: exit — client created successfully");
Some(Self { inner })
}
Err(e) => {
log::warn!("[memory] from_token: exit — client construction failed: {e}");
None
}
}
}
/// Store a skill data-sync result.
///
/// Inserts the document then polls `ingestion_job_status` every 30 s until
/// the job reaches `completed` (or `failed`/`error`). Returns only after the
/// ingestion job is confirmed complete.
pub async fn store_skill_sync(
&self,
skill_id: &str,
integration_id: &str,
title: &str,
content: &str,
source_type: Option<SourceType>,
metadata: Option<serde_json::Value>,
priority: Option<Priority>,
created_at: Option<f64>,
updated_at: Option<f64>,
document_id: Option<String>,
) -> Result<(), String> {
let namespace = skill_id.to_string();
log::info!(
"[memory] store_skill_sync: entry (namespace={namespace}, title={title:?}, content_len={})",
content.len()
);
let document_id_final = document_id.unwrap_or_else(|| Uuid::new_v4().to_string());
log::info!(
"[memory] insert_memory: calling SDK (namespace={namespace}, title={title:?}), content_len={}",
content.len()
);
let insert_resp = self
.inner
.insert_memory(InsertMemoryParams {
document_id: Some(document_id_final),
title: title.to_string(),
content: content.to_string(),
namespace: namespace.clone(),
source_type,
metadata,
priority,
created_at,
updated_at,
..Default::default()
})
.await
.map_err(|e| {
log::warn!(
"[memory] insert_memory: SDK error — kind={:?} msg={e}",
classify_insert_error(&e)
);
format!("Memory insert failed: {e}")
})?;
log::info!(
"[memory] insert_memory: accepted (namespace={namespace}, status={:?}, job_id={:?})",
insert_resp.data.status,
insert_resp.data.job_id
);
// If the API returned a job_id, poll until the job completes.
if let Some(job_id) = insert_resp.data.job_id {
log::info!("[memory] ingestion job queued (job_id={job_id}), polling every 30s...");
loop {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
match self.inner.ingestion_job_status(&job_id).await {
Ok(status_resp) => {
let state = status_resp
.data
.state
.as_deref()
.unwrap_or("unknown");
log::info!(
"[memory] ingestion job status: job_id={job_id}, state={state}, \
attempts={:?}, completed_at={:?}",
status_resp.data.attempts,
status_resp.data.completed_at
);
match state {
"completed" => {
log::info!(
"[memory] ingestion job completed (job_id={job_id}, namespace={namespace})"
);
break;
}
"failed" | "error" => {
let err_msg = status_resp
.data
.error
.unwrap_or_else(|| format!("job state={state}"));
log::warn!(
"[memory] ingestion job failed: job_id={job_id}, error={err_msg}"
);
log::warn!(
"[memory] store_skill_sync: exit — ingestion failed (namespace={namespace})"
);
return Err(format!("Ingestion job failed: {err_msg}"));
}
_ => {
// pending / processing / queued — keep waiting
log::info!(
"[memory] ingestion job still in progress (state={state}), waiting 30s..."
);
}
}
}
Err(e) => {
log::warn!(
"[memory] ingestion job status poll error (job_id={job_id}): {e} — retrying in 30s"
);
}
}
}
} else {
log::info!("[memory] no job_id returned — insert assumed synchronous, proceeding");
}
log::info!("[memory] store_skill_sync: exit — ok (namespace={namespace})");
Ok(())
}
/// Query relevant context for a skill integration (RAG).
pub async fn query_skill_context(
&self,
skill_id: &str,
_integration_id: &str,
query: &str,
max_chunks: u32,
) -> Result<String, String> {
let namespace = skill_id.to_string();
log::info!("[memory] query_skill_context: entry (namespace={namespace}, max_chunks={max_chunks}, query={query:?})");
log::debug!(
"[memory] query_skill_context: payload → namespace={namespace} | max_chunks={max_chunks} | query={query}"
);
let res = self
.inner
.query_memory(QueryMemoryParams {
query: query.to_string(),
namespace: Some(namespace.clone()),
max_chunks: Some(f64::from(max_chunks)),
..Default::default()
})
.await
.map_err(|e| {
log::warn!("[memory] query_skill_context: exit — error (namespace={namespace}): {e}");
format!("Memory query failed: {e}")
})?;
let response = res.data.response.unwrap_or_default();
log::info!("[memory] query_skill_context: exit — ok (namespace={namespace}, response_len={})", response.len());
Ok(response)
}
/// Recall context from the Master memory node for a given namespace.
/// Returns the synthesised `response` string, or `None` if the server returned nothing.
pub async fn recall_skill_context(
&self,
skill_id: &str,
_integration_id: &str,
max_chunks: u32,
) -> Result<Option<serde_json::Value>, String> {
let namespace = skill_id.to_string();
log::info!(
"[memory] recall_skill_context: entry (namespace={namespace}, max_chunks={max_chunks})"
);
let res = self
.inner
.recall_memory(RecallMemoryParams {
namespace: Some(namespace.clone()),
max_chunks: Some(f64::from(max_chunks)),
})
.await
.map_err(|e| {
log::warn!("[memory] recall_skill_context: exit — error (namespace={namespace}): {e}");
format!("Memory recall failed: {e}")
})?;
log::info!("[memory] recall_skill_context: response: {res:?}");
let response = res.data.context;
log::info!(
"[memory] recall_skill_context: exit — ok (namespace={namespace}, has_response={})",
response.is_some()
);
Ok(response)
}
/// List all ingested memory documents as returned by the API.
pub async fn list_documents(&self) -> Result<serde_json::Value, String> {
self.inner
.list_documents()
.await
.map_err(|e| format!("Memory list documents failed: {e}"))
}
/// Delete a specific document from a namespace.
pub async fn delete_document(&self, document_id: &str, namespace: &str) -> Result<serde_json::Value, String> {
self.inner
.delete_document(document_id, namespace)
.await
.map_err(|e| format!("Memory delete document failed: {e}"))
}
/// Query memory context by namespace directly.
pub async fn query_namespace_context(
&self,
namespace: &str,
query: &str,
max_chunks: u32,
) -> Result<String, String> {
let res = self
.inner
.query_memory(QueryMemoryParams {
query: query.to_string(),
namespace: Some(namespace.to_string()),
max_chunks: Some(f64::from(max_chunks)),
..Default::default()
})
.await
.map_err(|e| format!("Memory query failed: {e}"))?;
Ok(res.data.response.unwrap_or_default())
}
/// Recall memory context by namespace directly.
pub async fn recall_namespace_context(
&self,
namespace: &str,
max_chunks: u32,
) -> Result<Option<String>, String> {
let res = self
.inner
.recall_memory(RecallMemoryParams {
namespace: Some(namespace.to_string()),
max_chunks: Some(f64::from(max_chunks)),
})
.await
.map_err(|e| format!("Memory recall failed: {e}"))?;
Ok(res.data.response)
}
/// Delete all memories for a skill integration (e.g. on OAuth revoke).
pub async fn clear_skill_memory(
&self,
skill_id: &str,
integration_id: &str,
) -> Result<(), String> {
let namespace = format!("skill:{skill_id}:{integration_id}");
log::info!("[memory] clear_skill_memory: entry (namespace={namespace})");
log::debug!("[memory] clear_skill_memory: payload → namespace={namespace}");
let result = self.inner
.delete_memory(DeleteMemoryParams {
namespace: Some(namespace.clone()),
})
.await
.map(|_| ())
.map_err(|e| format!("Memory delete failed: {e}"));
match &result {
Ok(()) => log::info!("[memory] clear_skill_memory: exit — ok (namespace={namespace})"),
Err(e) => log::warn!("[memory] clear_skill_memory: exit — error (namespace={namespace}): {e}"),
}
result
}
}
fn classify_insert_error(e: &tinyhumansai::TinyHumansError) -> &'static str {
let msg = e.to_string();
if msg.contains("dns") || msg.contains("resolve") || msg.contains("lookup") {
"dns_failure"
} else if msg.contains("tls") || msg.contains("certificate") || msg.contains("ssl") {
"tls_failure"
} else if msg.contains("Connection refused") || msg.contains("connection refused") {
"connection_refused"
} else if msg.contains("timed out") || msg.contains("deadline") {
"timeout"
} else if msg.contains("error sending request") {
"transport_error"
} else {
"other"
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Integration test against the real TinyHumans production API.
///
/// Verifies: JWT is accepted, endpoint is reachable, and request shape is correct.
/// A `400 Insufficient ingestion budget` response is treated as a PASS because it proves:
/// - auth succeeded (not 401/403)
/// - the endpoint and payload are correctly formed (not 422)
/// - the account quota is the only limiting factor
///
/// Run with:
/// JWT_TOKEN=<your-alphahuman-jwt> \
/// cargo test --manifest-path src-tauri/Cargo.toml test_memory_skill_sync_flow -- --ignored --nocapture
#[tokio::test]
#[ignore]
async fn test_memory_skill_sync_flow() {
let jwt_token =
std::env::var("JWT_TOKEN").expect("JWT_TOKEN must be set");
let client = MemoryClient::from_token(jwt_token)
.expect("client creation failed");
let skill_id = "gmail";
let integration_id = "test@alphahuman.dev";
let dummy_content = serde_json::json!({
"integrationId": integration_id,
"type": "gmail_sync",
"summary": "Synced 45 emails from inbox",
"labels": ["Work", "Personal", "Finance"],
"recentSenders": ["alice@example.com", "bob@example.com"],
"unreadCount": 12,
"syncedAt": "2026-03-17T12:00:00Z"
});
// ── 1. Insert ─────────────────────────────────────────────────────────
let insert_result = client
.store_skill_sync(
skill_id,
integration_id,
"Gmail OAuth sync — test@alphahuman.dev",
&serde_json::to_string_pretty(&dummy_content).unwrap(),
None,
None,
None,
None,
None,
None,
)
.await;
println!("[test] insert result: {insert_result:?}");
match &insert_result {
Ok(()) => {
println!("[test] ✓ INSERT succeeded — quota available");
// ── 2. Query ─────────────────────────────────────────────────
let context = client
.query_skill_context(
skill_id,
integration_id,
"What emails were recently synced and who sent them?",
10,
)
.await;
println!("[test] query result: {context:?}");
assert!(context.is_ok(), "query_memory failed: {context:?}");
println!("[test] memory context:\n{}", context.unwrap());
// ── 3. Cleanup ────────────────────────────────────────────────
let del = client.clear_skill_memory(skill_id, integration_id).await;
println!("[test] delete result: {del:?}");
assert!(del.is_ok(), "delete_memory failed: {del:?}");
}
Err(e) if e.contains("Insufficient ingestion budget") => {
// Account quota exhausted — auth + endpoint + payload all correct.
println!(
"[test] ✓ API REACHABLE — auth accepted, endpoint correct.\n\
Quota limited: {e}\n\
Integration is wired correctly; upgrade the TinyHumans account \
to enable full insert/query/delete flow."
);
// Not a code failure — pass the test.
}
Err(e) => {
panic!("Unexpected error (not a quota issue): {e}");
}
}
}
/// Smoke test: from_token() returns None for an empty token.
#[test]
fn test_from_token_returns_none_for_empty_token() {
assert!(MemoryClient::from_token(String::new()).is_none());
assert!(MemoryClient::from_token(" ".to_string()).is_none());
}
}