Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@
"macos:dev": "yarn macos:build:debug && open 'src-tauri/target/debug/bundle/macos/AlphaHuman.app'",
"android:dev": "tauri android dev",
"android:build": "tauri android build",
"test": "vitest run --config test/vitest.config.ts",
"test:unit": "vitest run --config test/vitest.config.ts",
"test:unit:watch": "vitest --config test/vitest.config.ts --watch",
"test:watch": "vitest --config test/vitest.config.ts --watch",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"test": "vitest run",
"test:unit": "vitest run",
"test:unit:watch": "vitest",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:rust": "source $HOME/.cargo/env 2>/dev/null; cargo test --manifest-path src-tauri/Cargo.toml",
"test:e2e:build": "bash scripts/e2e-build.sh",
"test:e2e:login": "bash scripts/e2e-login.sh",
Expand All @@ -48,13 +48,12 @@
"@scure/bip32": "^2.0.1",
"@scure/bip39": "^2.0.1",
"@sentry/react": "^10.38.0",
"@tauri-apps/api": "^2",
"@tauri-apps/api": "2.10.1",
"@tauri-apps/plugin-deep-link": "^2",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "~2",
"@types/react-router-dom": "^5.3.3",
"@types/three": "^0.183.1",
"buffer": "^6.0.3",
"debug": "^4.4.3",
"immer": "^11.1.3",
Expand All @@ -71,7 +70,6 @@
"redux-persist": "^6.0.0",
"socket.io-client": "^4.8.3",
"telegram": "^2.26.22",
"three": "^0.183.2",
"util": "^0.12.5",
"zustand": "^5.0.10"
},
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ opentelemetry_sdk = { version = "0.31", default-features = false, features = ["t
opentelemetry-otlp = { version = "0.31", default-features = false, features = ["trace", "metrics", "http-proto", "reqwest-client", "reqwest-rustls-webpki-roots"] }
tokio-stream = { version = "0.1.18", features = ["full"] }
url = "2"
tinyhumansai = "0.1"
tinyhumansai = "0.1.4"

# Optional integrations
matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] }
Expand Down
34 changes: 34 additions & 0 deletions src-tauri/src/commands/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,40 @@ pub async fn init_memory_client(
Ok(())
}

/// Recall context from the TinyHumans Master memory node for a skill integration.
/// Returns the recalled context string (or null if the server had nothing to return).
#[tauri::command]
pub async fn recall_memory(
skill_id: String,
integration_id: String,
max_chunks: Option<u32>,
state: tauri::State<'_, MemoryState>,
) -> Result<Option<String>, String> {
log::info!(
"[memory] recall_memory: entry (skill_id={skill_id}, integration_id={integration_id}, max_chunks={max_chunks:?})"
);
let client = state.0.lock().map_err(|e| e.to_string())?.clone();
match client {
Some(c) => {
let result = c
.recall_skill_context(&skill_id, &integration_id, max_chunks.unwrap_or(10))
.await;
match &result {
Ok(ctx) => log::info!(
"[memory] recall_memory: exit — ok (has_context={})",
ctx.is_some()
),
Err(e) => log::warn!("[memory] recall_memory: exit — error: {e}"),
}
result
}
None => {
log::warn!("[memory] recall_memory: exit — client not initialised (no JWT set)");
Err("Memory layer not configured — JWT token not yet set".into())
}
}
}

/// Query the TinyHumans memory for a skill integration.
/// Returns the RAG context string to inject into AI prompts.
#[tauri::command]
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,7 @@ pub fn run() {
// Memory commands (TinyHumans Neocortex)
init_memory_client,
memory_query,
recall_memory,
]
}
#[cfg(not(desktop))]
Expand Down Expand Up @@ -916,6 +917,7 @@ pub fn run() {
// Memory commands (TinyHumans Neocortex)
init_memory_client,
memory_query,
recall_memory,
]
}
})
Expand Down
82 changes: 72 additions & 10 deletions src-tauri/src/memory/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

use std::sync::Arc;
use tinyhumansai::{
DeleteMemoryParams, InsertMemoryParams, QueryMemoryParams, TinyHumanConfig,
TinyHumanMemoryClient,
DeleteMemoryParams, InsertMemoryParams, QueryMemoryParams, RecallMemoryParams,
TinyHumanConfig, TinyHumanMemoryClient,
};

/// Shared, cloneable handle to the memory client.
Expand All @@ -21,15 +21,23 @@ 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::debug!("[memory] from_token: entry (token_len={})", jwt_token.trim().len());
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 base_url = std::env::var("TINYHUMANS_BASE_URL")
.unwrap_or_else(|_| "https://api.tinyhumans.ai".to_string());
log::debug!("[memory] from_token: constructing client (base_url={base_url})");
let config = TinyHumanConfig::new(jwt_token).with_base_url(base_url);
let config = match std::env::var("ALPHAHUMAN_BASE_URL")
.or_else(|_| std::env::var("TINYHUMANS_BASE_URL"))
{
Ok(base_url) => {
log::info!("[memory] from_token: constructing client (base_url={base_url})");
TinyHumanConfig::new(jwt_token).with_base_url(base_url)
}
Err(_) => {
log::warn!("[memory] from_token: constructing client (base_url=<sdk default>)");
TinyHumanConfig::new(jwt_token)
}
};
match TinyHumanMemoryClient::new(config) {
Ok(inner) => {
log::info!("[memory] from_token: exit — client created successfully");
Expand Down Expand Up @@ -58,6 +66,7 @@ impl MemoryClient {
"[memory] store_skill_sync: payload → namespace={namespace} | title={title} | content={}",
content
);
log::info!("[memory] insert_memory: calling SDK (namespace={namespace}, title={title:?})");
let result = self.inner
.insert_memory(InsertMemoryParams {
title: title.to_string(),
Expand All @@ -66,8 +75,13 @@ impl MemoryClient {
..Default::default()
})
.await
.map(|_| ())
.map_err(|e| format!("Memory insert failed: {e}"));
.map(|_| {
log::info!("[memory] insert_memory: success (namespace={namespace}, title={title:?})");
})
.map_err(|e| {
log::warn!("[memory] insert_memory: SDK error — kind={:?} msg={e}", classify_insert_error(&e));
format!("Memory insert failed: {e}")
});
match &result {
Ok(()) => log::info!("[memory] store_skill_sync: exit — ok (namespace={namespace})"),
Err(e) => log::warn!("[memory] store_skill_sync: exit — error (namespace={namespace}): {e}"),
Expand All @@ -93,7 +107,7 @@ impl MemoryClient {
.query_memory(QueryMemoryParams {
query: query.to_string(),
namespace: Some(namespace.clone()),
max_chunks: Some(max_chunks),
max_chunks: Some(f64::from(max_chunks)),
..Default::default()
})
.await
Expand All @@ -106,6 +120,37 @@ impl MemoryClient {
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<String>, String> {
let namespace = format!("skill:{skill_id}:{integration_id}");
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}")
})?;
let response = res.data.response;
log::info!(
"[memory] recall_skill_context: exit — ok (namespace={namespace}, has_response={})",
response.is_some()
);
Ok(response)
}

/// Delete all memories for a skill integration (e.g. on OAuth revoke).
pub async fn clear_skill_memory(
&self,
Expand All @@ -130,6 +175,23 @@ impl MemoryClient {
}
}

fn classify_insert_error(e: &tinyhumansai::TinyHumanError) -> &'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::*;
Expand Down
47 changes: 31 additions & 16 deletions src-tauri/src/runtime/qjs_skill_instance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ async fn run_event_loop(
// messages (events, pings, etc.) but queue any new CallTool.
match rx.try_recv() {
Ok(msg) => {
let should_stop = handle_message(rt, ctx, msg, state, skill_id, &mut pending_tool, app_handle).await;
let should_stop = handle_message(rt, ctx, msg, state, skill_id, &mut pending_tool, app_handle, ops_state).await;
if should_stop {
break;
}
Expand Down Expand Up @@ -447,6 +447,7 @@ async fn handle_message(
skill_id: &str,
pending_tool: &mut Option<PendingToolCall>,
app_handle: Option<&tauri::AppHandle>,
ops_state: &Arc<RwLock<qjs_ops::SkillState>>,
) -> bool {
match msg {
SkillMessage::CallTool { tool_name, arguments, reply } => {
Expand Down Expand Up @@ -553,12 +554,13 @@ async fn handle_message(
}
}
SkillMessage::Rpc { method, params, reply } => {
// Resolve the optional memory client once for this RPC call
let memory_client_opt: Option<crate::memory::MemoryClientRef> =
app_handle.and_then(|ah| {
ah.try_state::<Option<crate::memory::MemoryClientRef>>()
.and_then(|s: tauri::State<'_, Option<crate::memory::MemoryClientRef>>| s.inner().clone())
});
// Resolve the optional memory client once for this RPC call.
// State is registered as MemoryState(Mutex<Option<MemoryClientRef>>), not
// Option<MemoryClientRef> directly, so we must use the newtype wrapper.
let memory_client_opt = app_handle.and_then(|ah| {
ah.try_state::<crate::commands::memory::MemoryState>()
.and_then(|s| s.0.lock().ok().and_then(|g| g.clone()))
});

let result = match method.as_str() {
"oauth/complete" => {
Expand All @@ -582,17 +584,22 @@ async fn handle_message(
let params_str = serde_json::to_string(&params).unwrap_or_else(|_| "{}".to_string());
let result = handle_js_call(rt, ctx, "onOAuthComplete", &params_str).await;

// Fire-and-forget: persist returned payload to TinyHumans memory
if let Ok(ref payload) = result {
// Fire-and-forget: persist published ops state to TinyHumans memory.
// Skills publish data via state.set()/setPartial() into ops_state.data,
// not as the return value of onOAuthComplete() (which is typically undefined).
let state_snapshot = ops_state.read().data.clone();
if !state_snapshot.is_empty() {
if let Some(client) = memory_client_opt.clone() {
let skill = skill_id.to_string();
let integration_id = params
.get("integrationId")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let content = serde_json::to_string_pretty(payload)
.unwrap_or_else(|_| payload.to_string());
let content = serde_json::to_string_pretty(
&serde_json::Value::Object(state_snapshot),
)
.unwrap_or_else(|_| "{}".to_string());
let title = format!("{} OAuth sync — {}", skill, integration_id);
tokio::spawn(async move {
if let Err(e) = client
Expand All @@ -614,13 +621,21 @@ async fn handle_message(
}
"skill/sync" => {
let result = handle_js_call(rt, ctx, "onSync", "{}").await;

// Fire-and-forget: persist sync payload to TinyHumans memory
if let Ok(ref payload) = result {
// Fire-and-forget: persist published ops state to TinyHumans memory.
// Skills publish data via state.set()/setPartial() into ops_state.data,
// not as the return value of onSync() (which is typically undefined).
let state_snapshot = ops_state.read().data.clone();
log::info!(
"[memory] store_skill_sync: payload → state_snapshot={:?}",
state_snapshot
);
if !state_snapshot.is_empty() {
if let Some(client) = memory_client_opt.clone() {
let skill = skill_id.to_string();
let content = serde_json::to_string_pretty(payload)
.unwrap_or_else(|_| payload.to_string());
let content = serde_json::to_string_pretty(
&serde_json::Value::Object(state_snapshot),
)
.unwrap_or_else(|_| "{}".to_string());
let title = format!("{} periodic sync", skill);
tokio::spawn(async move {
if let Err(e) = client
Expand Down
4 changes: 2 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,10 @@ function App() {
<PersistGate
loading={null}
persistor={persistor}
onBeforeLift={() => {
onBeforeLift={async () => {
const token = store.getState().auth.token;
console.info('[memory] PersistGate onBeforeLift: token_present=%s', !!token);
if (token) void syncMemoryClientToken(token);
if (token) await syncMemoryClientToken(token);
}}>
<UserProvider>
<SocketProvider>
Expand Down
Loading
Loading