use crate::agent::{Message, Role};
use crate::agent::session::Session;
use crate::{PawanError, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ErukaConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_eruka_url")]
pub url: String,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default = "default_core_max_tokens")]
pub core_max_tokens: usize,
}
fn default_eruka_url() -> String {
"http://localhost:8081".into()
}
fn default_core_max_tokens() -> usize {
500
}
impl Default for ErukaConfig {
fn default() -> Self {
Self {
enabled: false,
url: default_eruka_url(),
api_key: None,
core_max_tokens: default_core_max_tokens(),
}
}
}
pub struct ErukaClient {
config: ErukaConfig,
http: reqwest::Client,
}
#[derive(Debug, Deserialize)]
pub struct SearchResult {
pub content: Option<String>,
pub field_name: Option<String>,
pub score: Option<f64>,
}
#[derive(Debug, Deserialize)]
pub struct ContextResponse {
pub fields: Option<Vec<ContextField>>,
}
#[derive(Debug, Deserialize)]
pub struct ContextField {
pub name: Option<String>,
pub value: Option<String>,
pub category: Option<String>,
}
impl ErukaClient {
pub fn new(config: ErukaConfig) -> Self {
Self {
config,
http: reqwest::Client::new(),
}
}
pub fn is_enabled(&self) -> bool {
self.config.enabled
}
pub async fn fetch_core_memory(&self) -> Result<Option<String>> {
if !self.config.enabled {
return Ok(None);
}
let url = format!("{}/api/v1/context", self.config.url);
let mut req = self.http.get(&url);
if let Some(key) = &self.config.api_key {
req = req
.header("X-Service-Key", key.as_str())
.header("X-Workspace-Id", "pawan");
}
let resp = req.send().await.map_err(|e| {
tracing::warn!("Eruka context fetch failed: {}", e);
PawanError::Agent(format!("Eruka: {}", e))
})?;
if !resp.status().is_success() {
tracing::warn!("Eruka returned {}", resp.status());
return Ok(None);
}
let body = resp.text().await.map_err(|e| {
PawanError::Agent(format!("Eruka body: {}", e))
})?;
if let Ok(ctx) = serde_json::from_str::<ContextResponse>(&body) {
if let Some(fields) = ctx.fields {
let memory: Vec<String> = fields
.iter()
.filter_map(|f| {
let name = f.name.as_deref()?;
let value = f.value.as_deref()?;
Some(format!("{}: {}", name, value))
})
.collect();
if memory.is_empty() {
return Ok(None);
}
let max_chars = self.config.core_max_tokens * 4;
let joined = memory.join("\n");
let truncated: String = joined.chars().take(max_chars).collect();
return Ok(Some(format!(
"[Eruka Core Memory]\n{}\n[End Core Memory]",
truncated
)));
}
}
if !body.is_empty() && body.len() < self.config.core_max_tokens * 4 {
return Ok(Some(format!(
"[Eruka Core Memory]\n{}\n[End Core Memory]",
body
)));
}
Ok(None)
}
pub async fn inject_core_memory(&self, history: &mut Vec<Message>) -> Result<()> {
if !self.config.enabled {
return Ok(());
}
if let Some(memory) = self.fetch_core_memory().await? {
let already_injected = history.iter().any(|m| {
m.role == Role::System && m.content.contains("[Eruka Core Memory]")
});
if !already_injected {
history.insert(
0,
Message {
role: Role::System,
content: memory,
tool_calls: vec![],
tool_result: None,
},
);
tracing::info!("Injected Eruka core memory into context");
}
}
Ok(())
}
pub async fn search_archival(&self, query: &str) -> Result<Vec<String>> {
if !self.config.enabled {
return Ok(vec![]);
}
let url = format!("{}/api/v1/context/search", self.config.url);
let mut req = self.http
.post(&url)
.json(&serde_json::json!({"query": query, "limit": 5}));
if let Some(key) = &self.config.api_key {
req = req
.header("X-Service-Key", key.as_str())
.header("X-Workspace-Id", "pawan");
}
let resp = req.send().await.map_err(|e| {
tracing::warn!("Eruka search failed: {}", e);
PawanError::Agent(format!("Eruka search: {}", e))
})?;
if !resp.status().is_success() {
return Ok(vec![]);
}
let body = resp.text().await.unwrap_or_default();
if let Ok(results) = serde_json::from_str::<Vec<SearchResult>>(&body) {
Ok(results
.into_iter()
.filter_map(|r| r.content)
.collect())
} else {
Ok(vec![])
}
}
pub async fn write_context(
&self,
path: &str,
value: &str,
source: &str,
confidence: f64,
) -> Result<bool> {
if !self.config.enabled {
return Ok(false);
}
let url = format!("{}/api/v1/context", self.config.url);
let mut req = self.http.post(&url).json(&serde_json::json!({
"path": path,
"value": value,
"source": source,
"confidence": confidence,
}));
if let Some(key) = &self.config.api_key {
req = req
.header("X-Service-Key", key.as_str())
.header("X-Workspace-Id", "pawan");
}
match req.send().await {
Ok(resp) if resp.status().is_success() => Ok(true),
Ok(resp) => {
tracing::warn!("Eruka write_context returned {}", resp.status());
Ok(false)
}
Err(e) => {
tracing::warn!("Eruka write_context failed (non-fatal): {}", e);
Ok(false)
}
}
}
pub async fn sync_turn(
&self,
user_message: &str,
assistant_message: &str,
session_id: &str,
) -> Result<bool> {
if !self.config.enabled {
return Ok(false);
}
let user_trim: String = user_message.chars().take(500).collect();
let asst_trim: String = assistant_message.chars().take(500).collect();
let path = format!("operations/turns/{session_id}");
let value = format!("USER: {user_trim} | ASSISTANT: {asst_trim}");
self.write_context(&path, &value, "agent_inference", 0.9)
.await
}
pub async fn on_pre_compress(&self, messages: &str, session_id: &str) -> Result<bool> {
if !self.config.enabled {
return Ok(false);
}
let path = format!("operations/compressed_insights/{session_id}");
let summary = if messages.len() > 2000 {
format!(
"{}...(truncated {} chars)",
&messages[..2000],
messages.len() - 2000
)
} else {
messages.to_string()
};
self.write_context(&path, &summary, "agent_inference", 0.8)
.await
}
pub async fn prefetch(&self, query: &str, max_tokens: usize) -> Result<Option<String>> {
if !self.config.enabled {
return Ok(None);
}
let search_url = format!("{}/api/v1/context/search", self.config.url);
let mut req = self.http.post(&search_url).json(&serde_json::json!({
"query": query,
"limit": 5,
}));
if let Some(key) = &self.config.api_key {
req = req
.header("X-Service-Key", key.as_str())
.header("X-Workspace-Id", "pawan");
}
let search_text = match req.send().await {
Ok(resp) if resp.status().is_success() => resp.text().await.unwrap_or_default(),
Ok(resp) => {
tracing::warn!("Eruka prefetch search returned {}", resp.status());
String::new()
}
Err(e) => {
tracing::warn!("Eruka prefetch search failed: {}", e);
return Ok(None);
}
};
let compress_url = format!("{}/api/v1/compress", self.config.url);
let mut req = self.http.post(&compress_url).json(&serde_json::json!({
"task_type": "general",
"max_tokens": max_tokens,
}));
if let Some(key) = &self.config.api_key {
req = req
.header("X-Service-Key", key.as_str())
.header("X-Workspace-Id", "pawan");
}
let compress_text = match req.send().await {
Ok(resp) if resp.status().is_success() => resp.text().await.unwrap_or_default(),
Ok(resp) => {
tracing::warn!("Eruka prefetch compress returned {}", resp.status());
String::new()
}
Err(e) => {
tracing::warn!("Eruka prefetch compress failed: {}", e);
String::new()
}
};
if search_text.is_empty() && compress_text.is_empty() {
return Ok(None);
}
Ok(Some(format!(
"[Eruka Prefetch for: {query}]\nSearch results: {search_text}\nCompressed: {compress_text}\n[End Prefetch]"
)))
}
pub async fn get_context_cached(
&self,
path: &str,
_session_id: &str,
) -> Result<Option<(String, String)>> {
if !self.config.enabled {
return Ok(None);
}
let url = format!(
"{}/api/v1/context?path={}&include_metadata=false",
self.config.url, path
);
let mut req = self.http.get(&url);
if let Some(key) = &self.config.api_key {
req = req
.header("X-Service-Key", key.as_str())
.header("X-Workspace-Id", "pawan");
}
let resp = match req.send().await {
Ok(r) if r.status().is_success() => r,
Ok(r) => {
tracing::warn!("Eruka get_context_cached returned {}", r.status());
return Ok(None);
}
Err(e) => {
tracing::warn!("Eruka get_context_cached failed: {}", e);
return Ok(None);
}
};
let body = resp.text().await.unwrap_or_default();
if body.is_empty() {
return Ok(None);
}
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
body.hash(&mut hasher);
let hash = format!("{:016x}", hasher.finish());
Ok(Some((body, hash)))
}
pub async fn export_context(
&self,
category: &str,
include_metadata: bool,
) -> Result<Option<serde_json::Value>> {
if !self.config.enabled {
return Ok(None);
}
let url = format!(
"{}/api/v1/context?path={}&include_metadata={}",
self.config.url, category, include_metadata
);
let mut req = self.http.get(&url);
if let Some(key) = &self.config.api_key {
req = req
.header("X-Service-Key", key.as_str())
.header("X-Workspace-Id", "pawan");
}
let resp = match req.send().await {
Ok(r) if r.status().is_success() => r,
Ok(r) => {
tracing::warn!("Eruka export_context returned {}", r.status());
return Ok(None);
}
Err(e) => {
tracing::warn!("Eruka export_context failed: {}", e);
return Ok(None);
}
};
let body = resp.text().await.unwrap_or_default();
let context_data: serde_json::Value = serde_json::from_str(&body)
.unwrap_or(serde_json::Value::Null);
Ok(Some(serde_json::json!({
"export_format": "eruka_context_core_v1",
"category": category,
"data": context_data,
"exported_at": chrono::Utc::now().to_rfc3339(),
"instructions": "Import this bundle into another Eruka instance via eruka_write_context for each field.",
})))
}
pub async fn archive_session(&self, session: &Session) -> Result<()> {
if !self.config.enabled {
return Ok(());
}
let user_messages: Vec<&str> = session
.messages
.iter()
.filter(|m| m.role == Role::User)
.map(|m| m.content.as_str())
.collect();
let assistant_messages: Vec<&str> = session
.messages
.iter()
.filter(|m| m.role == Role::Assistant)
.map(|m| m.content.as_str())
.collect();
if user_messages.is_empty() {
return Ok(());
}
let summary = format!(
"Session {} (model: {}, {} messages)\nUser topics: {}\nAssistant summary: {}",
session.id,
session.model,
session.messages.len(),
user_messages.join(" | "),
assistant_messages.last().map(|s| {
let trunc: String = s.chars().take(500).collect();
trunc
}).unwrap_or_default(),
);
let url = format!("{}/api/v1/context", self.config.url);
let mut req = self.http
.post(&url)
.json(&serde_json::json!({
"path": format!("operations/sessions/{}", session.id),
"value": summary,
"source": "agent",
}));
if let Some(key) = &self.config.api_key {
req = req
.header("X-Service-Key", key.as_str())
.header("X-Workspace-Id", "pawan");
}
match req.send().await {
Ok(resp) => {
if resp.status().is_success() {
tracing::info!("Archived session {} to Eruka", session.id);
} else {
tracing::warn!("Eruka archive returned {}", resp.status());
}
}
Err(e) => {
tracing::warn!("Eruka archive failed (non-fatal): {}", e);
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_disabled() {
let config = ErukaConfig::default();
assert!(!config.enabled);
assert_eq!(config.url, "http://localhost:8081");
assert_eq!(config.core_max_tokens, 500);
}
#[test]
fn client_respects_enabled() {
let config = ErukaConfig::default();
let client = ErukaClient::new(config);
assert!(!client.is_enabled());
}
#[tokio::test]
async fn disabled_client_noops() {
let client = ErukaClient::new(ErukaConfig::default());
let mut history = vec![];
client.inject_core_memory(&mut history).await.unwrap();
assert!(history.is_empty());
let results = client.search_archival("test").await.unwrap();
assert!(results.is_empty());
}
#[test]
fn config_toml_parsing() {
let toml = r#"
enabled = true
url = "http://eruka.example.com:9090"
api_key = "secret-key"
core_max_tokens = 1000
"#;
let config: ErukaConfig = toml::from_str(toml).expect("should parse");
assert!(config.enabled);
assert_eq!(config.url, "http://eruka.example.com:9090");
assert_eq!(config.api_key, Some("secret-key".into()));
assert_eq!(config.core_max_tokens, 1000);
}
#[test]
fn config_toml_defaults() {
let toml = "enabled = true\n";
let config: ErukaConfig = toml::from_str(toml).expect("should parse");
assert!(config.enabled);
assert_eq!(config.url, "http://localhost:8081");
assert_eq!(config.core_max_tokens, 500);
assert_eq!(config.api_key, None);
}
#[test]
fn context_response_deserialization() {
let json = r#"{"fields":[{"name":"project","value":"pawan","category":"core"},{"name":"role","value":"coding agent"}]}"#;
let ctx: ContextResponse = serde_json::from_str(json).unwrap();
let fields = ctx.fields.unwrap();
assert_eq!(fields.len(), 2);
assert_eq!(fields[0].name.as_deref(), Some("project"));
assert_eq!(fields[0].value.as_deref(), Some("pawan"));
assert_eq!(fields[0].category.as_deref(), Some("core"));
assert_eq!(fields[1].category, None);
}
#[test]
fn context_response_empty_fields() {
let json = r#"{"fields":[]}"#;
let ctx: ContextResponse = serde_json::from_str(json).unwrap();
assert!(ctx.fields.unwrap().is_empty());
}
#[test]
fn context_response_missing_fields() {
let json = r#"{}"#;
let ctx: ContextResponse = serde_json::from_str(json).unwrap();
assert!(ctx.fields.is_none());
}
#[test]
fn search_result_deserialization() {
let json = r#"[{"content":"relevant info","field_name":"notes","score":0.95},{"content":null,"score":0.5}]"#;
let results: Vec<SearchResult> = serde_json::from_str(json).unwrap();
assert_eq!(results.len(), 2);
assert_eq!(results[0].content.as_deref(), Some("relevant info"));
assert_eq!(results[0].score, Some(0.95));
assert!(results[1].content.is_none());
}
#[tokio::test]
async fn disabled_archive_noops() {
use crate::agent::session::Session;
let client = ErukaClient::new(ErukaConfig::default());
let session = Session {
notes: String::new(),
id: "test-123".into(),
model: "test-model".into(),
messages: vec![],
created_at: "2026-04-09T00:00:00Z".into(),
updated_at: "2026-04-09T00:00:00Z".into(),
total_tokens: 0,
iteration_count: 0,
tags: Vec::new(),
};
client.archive_session(&session).await.unwrap();
}
#[tokio::test]
async fn inject_dedup_prevents_double_injection() {
let history = vec![
Message {
role: Role::System,
content: "[Eruka Core Memory]\nproject: pawan\n[End Core Memory]".into(),
tool_calls: vec![],
tool_result: None,
},
Message {
role: Role::User,
content: "hello".into(),
tool_calls: vec![],
tool_result: None,
},
];
let already = history.iter().any(|m| {
m.role == Role::System && m.content.contains("[Eruka Core Memory]")
});
assert!(already, "Should detect existing injection");
}
#[test]
fn default_config_has_no_api_key() {
let config = ErukaConfig::default();
assert_eq!(config.api_key, None, "default api_key must be None");
}
#[test]
fn config_partial_override_keeps_defaults() {
let toml = "enabled = true\n";
let config: ErukaConfig = toml::from_str(toml).expect("should parse");
assert!(config.enabled);
assert_eq!(config.url, "http://localhost:8081", "url default must apply");
assert_eq!(config.core_max_tokens, 500, "core_max_tokens default must apply");
assert_eq!(config.api_key, None, "api_key default must apply");
}
#[test]
fn search_result_deserialize_with_all_null_fields() {
let json = r#"[{"content":null,"field_name":null,"score":null}]"#;
let results: Vec<SearchResult> = serde_json::from_str(json).unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].content.is_none());
assert!(results[0].field_name.is_none());
assert!(results[0].score.is_none());
}
#[test]
fn context_field_deserialize_without_category() {
let json = r#"{"name":"model","value":"qwen3.5-122b"}"#;
let field: ContextField = serde_json::from_str(json).unwrap();
assert_eq!(field.name.as_deref(), Some("model"));
assert_eq!(field.value.as_deref(), Some("qwen3.5-122b"));
assert!(field.category.is_none(), "category must default to None");
}
#[tokio::test]
async fn sync_turn_caps_long_messages_at_500_chars_each() {
let client = ErukaClient::new(ErukaConfig::default());
let long_user = "a".repeat(1200);
let long_asst = "b".repeat(1200);
let result = client
.sync_turn(&long_user, &long_asst, "session-long")
.await;
assert!(result.is_ok(), "long messages must not panic");
}
#[tokio::test]
async fn archive_enabled_with_no_user_messages_short_circuits() {
let config = ErukaConfig {
enabled: true,
url: "http://127.0.0.1:1".into(), api_key: None,
core_max_tokens: 500,
};
let client = ErukaClient::new(config);
let session = Session {
notes: String::new(),
id: "assistant-only".into(),
model: "m".into(),
messages: vec![Message {
role: Role::Assistant,
content: "hi".into(),
tool_calls: vec![],
tool_result: None,
}],
created_at: "2026-04-10T00:00:00Z".into(),
updated_at: "2026-04-10T00:00:00Z".into(),
total_tokens: 0,
iteration_count: 0,
tags: Vec::new(),
};
let result = tokio::time::timeout(
std::time::Duration::from_millis(500),
client.archive_session(&session),
)
.await
.expect("archive must not hang — empty user messages should short-circuit");
assert!(result.is_ok());
}
#[tokio::test]
async fn write_context_disabled_returns_false() {
let client = ErukaClient::new(ErukaConfig::default());
let ok = client
.write_context("identity/name", "pawan", "test", 1.0)
.await
.unwrap();
assert!(!ok, "disabled client must return false without calling network");
}
#[tokio::test]
async fn sync_turn_disabled_returns_false() {
let client = ErukaClient::new(ErukaConfig::default());
let ok = client
.sync_turn("hello", "world", "ses_abc")
.await
.unwrap();
assert!(!ok, "disabled client must short-circuit");
}
#[tokio::test]
async fn on_pre_compress_disabled_returns_false() {
let client = ErukaClient::new(ErukaConfig::default());
let ok = client
.on_pre_compress("some messages", "ses_abc")
.await
.unwrap();
assert!(!ok, "disabled client must short-circuit");
}
#[tokio::test]
async fn prefetch_disabled_returns_none() {
let client = ErukaClient::new(ErukaConfig::default());
let result = client.prefetch("test query", 1000).await.unwrap();
assert!(result.is_none(), "disabled client must return None");
}
#[tokio::test]
async fn get_context_cached_disabled_returns_none() {
let client = ErukaClient::new(ErukaConfig::default());
let result = client
.get_context_cached("identity/*", "ses_abc")
.await
.unwrap();
assert!(result.is_none(), "disabled client must return None");
}
#[tokio::test]
async fn export_context_disabled_returns_none() {
let client = ErukaClient::new(ErukaConfig::default());
let result = client.export_context("*", true).await.unwrap();
assert!(result.is_none(), "disabled client must return None");
}
}