Skip to main content

zeroclaw_memory/
lucid.rs

1use super::sqlite::SqliteMemory;
2use super::traits::{Memory, MemoryCategory, MemoryEntry, normalize_recent_recall_query};
3use async_trait::async_trait;
4use chrono::Local;
5use parking_lot::Mutex;
6use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, Instant};
9use tokio::process::Command;
10use tokio::time::timeout;
11
12pub struct LucidMemory {
13    alias: String,
14    local: SqliteMemory,
15    lucid_cmd: String,
16    token_budget: usize,
17    workspace_dir: PathBuf,
18    recall_timeout: Duration,
19    store_timeout: Duration,
20    local_hit_threshold: usize,
21    failure_cooldown: Duration,
22    last_failure_at: Mutex<Option<Instant>>,
23}
24
25impl LucidMemory {
26    const DEFAULT_LUCID_CMD: &'static str = "lucid";
27    const DEFAULT_TOKEN_BUDGET: usize = 200;
28    // Lucid CLI cold start can exceed 120ms on slower machines, which causes
29    // avoidable fallback to local-only memory and premature cooldown.
30    const DEFAULT_RECALL_TIMEOUT_MS: u64 = 500;
31    const DEFAULT_STORE_TIMEOUT_MS: u64 = 800;
32    const DEFAULT_LOCAL_HIT_THRESHOLD: usize = 3;
33    const DEFAULT_FAILURE_COOLDOWN_MS: u64 = 15_000;
34
35    pub fn new(alias: &str, workspace_dir: &Path, local: SqliteMemory) -> Self {
36        Self {
37            alias: alias.to_string(),
38            local,
39            lucid_cmd: Self::DEFAULT_LUCID_CMD.to_string(),
40            token_budget: Self::DEFAULT_TOKEN_BUDGET,
41            workspace_dir: workspace_dir.to_path_buf(),
42            recall_timeout: Duration::from_millis(Self::DEFAULT_RECALL_TIMEOUT_MS),
43            store_timeout: Duration::from_millis(Self::DEFAULT_STORE_TIMEOUT_MS),
44            local_hit_threshold: Self::DEFAULT_LOCAL_HIT_THRESHOLD,
45            failure_cooldown: Duration::from_millis(Self::DEFAULT_FAILURE_COOLDOWN_MS),
46            last_failure_at: Mutex::new(None),
47        }
48    }
49
50    #[cfg(test)]
51    #[allow(clippy::too_many_arguments)]
52    fn with_options(
53        alias: &str,
54        workspace_dir: &Path,
55        local: SqliteMemory,
56        lucid_cmd: String,
57        token_budget: usize,
58        local_hit_threshold: usize,
59        recall_timeout: Duration,
60        store_timeout: Duration,
61        failure_cooldown: Duration,
62    ) -> Self {
63        Self {
64            alias: alias.to_string(),
65            local,
66            lucid_cmd,
67            token_budget,
68            workspace_dir: workspace_dir.to_path_buf(),
69            recall_timeout,
70            store_timeout,
71            local_hit_threshold: local_hit_threshold.max(1),
72            failure_cooldown,
73            last_failure_at: Mutex::new(None),
74        }
75    }
76
77    fn in_failure_cooldown(&self) -> bool {
78        let guard = self.last_failure_at.lock();
79        guard
80            .as_ref()
81            .is_some_and(|last| last.elapsed() < self.failure_cooldown)
82    }
83
84    fn mark_failure_now(&self) {
85        let mut guard = self.last_failure_at.lock();
86        *guard = Some(Instant::now());
87    }
88
89    fn clear_failure(&self) {
90        let mut guard = self.last_failure_at.lock();
91        *guard = None;
92    }
93
94    fn to_lucid_type(category: &MemoryCategory) -> &'static str {
95        match category {
96            MemoryCategory::Core => "decision",
97            MemoryCategory::Daily => "context",
98            MemoryCategory::Conversation => "conversation",
99            MemoryCategory::Custom(_) => "learning",
100        }
101    }
102
103    fn to_memory_category(label: &str) -> MemoryCategory {
104        let normalized = label.to_lowercase();
105        if normalized.contains("visual") {
106            return MemoryCategory::Custom("visual".to_string());
107        }
108
109        match normalized.as_str() {
110            "decision" | "learning" | "solution" => MemoryCategory::Core,
111            "context" | "conversation" => MemoryCategory::Conversation,
112            "bug" => MemoryCategory::Daily,
113            other => MemoryCategory::Custom(other.to_string()),
114        }
115    }
116
117    fn merge_results(
118        primary_results: Vec<MemoryEntry>,
119        secondary_results: Vec<MemoryEntry>,
120        limit: usize,
121    ) -> Vec<MemoryEntry> {
122        if limit == 0 {
123            return Vec::new();
124        }
125
126        let mut merged = Vec::new();
127        let mut seen = HashSet::new();
128
129        for entry in primary_results.into_iter().chain(secondary_results) {
130            let signature = format!(
131                "{}\u{0}{}",
132                entry.key.to_lowercase(),
133                entry.content.to_lowercase()
134            );
135
136            if seen.insert(signature) {
137                merged.push(entry);
138                if merged.len() >= limit {
139                    break;
140                }
141            }
142        }
143
144        merged
145    }
146
147    fn parse_lucid_context(raw: &str) -> Vec<MemoryEntry> {
148        let mut in_context_block = false;
149        let mut entries = Vec::new();
150        let now = Local::now().to_rfc3339();
151
152        for line in raw.lines().map(str::trim) {
153            if line == "<lucid-context>" {
154                in_context_block = true;
155                continue;
156            }
157
158            if line == "</lucid-context>" {
159                break;
160            }
161
162            if !in_context_block || line.is_empty() {
163                continue;
164            }
165
166            let Some(rest) = line.strip_prefix("- [") else {
167                continue;
168            };
169
170            let Some((label, content_part)) = rest.split_once(']') else {
171                continue;
172            };
173
174            let content = content_part.trim();
175            if content.is_empty() {
176                continue;
177            }
178
179            let rank = entries.len();
180            entries.push(MemoryEntry {
181                id: format!("lucid:{rank}"),
182                key: format!("lucid_{rank}"),
183                content: content.to_string(),
184                category: Self::to_memory_category(label.trim()),
185                timestamp: now.clone(),
186                session_id: None,
187                score: Some((1.0 - rank as f64 * 0.05).max(0.1)),
188                namespace: "default".into(),
189                importance: None,
190                superseded_by: None,
191                agent_alias: None,
192                agent_id: None,
193            });
194        }
195
196        entries
197    }
198
199    async fn run_lucid_command_raw(
200        lucid_cmd: &str,
201        args: &[String],
202        timeout_window: Duration,
203    ) -> anyhow::Result<String> {
204        let mut cmd = Command::new(lucid_cmd);
205        cmd.args(args);
206
207        let output = timeout(timeout_window, cmd.output()).await.map_err(|_| {
208            ::zeroclaw_log::record!(
209                ERROR,
210                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Timeout)
211                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
212                    .with_attrs(::serde_json::json!({
213                        "command": lucid_cmd,
214                        "timeout_ms": timeout_window.as_millis() as u64,
215                    })),
216                "lucid command timed out"
217            );
218            anyhow::Error::msg(format!(
219                "lucid command timed out after {}ms",
220                timeout_window.as_millis()
221            ))
222        })??;
223
224        if !output.status.success() {
225            let stderr = String::from_utf8_lossy(&output.stderr);
226            anyhow::bail!("lucid command failed: {stderr}");
227        }
228
229        Ok(String::from_utf8_lossy(&output.stdout).to_string())
230    }
231
232    async fn run_lucid_command(
233        &self,
234        args: &[String],
235        timeout_window: Duration,
236    ) -> anyhow::Result<String> {
237        Self::run_lucid_command_raw(&self.lucid_cmd, args, timeout_window).await
238    }
239
240    fn build_store_args(&self, key: &str, content: &str, category: &MemoryCategory) -> Vec<String> {
241        let payload = format!("{key}: {content}");
242        vec![
243            "store".to_string(),
244            payload,
245            format!("--type={}", Self::to_lucid_type(category)),
246            format!("--project={}", self.workspace_dir.display().to_string()),
247        ]
248    }
249
250    fn build_recall_args(&self, query: &str) -> Vec<String> {
251        vec![
252            "context".to_string(),
253            query.to_string(),
254            format!("--budget={}", self.token_budget),
255            format!("--project={}", self.workspace_dir.display().to_string()),
256        ]
257    }
258
259    async fn sync_to_lucid_async(&self, key: &str, content: &str, category: &MemoryCategory) {
260        let args = self.build_store_args(key, content, category);
261        if let Err(error) = self.run_lucid_command(&args, self.store_timeout).await {
262            ::zeroclaw_log::record!(
263                DEBUG,
264                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
265                    .with_attrs(
266                        ::serde_json::json!({"command": self.lucid_cmd, "error": format!("{}", error)})
267                    ),
268                "Lucid store sync failed; sqlite remains authoritative"
269            );
270        }
271    }
272
273    async fn recall_from_lucid(&self, query: &str) -> anyhow::Result<Vec<MemoryEntry>> {
274        let args = self.build_recall_args(query);
275        let output = self.run_lucid_command(&args, self.recall_timeout).await?;
276        Ok(Self::parse_lucid_context(&output))
277    }
278}
279
280#[async_trait]
281impl Memory for LucidMemory {
282    fn name(&self) -> &str {
283        "lucid"
284    }
285
286    async fn store(
287        &self,
288        key: &str,
289        content: &str,
290        category: MemoryCategory,
291        session_id: Option<&str>,
292    ) -> anyhow::Result<()> {
293        self.local
294            .store(key, content, category.clone(), session_id)
295            .await?;
296        self.sync_to_lucid_async(key, content, &category).await;
297        Ok(())
298    }
299
300    async fn recall(
301        &self,
302        query: &str,
303        limit: usize,
304        session_id: Option<&str>,
305        since: Option<&str>,
306        until: Option<&str>,
307    ) -> anyhow::Result<Vec<MemoryEntry>> {
308        let since_dt = since
309            .map(chrono::DateTime::parse_from_rfc3339)
310            .transpose()
311            .map_err(|e| {
312                ::zeroclaw_log::record!(
313                    WARN,
314                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
315                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
316                        .with_attrs(
317                            ::serde_json::json!({"field": "since", "error": format!("{}", e)})
318                        ),
319                    "recall window bound rejected"
320                );
321                anyhow::Error::msg(format!("invalid 'since' date (expected RFC 3339): {e}"))
322            })?;
323        let until_dt = until
324            .map(chrono::DateTime::parse_from_rfc3339)
325            .transpose()
326            .map_err(|e| {
327                ::zeroclaw_log::record!(
328                    WARN,
329                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
330                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
331                        .with_attrs(
332                            ::serde_json::json!({"field": "until", "error": format!("{}", e)})
333                        ),
334                    "recall window bound rejected"
335                );
336                anyhow::Error::msg(format!("invalid 'until' date (expected RFC 3339): {e}"))
337            })?;
338        if let (Some(s), Some(u)) = (&since_dt, &until_dt)
339            && s >= u
340        {
341            anyhow::bail!("'since' must be before 'until'");
342        }
343
344        let recall_query = normalize_recent_recall_query(query);
345
346        let local_results = self
347            .local
348            .recall(recall_query, limit, session_id, since, until)
349            .await?;
350        if limit == 0
351            || local_results.len() >= limit
352            || local_results.len() >= self.local_hit_threshold
353        {
354            return Ok(local_results);
355        }
356
357        if self.in_failure_cooldown() {
358            return Ok(local_results);
359        }
360
361        match self.recall_from_lucid(recall_query).await {
362            Ok(lucid_results) if !lucid_results.is_empty() => {
363                self.clear_failure();
364                let merged = Self::merge_results(local_results, lucid_results, limit);
365                let filtered: Vec<MemoryEntry> = merged
366                    .into_iter()
367                    .filter(|e| {
368                        if let Some(ref s) = since_dt
369                            && let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&e.timestamp)
370                            && ts < *s
371                        {
372                            return false;
373                        }
374                        if let Some(ref u) = until_dt
375                            && let Ok(ts) = chrono::DateTime::parse_from_rfc3339(&e.timestamp)
376                            && ts > *u
377                        {
378                            return false;
379                        }
380                        true
381                    })
382                    .collect();
383                Ok(filtered)
384            }
385            Ok(_) => {
386                self.clear_failure();
387                Ok(local_results)
388            }
389            Err(error) => {
390                self.mark_failure_now();
391                ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"command": self.lucid_cmd, "error": format!("{}", error)})), "Lucid context unavailable; using local sqlite results");
392                Ok(local_results)
393            }
394        }
395    }
396
397    async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
398        self.local.get(key).await
399    }
400
401    async fn get_for_agent(
402        &self,
403        key: &str,
404        agent_id: &str,
405    ) -> anyhow::Result<Option<MemoryEntry>> {
406        self.local.get_for_agent(key, agent_id).await
407    }
408
409    async fn list(
410        &self,
411        category: Option<&MemoryCategory>,
412        session_id: Option<&str>,
413    ) -> anyhow::Result<Vec<MemoryEntry>> {
414        self.local.list(category, session_id).await
415    }
416
417    async fn forget(&self, key: &str) -> anyhow::Result<bool> {
418        self.local.forget(key).await
419    }
420
421    async fn forget_for_agent(&self, key: &str, agent_id: &str) -> anyhow::Result<bool> {
422        self.local.forget_for_agent(key, agent_id).await
423    }
424
425    async fn purge_session_for_agent(
426        &self,
427        session_id: &str,
428        agent_id: &str,
429    ) -> anyhow::Result<usize> {
430        self.local
431            .purge_session_for_agent(session_id, agent_id)
432            .await
433    }
434
435    async fn count(&self) -> anyhow::Result<usize> {
436        self.local.count().await
437    }
438
439    async fn health_check(&self) -> bool {
440        self.local.health_check().await
441    }
442
443    async fn store_with_agent(
444        &self,
445        key: &str,
446        content: &str,
447        category: MemoryCategory,
448        session_id: Option<&str>,
449        namespace: Option<&str>,
450        importance: Option<f64>,
451        agent_id: Option<&str>,
452    ) -> anyhow::Result<()> {
453        // Lucid composes a local SqliteMemory + a remote Lucid daemon; the
454        // remote side has no agent_id concept, so the attribution lives
455        // only on the local SQLite mirror. The async sync to the daemon
456        // continues unchanged.
457        self.local
458            .store_with_agent(
459                key,
460                content,
461                category.clone(),
462                session_id,
463                namespace,
464                importance,
465                agent_id,
466            )
467            .await?;
468        self.sync_to_lucid_async(key, content, &category).await;
469        Ok(())
470    }
471
472    async fn recall_for_agents(
473        &self,
474        allowed_agent_ids: &[&str],
475        query: &str,
476        limit: usize,
477        session_id: Option<&str>,
478        since: Option<&str>,
479        until: Option<&str>,
480    ) -> anyhow::Result<Vec<MemoryEntry>> {
481        // Lucid's remote-daemon recall has no agent_id awareness; the
482        // cross-agent allowlist is enforced on the local SQLite mirror
483        // only. If the local hits clear the threshold the remote leg
484        // never runs (matching `recall`'s short-circuit semantics).
485        self.local
486            .recall_for_agents(allowed_agent_ids, query, limit, session_id, since, until)
487            .await
488    }
489
490    async fn ensure_agent_uuid(&self, alias: &str) -> anyhow::Result<String> {
491        // Lucid's remote daemon has no agents table; the local SQLite
492        // mirror is the canonical agents-table store.
493        self.local.ensure_agent_uuid(alias).await
494    }
495}
496
497#[cfg(all(test, unix))]
498mod tests {
499    use super::*;
500    use std::fs;
501    use std::os::unix::fs::PermissionsExt;
502    use tempfile::TempDir;
503
504    fn write_fake_lucid_script(dir: &Path) -> String {
505        let script_path = dir.join("fake-lucid.sh");
506        let script = r#"#!/usr/bin/env bash
507set -euo pipefail
508
509if [[ "${1:-}" == "store" ]]; then
510  echo '{"success":true,"id":"mem_1"}'
511  exit 0
512fi
513
514if [[ "${1:-}" == "context" ]]; then
515  cat <<'EOF'
516<lucid-context>
517Auth context snapshot
518- [decision] Use token refresh middleware
519- [context] Working in src/auth.rs
520</lucid-context>
521EOF
522  exit 0
523fi
524
525echo "unsupported command" >&2
526exit 1
527"#;
528
529        fs::write(&script_path, script).unwrap();
530        let mut perms = fs::metadata(&script_path).unwrap().permissions();
531        perms.set_mode(0o755);
532        fs::set_permissions(&script_path, perms).unwrap();
533        script_path.display().to_string()
534    }
535
536    fn write_delayed_lucid_script(dir: &Path) -> String {
537        let script_path = dir.join("delayed-lucid.sh");
538        let script = r#"#!/usr/bin/env bash
539set -euo pipefail
540
541if [[ "${1:-}" == "store" ]]; then
542  echo '{"success":true,"id":"mem_1"}'
543  exit 0
544fi
545
546if [[ "${1:-}" == "context" ]]; then
547  # Simulate a cold start that is slower than 120ms but below the 500ms timeout.
548  sleep 0.2
549  cat <<'EOF'
550<lucid-context>
551- [decision] Delayed token refresh guidance
552</lucid-context>
553EOF
554  exit 0
555fi
556
557echo "unsupported command" >&2
558exit 1
559"#;
560
561        fs::write(&script_path, script).unwrap();
562        let mut perms = fs::metadata(&script_path).unwrap().permissions();
563        perms.set_mode(0o755);
564        fs::set_permissions(&script_path, perms).unwrap();
565        script_path.display().to_string()
566    }
567
568    fn write_probe_lucid_script(dir: &Path, marker_path: &Path) -> String {
569        let script_path = dir.join("probe-lucid.sh");
570        let marker = marker_path.display().to_string();
571        let script = format!(
572            r#"#!/usr/bin/env bash
573set -euo pipefail
574
575if [[ "${{1:-}}" == "store" ]]; then
576  echo '{{"success":true,"id":"mem_store"}}'
577  exit 0
578fi
579
580if [[ "${{1:-}}" == "context" ]]; then
581  printf 'context\n' >> "{marker}"
582  cat <<'EOF'
583<lucid-context>
584- [decision] should not be used when local hits are enough
585</lucid-context>
586EOF
587  exit 0
588fi
589
590echo "unsupported command" >&2
591exit 1
592"#
593        );
594
595        fs::write(&script_path, script).unwrap();
596        let mut perms = fs::metadata(&script_path).unwrap().permissions();
597        perms.set_mode(0o755);
598        fs::set_permissions(&script_path, perms).unwrap();
599        script_path.display().to_string()
600    }
601
602    fn test_memory(workspace: &Path, cmd: String) -> LucidMemory {
603        let sqlite = SqliteMemory::new("sqlite", workspace).unwrap();
604        LucidMemory::with_options(
605            "test",
606            workspace,
607            sqlite,
608            cmd,
609            200,
610            3,
611            Duration::from_secs(5),
612            Duration::from_secs(5),
613            Duration::from_secs(2),
614        )
615    }
616
617    #[tokio::test]
618    async fn lucid_name() {
619        let tmp = TempDir::new().unwrap();
620        let memory = test_memory(tmp.path(), "nonexistent-lucid-binary".to_string());
621        assert_eq!(memory.name(), "lucid");
622    }
623
624    #[tokio::test]
625    async fn store_succeeds_when_lucid_missing() {
626        let tmp = TempDir::new().unwrap();
627        let memory = test_memory(tmp.path(), "nonexistent-lucid-binary".to_string());
628
629        memory
630            .store("lang", "User prefers Rust", MemoryCategory::Core, None)
631            .await
632            .unwrap();
633
634        let entry = memory.get("lang").await.unwrap();
635        assert!(entry.is_some());
636        assert_eq!(entry.unwrap().content, "User prefers Rust");
637    }
638
639    #[tokio::test]
640    async fn recall_merges_lucid_and_local_results() {
641        let tmp = TempDir::new().unwrap();
642        let fake_cmd = write_fake_lucid_script(tmp.path());
643        let memory = test_memory(tmp.path(), fake_cmd);
644
645        memory
646            .store(
647                "local_note",
648                "Local sqlite auth fallback note",
649                MemoryCategory::Core,
650                None,
651            )
652            .await
653            .unwrap();
654
655        let entries = memory.recall("auth", 5, None, None, None).await.unwrap();
656
657        assert!(
658            entries
659                .iter()
660                .any(|e| e.content.contains("Local sqlite auth fallback note"))
661        );
662        assert!(entries.iter().any(|e| e.content.contains("token refresh")));
663    }
664
665    #[tokio::test]
666    async fn recall_handles_lucid_cold_start_delay_within_timeout() {
667        let tmp = TempDir::new().unwrap();
668        let delayed_cmd = write_delayed_lucid_script(tmp.path());
669        let memory = test_memory(tmp.path(), delayed_cmd);
670
671        memory
672            .store(
673                "local_note",
674                "Local sqlite auth fallback note",
675                MemoryCategory::Core,
676                None,
677            )
678            .await
679            .unwrap();
680
681        let entries = memory.recall("auth", 5, None, None, None).await.unwrap();
682
683        assert!(
684            entries
685                .iter()
686                .any(|e| e.content.contains("Local sqlite auth fallback note"))
687        );
688        assert!(
689            entries
690                .iter()
691                .any(|e| e.content.contains("Delayed token refresh guidance"))
692        );
693    }
694
695    #[tokio::test]
696    async fn recall_skips_lucid_when_local_hits_are_enough() {
697        let tmp = TempDir::new().unwrap();
698        let marker = tmp.path().join("context_calls.log");
699        let probe_cmd = write_probe_lucid_script(tmp.path(), &marker);
700
701        let sqlite = SqliteMemory::new("test", tmp.path()).unwrap();
702        let memory = LucidMemory::with_options(
703            "test",
704            tmp.path(),
705            sqlite,
706            probe_cmd,
707            200,
708            1,
709            Duration::from_secs(5),
710            Duration::from_secs(5),
711            Duration::from_secs(2),
712        );
713
714        memory
715            .store(
716                "pref",
717                "Rust should stay local-first",
718                MemoryCategory::Core,
719                None,
720            )
721            .await
722            .unwrap();
723
724        let entries = memory.recall("rust", 5, None, None, None).await.unwrap();
725        assert!(
726            entries
727                .iter()
728                .any(|e| e.content.contains("Rust should stay local-first"))
729        );
730
731        let context_calls = tokio::fs::read_to_string(&marker).await.unwrap_or_default();
732        assert!(
733            context_calls.trim().is_empty(),
734            "Expected local-hit short-circuit; got calls: {context_calls}"
735        );
736    }
737
738    fn write_failing_lucid_script(dir: &Path, marker_path: &Path) -> String {
739        let script_path = dir.join("failing-lucid.sh");
740        let marker = marker_path.display().to_string();
741        let script = format!(
742            r#"#!/usr/bin/env bash
743set -euo pipefail
744
745if [[ "${{1:-}}" == "store" ]]; then
746  echo '{{"success":true,"id":"mem_store"}}'
747  exit 0
748fi
749
750if [[ "${{1:-}}" == "context" ]]; then
751  printf 'context\n' >> "{marker}"
752  echo "simulated lucid failure" >&2
753  exit 1
754fi
755
756echo "unsupported command" >&2
757exit 1
758"#
759        );
760
761        fs::write(&script_path, script).unwrap();
762        let mut perms = fs::metadata(&script_path).unwrap().permissions();
763        perms.set_mode(0o755);
764        fs::set_permissions(&script_path, perms).unwrap();
765        script_path.display().to_string()
766    }
767
768    #[tokio::test]
769    async fn failure_cooldown_avoids_repeated_lucid_calls() {
770        let tmp = TempDir::new().unwrap();
771        let marker = tmp.path().join("failing_context_calls.log");
772        let failing_cmd = write_failing_lucid_script(tmp.path(), &marker);
773
774        let sqlite = SqliteMemory::new("test", tmp.path()).unwrap();
775        let memory = LucidMemory::with_options(
776            "test",
777            tmp.path(),
778            sqlite,
779            failing_cmd,
780            200,
781            99,
782            Duration::from_secs(5),
783            Duration::from_secs(5),
784            Duration::from_secs(5),
785        );
786
787        let first = memory.recall("auth", 5, None, None, None).await.unwrap();
788        let second = memory.recall("auth", 5, None, None, None).await.unwrap();
789
790        assert!(first.is_empty());
791        assert!(second.is_empty());
792
793        let calls = tokio::fs::read_to_string(&marker).await.unwrap_or_default();
794        assert_eq!(calls.lines().count(), 1);
795    }
796}
797
798impl ::zeroclaw_api::attribution::Attributable for LucidMemory {
799    fn role(&self) -> ::zeroclaw_api::attribution::Role {
800        ::zeroclaw_api::attribution::Role::Memory(::zeroclaw_api::attribution::MemoryKind::Lucid)
801    }
802    fn alias(&self) -> &str {
803        &self.alias
804    }
805}