1use crate::agent::session::Session;
9use crate::agent::{Message, Role};
10use crate::{PawanError, Result};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ErukaConfig {
16 #[serde(default)]
18 pub enabled: bool,
19 #[serde(default = "default_eruka_url")]
21 pub url: String,
22 #[serde(default)]
24 pub api_key: Option<String>,
25 #[serde(default = "default_core_max_tokens")]
27 pub core_max_tokens: usize,
28}
29
30fn default_eruka_url() -> String {
31 "http://localhost:8081".into()
32}
33
34fn default_core_max_tokens() -> usize {
35 500
36}
37
38impl Default for ErukaConfig {
39 fn default() -> Self {
40 Self {
41 enabled: false,
42 url: default_eruka_url(),
43 api_key: None,
44 core_max_tokens: default_core_max_tokens(),
45 }
46 }
47}
48
49pub struct ErukaClient {
51 config: ErukaConfig,
52 http: reqwest::Client,
53}
54
55#[derive(Debug, Deserialize)]
57pub struct SearchResult {
58 pub content: Option<String>,
59 pub field_name: Option<String>,
60 pub score: Option<f64>,
61}
62
63#[derive(Debug, Deserialize)]
65pub struct ContextResponse {
66 pub fields: Option<Vec<ContextField>>,
67}
68
69#[derive(Debug, Deserialize)]
70pub struct ContextField {
71 pub name: Option<String>,
72 pub value: Option<String>,
73 pub category: Option<String>,
74}
75
76impl ErukaClient {
77 pub fn new(config: ErukaConfig) -> Self {
79 Self {
80 config,
81 http: reqwest::Client::new(),
82 }
83 }
84
85 pub fn is_enabled(&self) -> bool {
87 self.config.enabled
88 }
89
90 pub async fn fetch_core_memory(&self) -> Result<Option<String>> {
92 if !self.config.enabled {
93 return Ok(None);
94 }
95
96 let url = format!("{}/api/v1/context", self.config.url);
97 let mut req = self.http.get(&url);
98 if let Some(key) = &self.config.api_key {
100 req = req
101 .header("X-Service-Key", key.as_str())
102 .header("X-Workspace-Id", "pawan");
103 }
104
105 let resp = req.send().await.map_err(|e| {
106 tracing::warn!("Eruka context fetch failed: {}", e);
107 PawanError::Agent(format!("Eruka: {}", e))
108 })?;
109
110 if !resp.status().is_success() {
111 tracing::warn!("Eruka returned {}", resp.status());
112 return Ok(None);
113 }
114
115 let body = resp
116 .text()
117 .await
118 .map_err(|e| PawanError::Agent(format!("Eruka body: {}", e)))?;
119
120 if let Ok(ctx) = serde_json::from_str::<ContextResponse>(&body) {
122 if let Some(fields) = ctx.fields {
123 let memory: Vec<String> = fields
124 .iter()
125 .filter_map(|f| {
126 let name = f.name.as_deref()?;
127 let value = f.value.as_deref()?;
128 Some(format!("{}: {}", name, value))
129 })
130 .collect();
131
132 if memory.is_empty() {
133 return Ok(None);
134 }
135
136 let max_chars = self.config.core_max_tokens * 4;
138 let joined = memory.join("\n");
139 let truncated: String = joined.chars().take(max_chars).collect();
140
141 return Ok(Some(format!(
142 "[Eruka Core Memory]\n{}\n[End Core Memory]",
143 truncated
144 )));
145 }
146 }
147
148 if !body.is_empty() && body.len() < self.config.core_max_tokens * 4 {
150 return Ok(Some(format!(
151 "[Eruka Core Memory]\n{}\n[End Core Memory]",
152 body
153 )));
154 }
155
156 Ok(None)
157 }
158
159 pub async fn inject_core_memory(&self, history: &mut Vec<Message>) -> Result<()> {
161 if !self.config.enabled {
162 return Ok(());
163 }
164
165 if let Some(memory) = self.fetch_core_memory().await? {
166 let already_injected = history
168 .iter()
169 .any(|m| m.role == Role::System && m.content.contains("[Eruka Core Memory]"));
170
171 if !already_injected {
172 history.insert(
173 0,
174 Message {
175 role: Role::System,
176 content: memory,
177 tool_calls: vec![],
178 tool_result: None,
179 },
180 );
181 tracing::info!("Injected Eruka core memory into context");
182 }
183 }
184
185 Ok(())
186 }
187
188 pub async fn search_archival(&self, query: &str) -> Result<Vec<String>> {
190 if !self.config.enabled {
191 return Ok(vec![]);
192 }
193
194 let url = format!("{}/api/v1/context/search", self.config.url);
195 let mut req = self
196 .http
197 .post(&url)
198 .json(&serde_json::json!({"query": query, "limit": 5}));
199 if let Some(key) = &self.config.api_key {
200 req = req
201 .header("X-Service-Key", key.as_str())
202 .header("X-Workspace-Id", "pawan");
203 }
204
205 let resp = req.send().await.map_err(|e| {
206 tracing::warn!("Eruka search failed: {}", e);
207 PawanError::Agent(format!("Eruka search: {}", e))
208 })?;
209
210 if !resp.status().is_success() {
211 return Ok(vec![]);
212 }
213
214 let body = resp.text().await.unwrap_or_default();
215 if let Ok(results) = serde_json::from_str::<Vec<SearchResult>>(&body) {
216 Ok(results.into_iter().filter_map(|r| r.content).collect())
217 } else {
218 Ok(vec![])
219 }
220 }
221
222 pub async fn write_context(
228 &self,
229 path: &str,
230 value: &str,
231 source: &str,
232 confidence: f64,
233 ) -> Result<bool> {
234 if !self.config.enabled {
235 return Ok(false);
236 }
237 let url = format!("{}/api/v1/context", self.config.url);
238 let mut req = self.http.post(&url).json(&serde_json::json!({
239 "path": path,
240 "value": value,
241 "source": source,
242 "confidence": confidence,
243 }));
244 if let Some(key) = &self.config.api_key {
245 req = req
246 .header("X-Service-Key", key.as_str())
247 .header("X-Workspace-Id", "pawan");
248 }
249 match req.send().await {
250 Ok(resp) if resp.status().is_success() => Ok(true),
251 Ok(resp) => {
252 tracing::warn!("Eruka write_context returned {}", resp.status());
253 Ok(false)
254 }
255 Err(e) => {
256 tracing::warn!("Eruka write_context failed (non-fatal): {}", e);
257 Ok(false)
258 }
259 }
260 }
261
262 pub async fn sync_turn(
268 &self,
269 user_message: &str,
270 assistant_message: &str,
271 session_id: &str,
272 ) -> Result<bool> {
273 if !self.config.enabled {
274 return Ok(false);
275 }
276 let user_trim: String = user_message.chars().take(500).collect();
278 let asst_trim: String = assistant_message.chars().take(500).collect();
279 let path = format!("operations/turns/{session_id}");
280 let value = format!("USER: {user_trim} | ASSISTANT: {asst_trim}");
281 self.write_context(&path, &value, "agent_inference", 0.9)
282 .await
283 }
284
285 pub async fn on_pre_compress(&self, messages: &str, session_id: &str) -> Result<bool> {
292 if !self.config.enabled {
293 return Ok(false);
294 }
295 let path = format!("operations/compressed_insights/{session_id}");
296 let summary = if messages.len() > 2000 {
297 format!(
298 "{}...(truncated {} chars)",
299 &messages[..2000],
300 messages.len() - 2000
301 )
302 } else {
303 messages.to_string()
304 };
305 self.write_context(&path, &summary, "agent_inference", 0.8)
306 .await
307 }
308
309 pub async fn prefetch(&self, query: &str, max_tokens: usize) -> Result<Option<String>> {
315 if !self.config.enabled {
316 return Ok(None);
317 }
318
319 let search_url = format!("{}/api/v1/context/search", self.config.url);
321 let mut req = self.http.post(&search_url).json(&serde_json::json!({
322 "query": query,
323 "limit": 5,
324 }));
325 if let Some(key) = &self.config.api_key {
326 req = req
327 .header("X-Service-Key", key.as_str())
328 .header("X-Workspace-Id", "pawan");
329 }
330 let search_text = match req.send().await {
331 Ok(resp) if resp.status().is_success() => resp.text().await.unwrap_or_default(),
332 Ok(resp) => {
333 tracing::warn!("Eruka prefetch search returned {}", resp.status());
334 String::new()
335 }
336 Err(e) => {
337 tracing::warn!("Eruka prefetch search failed: {}", e);
338 return Ok(None);
339 }
340 };
341
342 let compress_url = format!("{}/api/v1/compress", self.config.url);
344 let mut req = self.http.post(&compress_url).json(&serde_json::json!({
345 "task_type": "general",
346 "max_tokens": max_tokens,
347 }));
348 if let Some(key) = &self.config.api_key {
349 req = req
350 .header("X-Service-Key", key.as_str())
351 .header("X-Workspace-Id", "pawan");
352 }
353 let compress_text = match req.send().await {
354 Ok(resp) if resp.status().is_success() => resp.text().await.unwrap_or_default(),
355 Ok(resp) => {
356 tracing::warn!("Eruka prefetch compress returned {}", resp.status());
357 String::new()
358 }
359 Err(e) => {
360 tracing::warn!("Eruka prefetch compress failed: {}", e);
361 String::new()
362 }
363 };
364
365 if search_text.is_empty() && compress_text.is_empty() {
366 return Ok(None);
367 }
368
369 Ok(Some(format!(
370 "[Eruka Prefetch for: {query}]\nSearch results: {search_text}\nCompressed: {compress_text}\n[End Prefetch]"
371 )))
372 }
373
374 pub async fn get_context_cached(
380 &self,
381 path: &str,
382 _session_id: &str,
383 ) -> Result<Option<(String, String)>> {
384 if !self.config.enabled {
385 return Ok(None);
386 }
387
388 let url = format!(
389 "{}/api/v1/context?path={}&include_metadata=false",
390 self.config.url, path
391 );
392 let mut req = self.http.get(&url);
393 if let Some(key) = &self.config.api_key {
394 req = req
395 .header("X-Service-Key", key.as_str())
396 .header("X-Workspace-Id", "pawan");
397 }
398
399 let resp = match req.send().await {
400 Ok(r) if r.status().is_success() => r,
401 Ok(r) => {
402 tracing::warn!("Eruka get_context_cached returned {}", r.status());
403 return Ok(None);
404 }
405 Err(e) => {
406 tracing::warn!("Eruka get_context_cached failed: {}", e);
407 return Ok(None);
408 }
409 };
410
411 let body = resp.text().await.unwrap_or_default();
412 if body.is_empty() {
413 return Ok(None);
414 }
415
416 use std::collections::hash_map::DefaultHasher;
419 use std::hash::{Hash, Hasher};
420 let mut hasher = DefaultHasher::new();
421 body.hash(&mut hasher);
422 let hash = format!("{:016x}", hasher.finish());
423
424 Ok(Some((body, hash)))
425 }
426
427 pub async fn export_context(
433 &self,
434 category: &str,
435 include_metadata: bool,
436 ) -> Result<Option<serde_json::Value>> {
437 if !self.config.enabled {
438 return Ok(None);
439 }
440
441 let url = format!(
442 "{}/api/v1/context?path={}&include_metadata={}",
443 self.config.url, category, include_metadata
444 );
445 let mut req = self.http.get(&url);
446 if let Some(key) = &self.config.api_key {
447 req = req
448 .header("X-Service-Key", key.as_str())
449 .header("X-Workspace-Id", "pawan");
450 }
451
452 let resp = match req.send().await {
453 Ok(r) if r.status().is_success() => r,
454 Ok(r) => {
455 tracing::warn!("Eruka export_context returned {}", r.status());
456 return Ok(None);
457 }
458 Err(e) => {
459 tracing::warn!("Eruka export_context failed: {}", e);
460 return Ok(None);
461 }
462 };
463
464 let body = resp.text().await.unwrap_or_default();
465 let context_data: serde_json::Value =
466 serde_json::from_str(&body).unwrap_or(serde_json::Value::Null);
467
468 Ok(Some(serde_json::json!({
469 "export_format": "eruka_context_core_v1",
470 "category": category,
471 "data": context_data,
472 "exported_at": chrono::Utc::now().to_rfc3339(),
473 "instructions": "Import this bundle into another Eruka instance via eruka_write_context for each field.",
474 })))
475 }
476
477 pub async fn archive_session(&self, session: &Session) -> Result<()> {
479 if !self.config.enabled {
480 return Ok(());
481 }
482
483 let user_messages: Vec<&str> = session
485 .messages
486 .iter()
487 .filter(|m| m.role == Role::User)
488 .map(|m| m.content.as_str())
489 .collect();
490
491 let assistant_messages: Vec<&str> = session
492 .messages
493 .iter()
494 .filter(|m| m.role == Role::Assistant)
495 .map(|m| m.content.as_str())
496 .collect();
497
498 if user_messages.is_empty() {
499 return Ok(());
500 }
501
502 let summary = format!(
503 "Session {} (model: {}, {} messages)\nUser topics: {}\nAssistant summary: {}",
504 session.id,
505 session.model,
506 session.messages.len(),
507 user_messages.join(" | "),
508 assistant_messages
509 .last()
510 .map(|s| {
511 let trunc: String = s.chars().take(500).collect();
512 trunc
513 })
514 .unwrap_or_default(),
515 );
516
517 let url = format!("{}/api/v1/context", self.config.url);
518 let mut req = self.http.post(&url).json(&serde_json::json!({
519 "path": format!("operations/sessions/{}", session.id),
520 "value": summary,
521 "source": "agent",
522 }));
523 if let Some(key) = &self.config.api_key {
524 req = req
525 .header("X-Service-Key", key.as_str())
526 .header("X-Workspace-Id", "pawan");
527 }
528
529 match req.send().await {
530 Ok(resp) => {
531 if resp.status().is_success() {
532 tracing::info!("Archived session {} to Eruka", session.id);
533 } else {
534 tracing::warn!("Eruka archive returned {}", resp.status());
535 }
536 }
537 Err(e) => {
538 tracing::warn!("Eruka archive failed (non-fatal): {}", e);
539 }
540 }
541
542 Ok(())
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549
550 #[test]
551 fn default_config_disabled() {
552 let config = ErukaConfig::default();
553 assert!(!config.enabled);
554 assert_eq!(config.url, "http://localhost:8081");
555 assert_eq!(config.core_max_tokens, 500);
556 }
557
558 #[test]
559 fn client_respects_enabled() {
560 let config = ErukaConfig::default();
561 let client = ErukaClient::new(config);
562 assert!(!client.is_enabled());
563 }
564
565 #[tokio::test]
566 async fn disabled_client_noops() {
567 let client = ErukaClient::new(ErukaConfig::default());
568 let mut history = vec![];
569 client.inject_core_memory(&mut history).await.unwrap();
570 assert!(history.is_empty());
571
572 let results = client.search_archival("test").await.unwrap();
573 assert!(results.is_empty());
574 }
575
576 #[test]
577 fn config_toml_parsing() {
578 let toml = r#"
579enabled = true
580url = "http://eruka.example.com:9090"
581api_key = "secret-key"
582core_max_tokens = 1000
583"#;
584 let config: ErukaConfig = toml::from_str(toml).expect("should parse");
585 assert!(config.enabled);
586 assert_eq!(config.url, "http://eruka.example.com:9090");
587 assert_eq!(config.api_key, Some("secret-key".into()));
588 assert_eq!(config.core_max_tokens, 1000);
589 }
590
591 #[test]
592 fn config_toml_defaults() {
593 let toml = "enabled = true\n";
594 let config: ErukaConfig = toml::from_str(toml).expect("should parse");
595 assert!(config.enabled);
596 assert_eq!(config.url, "http://localhost:8081");
597 assert_eq!(config.core_max_tokens, 500);
598 assert_eq!(config.api_key, None);
599 }
600
601 #[test]
602 fn context_response_deserialization() {
603 let json = r#"{"fields":[{"name":"project","value":"pawan","category":"core"},{"name":"role","value":"coding agent"}]}"#;
604 let ctx: ContextResponse = serde_json::from_str(json).unwrap();
605 let fields = ctx.fields.unwrap();
606 assert_eq!(fields.len(), 2);
607 assert_eq!(fields[0].name.as_deref(), Some("project"));
608 assert_eq!(fields[0].value.as_deref(), Some("pawan"));
609 assert_eq!(fields[0].category.as_deref(), Some("core"));
610 assert_eq!(fields[1].category, None);
611 }
612
613 #[test]
614 fn context_response_empty_fields() {
615 let json = r#"{"fields":[]}"#;
616 let ctx: ContextResponse = serde_json::from_str(json).unwrap();
617 assert!(ctx.fields.unwrap().is_empty());
618 }
619
620 #[test]
621 fn context_response_missing_fields() {
622 let json = r#"{}"#;
623 let ctx: ContextResponse = serde_json::from_str(json).unwrap();
624 assert!(ctx.fields.is_none());
625 }
626
627 #[test]
628 fn search_result_deserialization() {
629 let json = r#"[{"content":"relevant info","field_name":"notes","score":0.95},{"content":null,"score":0.5}]"#;
630 let results: Vec<SearchResult> = serde_json::from_str(json).unwrap();
631 assert_eq!(results.len(), 2);
632 assert_eq!(results[0].content.as_deref(), Some("relevant info"));
633 assert_eq!(results[0].score, Some(0.95));
634 assert!(results[1].content.is_none());
635 }
636
637 #[tokio::test]
638 async fn disabled_archive_noops() {
639 use crate::agent::session::Session;
640 let client = ErukaClient::new(ErukaConfig::default());
641 let session = Session {
642 notes: String::new(),
643 parent_id: None,
644 root_id: None,
645 branch_label: None,
646 branch_depth: 0,
647 labels: vec![],
648 id: "test-123".into(),
649 model: "test-model".into(),
650 messages: vec![],
651 created_at: "2026-04-09T00:00:00Z".into(),
652 updated_at: "2026-04-09T00:00:00Z".into(),
653 total_tokens: 0,
654 iteration_count: 0,
655 tags: Vec::new(),
656 };
657 client.archive_session(&session).await.unwrap();
659 }
660
661 #[tokio::test]
662 async fn inject_dedup_prevents_double_injection() {
663 let history = [
665 Message {
666 role: Role::System,
667 content: "[Eruka Core Memory]\nproject: pawan\n[End Core Memory]".into(),
668 tool_calls: vec![],
669 tool_result: None,
670 },
671 Message {
672 role: Role::User,
673 content: "hello".into(),
674 tool_calls: vec![],
675 tool_result: None,
676 },
677 ];
678 let already = history
681 .iter()
682 .any(|m| m.role == Role::System && m.content.contains("[Eruka Core Memory]"));
683 assert!(already, "Should detect existing injection");
684 }
685
686 #[test]
687 fn default_config_has_no_api_key() {
688 let config = ErukaConfig::default();
691 assert_eq!(config.api_key, None, "default api_key must be None");
692 }
693
694 #[test]
695 fn config_partial_override_keeps_defaults() {
696 let toml = "enabled = true\n";
700 let config: ErukaConfig = toml::from_str(toml).expect("should parse");
701 assert!(config.enabled);
702 assert_eq!(
703 config.url, "http://localhost:8081",
704 "url default must apply"
705 );
706 assert_eq!(
707 config.core_max_tokens, 500,
708 "core_max_tokens default must apply"
709 );
710 assert_eq!(config.api_key, None, "api_key default must apply");
711 }
712
713 #[test]
714 fn search_result_deserialize_with_all_null_fields() {
715 let json = r#"[{"content":null,"field_name":null,"score":null}]"#;
719 let results: Vec<SearchResult> = serde_json::from_str(json).unwrap();
720 assert_eq!(results.len(), 1);
721 assert!(results[0].content.is_none());
722 assert!(results[0].field_name.is_none());
723 assert!(results[0].score.is_none());
724 }
725
726 #[test]
727 fn context_field_deserialize_without_category() {
728 let json = r#"{"name":"model","value":"qwen3.5-122b"}"#;
731 let field: ContextField = serde_json::from_str(json).unwrap();
732 assert_eq!(field.name.as_deref(), Some("model"));
733 assert_eq!(field.value.as_deref(), Some("qwen3.5-122b"));
734 assert!(field.category.is_none(), "category must default to None");
735 }
736
737 #[tokio::test]
742 async fn sync_turn_caps_long_messages_at_500_chars_each() {
743 let client = ErukaClient::new(ErukaConfig::default());
746 let long_user = "a".repeat(1200);
747 let long_asst = "b".repeat(1200);
748 let result = client
750 .sync_turn(&long_user, &long_asst, "session-long")
751 .await;
752 assert!(result.is_ok(), "long messages must not panic");
753 }
754
755 #[tokio::test]
756 async fn archive_enabled_with_no_user_messages_short_circuits() {
757 let config = ErukaConfig {
762 enabled: true,
763 url: "http://127.0.0.1:1".into(), api_key: None,
765 core_max_tokens: 500,
766 };
767 let client = ErukaClient::new(config);
768 let session = Session {
769 notes: String::new(),
770 parent_id: None,
771 root_id: None,
772 branch_label: None,
773 branch_depth: 0,
774 labels: vec![],
775 id: "assistant-only".into(),
776 model: "m".into(),
777 messages: vec![Message {
778 role: Role::Assistant,
779 content: "hi".into(),
780 tool_calls: vec![],
781 tool_result: None,
782 }],
783 created_at: "2026-04-10T00:00:00Z".into(),
784 updated_at: "2026-04-10T00:00:00Z".into(),
785 total_tokens: 0,
786 iteration_count: 0,
787 tags: Vec::new(),
788 };
789 let result = tokio::time::timeout(
793 std::time::Duration::from_millis(500),
794 client.archive_session(&session),
795 )
796 .await
797 .expect("archive must not hang — empty user messages should short-circuit");
798 assert!(result.is_ok());
799 }
800
801 #[tokio::test]
804 async fn write_context_disabled_returns_false() {
805 let client = ErukaClient::new(ErukaConfig::default());
806 let ok = client
807 .write_context("identity/name", "pawan", "test", 1.0)
808 .await
809 .unwrap();
810 assert!(
811 !ok,
812 "disabled client must return false without calling network"
813 );
814 }
815
816 #[tokio::test]
817 async fn sync_turn_disabled_returns_false() {
818 let client = ErukaClient::new(ErukaConfig::default());
819 let ok = client.sync_turn("hello", "world", "ses_abc").await.unwrap();
820 assert!(!ok, "disabled client must short-circuit");
821 }
822
823 #[tokio::test]
824 async fn on_pre_compress_disabled_returns_false() {
825 let client = ErukaClient::new(ErukaConfig::default());
826 let ok = client
827 .on_pre_compress("some messages", "ses_abc")
828 .await
829 .unwrap();
830 assert!(!ok, "disabled client must short-circuit");
831 }
832
833 #[tokio::test]
834 async fn prefetch_disabled_returns_none() {
835 let client = ErukaClient::new(ErukaConfig::default());
836 let result = client.prefetch("test query", 1000).await.unwrap();
837 assert!(result.is_none(), "disabled client must return None");
838 }
839
840 #[tokio::test]
841 async fn get_context_cached_disabled_returns_none() {
842 let client = ErukaClient::new(ErukaConfig::default());
843 let result = client
844 .get_context_cached("identity/*", "ses_abc")
845 .await
846 .unwrap();
847 assert!(result.is_none(), "disabled client must return None");
848 }
849
850 #[tokio::test]
851 async fn export_context_disabled_returns_none() {
852 let client = ErukaClient::new(ErukaConfig::default());
853 let result = client.export_context("*", true).await.unwrap();
854 assert!(result.is_none(), "disabled client must return None");
855 }
856}