Skip to main content

openhawk_memory/
lib.rs

1// hawk-memory: Aura bridge + in-memory fallback
2//
3// Aura: https://github.com/ojuschugh1/aura
4//
5// Aura runs as a local daemon at localhost:7437 and exposes:
6//   POST /memory/add    { "key": "...", "value": "..." }
7//   GET  /memory/get?key=...
8//   GET  /memory/ls
9//   DELETE /memory/rm?key=...
10//
11// When the Aura daemon is running, AuraMemoryStore delegates all operations
12// to it — giving persistent cross-tool memory (Claude Code, Cursor, Kiro,
13// Gemini CLI all share the same store).
14//
15// When Aura is not running, InMemoryStore is used as a fallback so the
16// interface always works.
17
18use std::collections::HashMap;
19use std::sync::{Arc, Mutex};
20
21use chrono::Utc;
22use serde::{Deserialize, Serialize};
23use serde_json::{json, Value};
24use thiserror::Error;
25
26// ── Error ─────────────────────────────────────────────────────────────────────
27
28#[derive(Debug, Error)]
29pub enum MemoryError {
30    #[error("key not found: {0}")]
31    KeyNotFound(String),
32    #[error("lock poisoned")]
33    LockPoisoned,
34    #[error("invalid params: {0}")]
35    InvalidParams(String),
36}
37
38// ── Scope ─────────────────────────────────────────────────────────────────────
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
41pub enum MemoryScope {
42    Global,
43    Session(String),
44    Agent(u32),
45}
46
47impl MemoryScope {
48    fn prefix(&self) -> String {
49        match self {
50            MemoryScope::Global => "global".to_owned(),
51            MemoryScope::Session(id) => format!("session:{id}"),
52            MemoryScope::Agent(pid) => format!("agent:{pid}"),
53        }
54    }
55}
56
57// ── Entry ─────────────────────────────────────────────────────────────────────
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct MemoryEntry {
61    pub key: String,
62    pub value: Vec<u8>,
63    pub scope: MemoryScope,
64    pub timestamp: String,
65    pub source_agent: u32,
66}
67
68// ── Trait ─────────────────────────────────────────────────────────────────────
69
70pub trait SharedMemory {
71    fn store(
72        &self,
73        scope: MemoryScope,
74        key: &str,
75        value: &[u8],
76        source_agent: u32,
77    ) -> Result<(), MemoryError>;
78
79    fn query(&self, key: &str) -> Result<Option<MemoryEntry>, MemoryError>;
80
81    fn archive_session(&self, session_id: &str) -> Result<(), MemoryError>;
82}
83
84// ── InMemoryStore ─────────────────────────────────────────────────────────────
85
86// Pure-Rust in-memory implementation.
87//
88// Go FFI note: a production build would call into the Aura Go library via cgo.
89// The bridge would look roughly like:
90//
91//   extern "C" {
92//       fn aura_store(scope: *const c_char, key: *const c_char,
93//                     value: *const u8, len: usize, agent: u32) -> c_int;
94//       fn aura_query(key: *const c_char, out: *mut AuraEntry) -> c_int;
95//       fn aura_archive_session(session_id: *const c_char) -> c_int;
96//   }
97//
98// The cc build script would compile the cgo shim and link it here.
99// This in-memory implementation satisfies the same interface for testing
100// and environments where the Aura Go library is not available.
101
102struct StoreState {
103    entries: HashMap<String, MemoryEntry>,
104    archive: HashMap<String, MemoryEntry>,
105}
106
107impl StoreState {
108    fn new() -> Self {
109        Self { entries: HashMap::new(), archive: HashMap::new() }
110    }
111
112    fn composite_key(scope: &MemoryScope, key: &str) -> String {
113        format!("{}:{}", scope.prefix(), key)
114    }
115}
116
117#[derive(Clone)]
118pub struct InMemoryStore {
119    state: Arc<Mutex<StoreState>>,
120}
121
122impl InMemoryStore {
123    pub fn new() -> Self {
124        Self { state: Arc::new(Mutex::new(StoreState::new())) }
125    }
126}
127
128impl Default for InMemoryStore {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl SharedMemory for InMemoryStore {
135    fn store(
136        &self,
137        scope: MemoryScope,
138        key: &str,
139        value: &[u8],
140        source_agent: u32,
141    ) -> Result<(), MemoryError> {
142        let mut state = self.state.lock().map_err(|_| MemoryError::LockPoisoned)?;
143        let ckey = StoreState::composite_key(&scope, key);
144        state.entries.insert(
145            ckey,
146            MemoryEntry {
147                key: key.to_owned(),
148                value: value.to_vec(),
149                scope,
150                timestamp: Utc::now().to_rfc3339(),
151                source_agent,
152            },
153        );
154        Ok(())
155    }
156
157    // Query priority: agent scope (any agent) → session scope (any session) → global.
158    // The caller supplies only the bare key; we scan all composite keys that end with
159    // ":<key>" in priority order.
160    fn query(&self, key: &str) -> Result<Option<MemoryEntry>, MemoryError> {
161        let state = self.state.lock().map_err(|_| MemoryError::LockPoisoned)?;
162        let suffix = format!(":{key}");
163
164        // 1. Agent scope
165        for (ckey, entry) in &state.entries {
166            if ckey.starts_with("agent:") && ckey.ends_with(&suffix) {
167                return Ok(Some(entry.clone()));
168            }
169        }
170        // 2. Session scope
171        for (ckey, entry) in &state.entries {
172            if ckey.starts_with("session:") && ckey.ends_with(&suffix) {
173                return Ok(Some(entry.clone()));
174            }
175        }
176        // 3. Global scope
177        let global_key = format!("global:{key}");
178        Ok(state.entries.get(&global_key).cloned())
179    }
180
181    fn archive_session(&self, session_id: &str) -> Result<(), MemoryError> {
182        let mut state = self.state.lock().map_err(|_| MemoryError::LockPoisoned)?;
183        let prefix = format!("session:{session_id}:");
184        let session_keys: Vec<String> =
185            state.entries.keys().filter(|k| k.starts_with(&prefix)).cloned().collect();
186        for k in session_keys {
187            if let Some(entry) = state.entries.remove(&k) {
188                state.archive.insert(k, entry);
189            }
190        }
191        Ok(())
192    }
193}
194
195impl InMemoryStore {
196    /// Returns archived entries for a session (for inspection / tests).
197    pub fn archived_entries(&self, session_id: &str) -> Vec<MemoryEntry> {
198        let state = self.state.lock().unwrap();
199        let prefix = format!("session:{session_id}:");
200        state
201            .archive
202            .iter()
203            .filter(|(k, _)| k.starts_with(&prefix))
204            .map(|(_, v)| v.clone())
205            .collect()
206    }
207}
208
209// ── Aura HTTP bridge ──────────────────────────────────────────────────────────
210//
211// Aura daemon REST API (localhost:7437):
212//
213//   POST   /memory/add     body: { "key": "...", "value": "..." }
214//   GET    /memory/get     query: ?key=...
215//   GET    /memory/ls      returns: [{ "key": "...", "value": "...", ... }]
216//   DELETE /memory/rm      query: ?key=...
217//
218// aura verify [--session <id>]   -- runs claimcheck internally
219// aura compact                   -- runs sqz internally
220// aura scan                      -- runs ghostdep internally
221// aura cost [--daily]            -- token cost report
222
223const AURA_BASE: &str = "http://localhost:7437";
224#[allow(dead_code)]
225
226/// Returns true if the Aura daemon is reachable at localhost:7437.
227pub fn aura_available() -> bool {
228    // Use a TCP connect check — no HTTP client dep needed
229    std::net::TcpStream::connect_timeout(
230        &"127.0.0.1:7437".parse().unwrap(),
231        std::time::Duration::from_millis(200),
232    ).is_ok()
233}
234
235/// Returns true if the `aura` CLI binary is on PATH.
236pub fn aura_cli_available() -> bool {
237    std::process::Command::new("aura").arg("version").output().is_ok()
238}
239
240/// Aura memory entry as returned by GET /memory/ls.
241#[derive(Debug, Deserialize)]
242pub struct AuraMemoryItem {
243    pub key: String,
244    pub value: String,
245    #[serde(default)]
246    pub agent_id: String,
247    #[serde(default)]
248    pub timestamp: String,
249}
250
251/// Call `aura memory add <key> <value>` via CLI.
252pub fn aura_memory_add(key: &str, value: &str) -> bool {
253    std::process::Command::new("aura")
254        .args(["memory", "add", key, value])
255        .output()
256        .map(|o| o.status.success())
257        .unwrap_or(false)
258}
259
260/// Call `aura memory get <key>` via CLI and return the value.
261pub fn aura_memory_get(key: &str) -> Option<String> {
262    let output = std::process::Command::new("aura")
263        .args(["memory", "get", key])
264        .output()
265        .ok()?;
266    if output.status.success() {
267        let s = String::from_utf8(output.stdout).ok()?;
268        let trimmed = s.trim().to_string();
269        if trimmed.is_empty() { None } else { Some(trimmed) }
270    } else {
271        None
272    }
273}
274
275/// Call `aura memory ls --json` and return all entries.
276pub fn aura_memory_ls() -> Vec<AuraMemoryItem> {
277    let output = std::process::Command::new("aura")
278        .args(["memory", "ls", "--json"])
279        .output();
280    let output = match output {
281        Ok(o) if o.status.success() => o,
282        _ => return Vec::new(),
283    };
284    serde_json::from_slice(&output.stdout).unwrap_or_default()
285}
286
287/// Call `aura memory rm <key>` via CLI.
288pub fn aura_memory_rm(key: &str) -> bool {
289    std::process::Command::new("aura")
290        .args(["memory", "rm", key])
291        .output()
292        .map(|o| o.status.success())
293        .unwrap_or(false)
294}
295
296/// AuraMemoryStore — delegates to the Aura daemon when running,
297/// falls back to InMemoryStore otherwise.
298pub struct AuraMemoryStore {
299    fallback: InMemoryStore,
300}
301
302impl AuraMemoryStore {
303    pub fn new() -> Self {
304        Self { fallback: InMemoryStore::new() }
305    }
306
307    /// Store a key-value pair. Uses Aura when available, fallback otherwise.
308    pub fn store_kv(&self, key: &str, value: &str) -> bool {
309        if aura_cli_available() {
310            aura_memory_add(key, value)
311        } else {
312            self.fallback
313                .store(MemoryScope::Global, key, value.as_bytes(), 0)
314                .is_ok()
315        }
316    }
317
318    /// Retrieve a value by key. Uses Aura when available, fallback otherwise.
319    pub fn get_kv(&self, key: &str) -> Option<String> {
320        if aura_cli_available() {
321            aura_memory_get(key)
322        } else {
323            self.fallback
324                .query(key)
325                .ok()
326                .flatten()
327                .map(|e| String::from_utf8_lossy(&e.value).into_owned())
328        }
329    }
330
331    /// List all entries. Uses Aura when available, fallback otherwise.
332    pub fn list(&self) -> Vec<(String, String)> {
333        if aura_cli_available() {
334            aura_memory_ls()
335                .into_iter()
336                .map(|item| (item.key, item.value))
337                .collect()
338        } else {
339            let state = self.fallback.state.lock().unwrap();
340            state.entries.values()
341                .map(|e| (e.key.clone(), String::from_utf8_lossy(&e.value).into_owned()))
342                .collect()
343        }
344    }
345
346    /// Delete a key. Uses Aura when available, fallback otherwise.
347    pub fn remove(&self, key: &str) -> bool {
348        if aura_cli_available() {
349            aura_memory_rm(key)
350        } else {
351            // remove from fallback by overwriting with empty — no delete API
352            self.fallback
353                .store(MemoryScope::Global, key, b"", 0)
354                .is_ok()
355        }
356    }
357}
358
359impl Default for AuraMemoryStore {
360    fn default() -> Self {
361        Self::new()
362    }
363}
364
365impl SharedMemory for AuraMemoryStore {
366    fn store(&self, scope: MemoryScope, key: &str, value: &[u8], source_agent: u32) -> Result<(), MemoryError> {
367        let value_str = String::from_utf8_lossy(value);
368        // prefix key with scope so Aura stores them distinctly
369        let scoped_key = format!("{}:{}", scope.prefix(), key);
370        if aura_cli_available() {
371            aura_memory_add(&scoped_key, &value_str);
372        }
373        // always write to fallback so in-process queries work
374        self.fallback.store(scope, key, value, source_agent)
375    }
376
377    fn query(&self, key: &str) -> Result<Option<MemoryEntry>, MemoryError> {
378        // try Aura first for the global scope key
379        if aura_cli_available() {
380            let scoped_key = format!("global:{key}");
381            if let Some(val) = aura_memory_get(&scoped_key) {
382                return Ok(Some(MemoryEntry {
383                    key: key.to_owned(),
384                    value: val.into_bytes(),
385                    scope: MemoryScope::Global,
386                    timestamp: Utc::now().to_rfc3339(),
387                    source_agent: 0,
388                }));
389            }
390        }
391        // fall back to in-process store
392        self.fallback.query(key)
393    }
394
395    fn archive_session(&self, session_id: &str) -> Result<(), MemoryError> {
396        self.fallback.archive_session(session_id)
397    }
398}
399
400// ── MCP interface ─────────────────────────────────────────────────────────────
401
402/// Exposes memory operations as MCP tool provider endpoints.
403///
404/// Supported tool names:
405///   - "memory_store"  params: { scope, key, value (base64), source_agent }
406///   - "memory_query"  params: { key }
407pub struct McpMemoryProvider {
408    store: InMemoryStore,
409}
410
411impl McpMemoryProvider {
412    pub fn new(store: InMemoryStore) -> Self {
413        Self { store }
414    }
415
416    pub fn handle_tool_call(&self, tool_name: &str, params: Value) -> Value {
417        match tool_name {
418            "memory_store" => self.mcp_store(params),
419            "memory_query" => self.mcp_query(params),
420            _ => json!({ "error": format!("unknown tool: {tool_name}") }),
421        }
422    }
423
424    fn mcp_store(&self, params: Value) -> Value {
425        let scope = match parse_scope(&params) {
426            Ok(s) => s,
427            Err(e) => return json!({ "error": e }),
428        };
429        let key = match params.get("key").and_then(Value::as_str) {
430            Some(k) => k.to_owned(),
431            None => return json!({ "error": "missing field: key" }),
432        };
433        let value_b64 = match params.get("value").and_then(Value::as_str) {
434            Some(v) => v.to_owned(),
435            None => return json!({ "error": "missing field: value" }),
436        };
437        let value = match base64_decode(&value_b64) {
438            Ok(v) => v,
439            Err(e) => return json!({ "error": e }),
440        };
441        let source_agent = params
442            .get("source_agent")
443            .and_then(Value::as_u64)
444            .unwrap_or(0) as u32;
445
446        match self.store.store(scope, &key, &value, source_agent) {
447            Ok(()) => json!({ "ok": true }),
448            Err(e) => json!({ "error": e.to_string() }),
449        }
450    }
451
452    fn mcp_query(&self, params: Value) -> Value {
453        let key = match params.get("key").and_then(Value::as_str) {
454            Some(k) => k.to_owned(),
455            None => return json!({ "error": "missing field: key" }),
456        };
457        match self.store.query(&key) {
458            Ok(Some(entry)) => json!({
459                "found": true,
460                "key": entry.key,
461                "value": base64_encode(&entry.value),
462                "scope": scope_to_str(&entry.scope),
463                "timestamp": entry.timestamp,
464                "source_agent": entry.source_agent,
465            }),
466            Ok(None) => json!({ "found": false }),
467            Err(e) => json!({ "error": e.to_string() }),
468        }
469    }
470}
471
472// ── A2A interface ─────────────────────────────────────────────────────────────
473
474/// Exposes memory operations as A2A agent card endpoints.
475///
476/// Supported actions:
477///   - "store"  params: { scope, key, value (base64), source_agent }
478///   - "query"  params: { key }
479pub struct A2aMemoryCard {
480    store: InMemoryStore,
481}
482
483impl A2aMemoryCard {
484    pub fn new(store: InMemoryStore) -> Self {
485        Self { store }
486    }
487
488    pub fn handle_request(&self, action: &str, params: Value) -> Value {
489        match action {
490            "store" => self.a2a_store(params),
491            "query" => self.a2a_query(params),
492            _ => json!({ "error": format!("unknown action: {action}") }),
493        }
494    }
495
496    fn a2a_store(&self, params: Value) -> Value {
497        let scope = match parse_scope(&params) {
498            Ok(s) => s,
499            Err(e) => return json!({ "error": e }),
500        };
501        let key = match params.get("key").and_then(Value::as_str) {
502            Some(k) => k.to_owned(),
503            None => return json!({ "error": "missing field: key" }),
504        };
505        let value_b64 = match params.get("value").and_then(Value::as_str) {
506            Some(v) => v.to_owned(),
507            None => return json!({ "error": "missing field: value" }),
508        };
509        let value = match base64_decode(&value_b64) {
510            Ok(v) => v,
511            Err(e) => return json!({ "error": e }),
512        };
513        let source_agent = params
514            .get("source_agent")
515            .and_then(Value::as_u64)
516            .unwrap_or(0) as u32;
517
518        match self.store.store(scope, &key, &value, source_agent) {
519            Ok(()) => json!({ "status": "ok" }),
520            Err(e) => json!({ "error": e.to_string() }),
521        }
522    }
523
524    fn a2a_query(&self, params: Value) -> Value {
525        let key = match params.get("key").and_then(Value::as_str) {
526            Some(k) => k.to_owned(),
527            None => return json!({ "error": "missing field: key" }),
528        };
529        match self.store.query(&key) {
530            Ok(Some(entry)) => json!({
531                "status": "ok",
532                "key": entry.key,
533                "value": base64_encode(&entry.value),
534                "scope": scope_to_str(&entry.scope),
535                "timestamp": entry.timestamp,
536                "source_agent": entry.source_agent,
537            }),
538            Ok(None) => json!({ "status": "not_found" }),
539            Err(e) => json!({ "error": e.to_string() }),
540        }
541    }
542}
543
544// ── Helpers ───────────────────────────────────────────────────────────────────
545
546fn parse_scope(params: &Value) -> Result<MemoryScope, String> {
547    let scope_str = params
548        .get("scope")
549        .and_then(Value::as_str)
550        .unwrap_or("global");
551    if scope_str == "global" {
552        return Ok(MemoryScope::Global);
553    }
554    if let Some(id) = scope_str.strip_prefix("session:") {
555        return Ok(MemoryScope::Session(id.to_owned()));
556    }
557    if let Some(pid_str) = scope_str.strip_prefix("agent:") {
558        let pid: u32 = pid_str
559            .parse()
560            .map_err(|_| format!("invalid agent pid: {pid_str}"))?;
561        return Ok(MemoryScope::Agent(pid));
562    }
563    Err(format!("unknown scope: {scope_str}"))
564}
565
566fn scope_to_str(scope: &MemoryScope) -> String {
567    match scope {
568        MemoryScope::Global => "global".to_owned(),
569        MemoryScope::Session(id) => format!("session:{id}"),
570        MemoryScope::Agent(pid) => format!("agent:{pid}"),
571    }
572}
573
574fn base64_encode(data: &[u8]) -> String {
575    use std::fmt::Write;
576    // Simple base64 without external dep — use the alphabet directly.
577    const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
578    let mut out = String::with_capacity((data.len() + 2) / 3 * 4);
579    for chunk in data.chunks(3) {
580        let b0 = chunk[0] as usize;
581        let b1 = if chunk.len() > 1 { chunk[1] as usize } else { 0 };
582        let b2 = if chunk.len() > 2 { chunk[2] as usize } else { 0 };
583        let _ = write!(out, "{}", ALPHABET[b0 >> 2] as char);
584        let _ = write!(out, "{}", ALPHABET[((b0 & 3) << 4) | (b1 >> 4)] as char);
585        if chunk.len() > 1 {
586            let _ = write!(out, "{}", ALPHABET[((b1 & 0xf) << 2) | (b2 >> 6)] as char);
587        } else {
588            out.push('=');
589        }
590        if chunk.len() > 2 {
591            let _ = write!(out, "{}", ALPHABET[b2 & 0x3f] as char);
592        } else {
593            out.push('=');
594        }
595    }
596    out
597}
598
599fn base64_decode(s: &str) -> Result<Vec<u8>, String> {
600    fn val(c: u8) -> Result<u8, String> {
601        match c {
602            b'A'..=b'Z' => Ok(c - b'A'),
603            b'a'..=b'z' => Ok(c - b'a' + 26),
604            b'0'..=b'9' => Ok(c - b'0' + 52),
605            b'+' => Ok(62),
606            b'/' => Ok(63),
607            b'=' => Ok(0),
608            _ => Err(format!("invalid base64 char: {c}")),
609        }
610    }
611    let bytes = s.as_bytes();
612    if bytes.len() % 4 != 0 {
613        return Err("base64 length must be a multiple of 4".to_owned());
614    }
615    let mut out = Vec::with_capacity(bytes.len() / 4 * 3);
616    for chunk in bytes.chunks(4) {
617        let v0 = val(chunk[0])?;
618        let v1 = val(chunk[1])?;
619        let v2 = val(chunk[2])?;
620        let v3 = val(chunk[3])?;
621        out.push((v0 << 2) | (v1 >> 4));
622        if chunk[2] != b'=' {
623            out.push((v1 << 4) | (v2 >> 2));
624        }
625        if chunk[3] != b'=' {
626            out.push((v2 << 6) | v3);
627        }
628    }
629    Ok(out)
630}
631
632// ── Tests ─────────────────────────────────────────────────────────────────────
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    fn store() -> InMemoryStore {
639        InMemoryStore::new()
640    }
641
642    #[allow(dead_code)]
643    fn bytes(s: &str) -> Vec<u8> {
644        s.as_bytes().to_vec()
645    }
646
647    // ── Task 11.3: SharedMemory unit tests ────────────────────────────────────
648
649    #[test]
650    fn global_scope_store_and_query_round_trip() {
651        let s = store();
652        s.store(MemoryScope::Global, "answer", b"42", 1).unwrap();
653        let entry = s.query("answer").unwrap().expect("entry should exist");
654        assert_eq!(entry.value, b"42");
655        assert_eq!(entry.scope, MemoryScope::Global);
656        assert_eq!(entry.source_agent, 1);
657    }
658
659    #[test]
660    fn session_scope_store_and_query_round_trip() {
661        let s = store();
662        s.store(MemoryScope::Session("sess-1".into()), "ctx", b"hello", 2).unwrap();
663        let entry = s.query("ctx").unwrap().expect("entry should exist");
664        assert_eq!(entry.value, b"hello");
665        assert!(matches!(entry.scope, MemoryScope::Session(ref id) if id == "sess-1"));
666    }
667
668    #[test]
669    fn agent_scope_store_and_query_round_trip() {
670        let s = store();
671        s.store(MemoryScope::Agent(99), "private", b"secret", 99).unwrap();
672        let entry = s.query("private").unwrap().expect("entry should exist");
673        assert_eq!(entry.value, b"secret");
674        assert_eq!(entry.scope, MemoryScope::Agent(99));
675    }
676
677    #[test]
678    fn query_returns_none_for_missing_key() {
679        let s = store();
680        assert!(s.query("nonexistent").unwrap().is_none());
681    }
682
683    #[test]
684    fn global_scope_persists_after_session_archive() {
685        let s = store();
686        s.store(MemoryScope::Global, "persistent", b"yes", 1).unwrap();
687        s.store(MemoryScope::Session("sess-a".into()), "temp", b"no", 1).unwrap();
688
689        s.archive_session("sess-a").unwrap();
690
691        // Global entry still queryable
692        let entry = s.query("persistent").unwrap().expect("global entry should survive archival");
693        assert_eq!(entry.value, b"yes");
694    }
695
696    #[test]
697    fn session_scope_is_archived_on_session_end() {
698        let s = store();
699        s.store(MemoryScope::Session("sess-b".into()), "work", b"data", 5).unwrap();
700
701        // Before archival: queryable
702        assert!(s.query("work").unwrap().is_some());
703
704        s.archive_session("sess-b").unwrap();
705
706        // After archival: no longer in live store
707        assert!(s.query("work").unwrap().is_none());
708
709        // But present in archive
710        let archived = s.archived_entries("sess-b");
711        assert_eq!(archived.len(), 1);
712        assert_eq!(archived[0].key, "work");
713    }
714
715    #[test]
716    fn archive_session_only_removes_matching_session() {
717        let s = store();
718        s.store(MemoryScope::Session("sess-x".into()), "x_key", b"x", 1).unwrap();
719        s.store(MemoryScope::Session("sess-y".into()), "y_key", b"y", 2).unwrap();
720
721        s.archive_session("sess-x").unwrap();
722
723        assert!(s.query("x_key").unwrap().is_none());
724        assert!(s.query("y_key").unwrap().is_some());
725    }
726
727    #[test]
728    fn agent_scope_is_private_to_agent() {
729        let s = store();
730        s.store(MemoryScope::Agent(10), "secret", b"agent10", 10).unwrap();
731        s.store(MemoryScope::Agent(20), "secret", b"agent20", 20).unwrap();
732
733        // query returns one of the agent-scoped entries (agent scope has highest priority)
734        let entry = s.query("secret").unwrap().expect("should find an agent entry");
735        assert!(matches!(entry.scope, MemoryScope::Agent(_)));
736        // The value belongs to one of the agents, not mixed
737        assert!(entry.value == b"agent10" || entry.value == b"agent20");
738    }
739
740    #[test]
741    fn query_priority_agent_over_session_over_global() {
742        let s = store();
743        s.store(MemoryScope::Global, "k", b"global", 0).unwrap();
744        s.store(MemoryScope::Session("s1".into()), "k", b"session", 1).unwrap();
745        s.store(MemoryScope::Agent(7), "k", b"agent", 7).unwrap();
746
747        let entry = s.query("k").unwrap().unwrap();
748        assert_eq!(entry.value, b"agent");
749    }
750
751    #[test]
752    fn query_falls_back_to_session_when_no_agent_entry() {
753        let s = store();
754        s.store(MemoryScope::Global, "k", b"global", 0).unwrap();
755        s.store(MemoryScope::Session("s1".into()), "k", b"session", 1).unwrap();
756
757        let entry = s.query("k").unwrap().unwrap();
758        assert_eq!(entry.value, b"session");
759    }
760
761    #[test]
762    fn query_falls_back_to_global_when_no_session_or_agent_entry() {
763        let s = store();
764        s.store(MemoryScope::Global, "k", b"global", 0).unwrap();
765
766        let entry = s.query("k").unwrap().unwrap();
767        assert_eq!(entry.value, b"global");
768    }
769
770    #[test]
771    fn store_overwrites_existing_entry() {
772        let s = store();
773        s.store(MemoryScope::Global, "counter", b"1", 1).unwrap();
774        s.store(MemoryScope::Global, "counter", b"2", 1).unwrap();
775        let entry = s.query("counter").unwrap().unwrap();
776        assert_eq!(entry.value, b"2");
777    }
778
779    // ── Task 11.2: MCP interface tests ────────────────────────────────────────
780
781    #[test]
782    fn mcp_store_and_query_round_trip() {
783        let mcp = McpMemoryProvider::new(store());
784        let encoded = base64_encode(b"mcp_value");
785        let store_result = mcp.handle_tool_call(
786            "memory_store",
787            json!({ "scope": "global", "key": "mcp_key", "value": encoded, "source_agent": 1 }),
788        );
789        assert_eq!(store_result["ok"], true);
790
791        let query_result =
792            mcp.handle_tool_call("memory_query", json!({ "key": "mcp_key" }));
793        assert_eq!(query_result["found"], true);
794        assert_eq!(query_result["key"], "mcp_key");
795    }
796
797    #[test]
798    fn mcp_query_missing_key_returns_not_found() {
799        let mcp = McpMemoryProvider::new(store());
800        let result = mcp.handle_tool_call("memory_query", json!({ "key": "ghost" }));
801        assert_eq!(result["found"], false);
802    }
803
804    #[test]
805    fn mcp_unknown_tool_returns_error() {
806        let mcp = McpMemoryProvider::new(store());
807        let result = mcp.handle_tool_call("unknown_tool", json!({}));
808        assert!(result["error"].as_str().unwrap().contains("unknown tool"));
809    }
810
811    #[test]
812    fn mcp_store_missing_key_field_returns_error() {
813        let mcp = McpMemoryProvider::new(store());
814        let result = mcp.handle_tool_call(
815            "memory_store",
816            json!({ "scope": "global", "value": base64_encode(b"x") }),
817        );
818        assert!(result["error"].as_str().is_some());
819    }
820
821    // ── Task 11.2: A2A interface tests ────────────────────────────────────────
822
823    #[test]
824    fn a2a_store_and_query_round_trip() {
825        let a2a = A2aMemoryCard::new(store());
826        let encoded = base64_encode(b"a2a_value");
827        let store_result = a2a.handle_request(
828            "store",
829            json!({ "scope": "session:s1", "key": "a2a_key", "value": encoded, "source_agent": 2 }),
830        );
831        assert_eq!(store_result["status"], "ok");
832
833        let query_result = a2a.handle_request("query", json!({ "key": "a2a_key" }));
834        assert_eq!(query_result["status"], "ok");
835        assert_eq!(query_result["key"], "a2a_key");
836    }
837
838    #[test]
839    fn a2a_query_missing_key_returns_not_found() {
840        let a2a = A2aMemoryCard::new(store());
841        let result = a2a.handle_request("query", json!({ "key": "ghost" }));
842        assert_eq!(result["status"], "not_found");
843    }
844
845    #[test]
846    fn a2a_unknown_action_returns_error() {
847        let a2a = A2aMemoryCard::new(store());
848        let result = a2a.handle_request("delete", json!({}));
849        assert!(result["error"].as_str().unwrap().contains("unknown action"));
850    }
851
852    #[test]
853    fn a2a_agent_scope_store_and_query() {
854        let a2a = A2aMemoryCard::new(store());
855        let encoded = base64_encode(b"private");
856        a2a.handle_request(
857            "store",
858            json!({ "scope": "agent:42", "key": "priv", "value": encoded, "source_agent": 42 }),
859        );
860        let result = a2a.handle_request("query", json!({ "key": "priv" }));
861        assert_eq!(result["status"], "ok");
862        assert_eq!(result["scope"], "agent:42");
863    }
864
865    // ── Base64 helpers ────────────────────────────────────────────────────────
866
867    #[test]
868    fn base64_round_trip() {
869        let original = b"Hello, World! \x00\xff\xfe";
870        let encoded = base64_encode(original);
871        let decoded = base64_decode(&encoded).unwrap();
872        assert_eq!(decoded, original);
873    }
874
875    #[test]
876    fn base64_empty_input() {
877        assert_eq!(base64_encode(b""), "");
878        assert_eq!(base64_decode("").unwrap(), b"");
879    }
880
881    // ── Aura bridge tests ─────────────────────────────────────────────────────
882
883    #[test]
884    fn aura_available_check_does_not_panic() {
885        let _ = aura_available();
886    }
887
888    #[test]
889    fn aura_cli_available_check_does_not_panic() {
890        let _ = aura_cli_available();
891    }
892
893    #[test]
894    fn aura_memory_store_falls_back_to_in_memory_when_aura_not_running() {
895        let store = AuraMemoryStore::new();
896        // store and retrieve — should work via fallback regardless of Aura
897        store.store(MemoryScope::Global, "test-key", b"test-value", 0).unwrap();
898        let entry = store.query("test-key").unwrap();
899        // if Aura is not running, fallback returns the value
900        // if Aura is running, it may or may not have the key — either is valid
901        if !aura_cli_available() {
902            assert!(entry.is_some());
903            assert_eq!(entry.unwrap().value, b"test-value");
904        }
905    }
906
907    #[test]
908    fn aura_memory_store_kv_does_not_panic() {
909        let store = AuraMemoryStore::new();
910        let _ = store.store_kv("hawk-test-key", "hawk-test-value");
911    }
912
913    #[test]
914    fn aura_memory_get_kv_does_not_panic() {
915        let store = AuraMemoryStore::new();
916        let _ = store.get_kv("hawk-test-key");
917    }
918
919    #[test]
920    fn aura_memory_list_does_not_panic() {
921        let store = AuraMemoryStore::new();
922        let _ = store.list();
923    }
924
925    #[test]
926    fn aura_memory_store_and_retrieve_via_fallback() {
927        // always works via in-memory fallback
928        let store = AuraMemoryStore::new();
929        store.store(MemoryScope::Global, "fallback-key", b"fallback-val", 1).unwrap();
930        let entry = store.fallback.query("fallback-key").unwrap();
931        assert!(entry.is_some());
932        assert_eq!(entry.unwrap().value, b"fallback-val");
933    }
934
935    #[test]
936    fn aura_memory_ls_returns_vec() {
937        // returns empty vec when aura is not installed — should not panic
938        let items = aura_memory_ls();
939        let _ = items; // just verify it doesn't panic
940    }
941}