Skip to main content

caliban_memory/
loader.rs

1//! Async tier loader + budget enforcement.
2
3use std::fmt::Write as _;
4use std::path::{Path, PathBuf};
5
6use crate::auto::strip_html_comments;
7use crate::config::MemoryConfig;
8use crate::error::{MemoryError, Result};
9use crate::prefix::{MemoryPrefix, ProjectTier, TierFile, TierKind};
10use crate::project_imports::{
11    ApprovalMode, ImportAllowlist, ImportState, canonical_or, resolve_imports,
12};
13use crate::project_walk::walk_ancestors;
14use crate::rules::scan_caliban_rules;
15
16/// Cap per-file disk read at 256 KB so a runaway memory file cannot wedge the
17/// startup path.
18const MAX_FILE_BYTES: usize = 256 * 1024;
19
20/// Maximum number of `MEMORY.md` lines spliced into the prompt.
21const AUTO_MAX_LINES: usize = 200;
22
23/// Maximum bytes of `MEMORY.md` spliced into the prompt. Whichever cap is
24/// reached first wins.
25const AUTO_MAX_BYTES: usize = 25 * 1024;
26
27/// Approximate-token estimator (chars / 4). Provider-agnostic, deterministic.
28#[must_use]
29pub fn estimate_tokens(body: &str) -> usize {
30    body.chars().count() / 4
31}
32
33/// Seed file written into a freshly created auto-memory directory on first run.
34const SEED_MEMORY_MD: &str = "# Memory index\n\n_No memories yet. Add entries below as `- [title](slug.md) — one-line summary`._\n";
35
36/// Conventions block appended to MEMORY.md (in-memory only) on every load so
37/// the agent always sees the writing rules without the operator maintaining them.
38const CONVENTIONS_BLOCK: &str = concat!(
39    "\n<!-- caliban: auto-memory conventions follow; do not delete -->\n",
40    "Write to this index when you learn something durable about the user, project, or environment. ",
41    "One topic per file, slug in kebab-case. Do not save transient task state, debug traces, or ",
42    "facts already in the repo. Keep this file ≤ 200 lines.\n",
43);
44
45/// Load all three memory tiers from disk, enforce the token budget, and return
46/// the assembled [`MemoryPrefix`].
47///
48/// Missing files are not errors — they contribute `None` tiers.
49///
50/// # Errors
51///
52/// Returns [`MemoryError::Io`] if a tier file exists but cannot be read
53/// (permissions, etc.), or [`MemoryError::AutoMemorySeed`] if the auto-memory
54/// directory exists check / seed write fails.
55pub async fn load(config: &MemoryConfig) -> Result<MemoryPrefix> {
56    let auto_disabled = config.disable_auto;
57
58    // Seed the auto-memory dir if it doesn't exist yet (skip when disabled —
59    // we don't want a CI run to create a project dir).
60    let auto_md = if auto_disabled {
61        None
62    } else {
63        Some(ensure_auto_memory(&config.auto_memory_dir).await?)
64    };
65
66    let global = read_optional(config.global_path.as_deref()).await?;
67    let auto_raw = if let Some(p) = auto_md.as_deref() {
68        read_optional_with_caps(Some(p), AUTO_MAX_LINES, AUTO_MAX_BYTES).await?
69    } else {
70        None
71    };
72
73    let global = global.map(post_process_static);
74    // Inject conventions into the auto-memory body (in-memory only), then
75    // strip HTML comments so the splice stays clean.
76    let auto = auto_raw.map(|mut t| {
77        if !t.body.contains("caliban: auto-memory conventions follow") {
78            if !t.body.ends_with('\n') {
79                t.body.push('\n');
80            }
81            t.body.push_str(CONVENTIONS_BLOCK);
82        }
83        t.body = strip_html_comments(&t.body);
84        t.estimated_tokens = estimate_tokens(&t.body);
85        t
86    });
87
88    // Build the project tier — either the legacy single-file load (regression
89    // escape) or the new ancestor walk + imports + rules.
90    let (project_legacy, project_tier) = if config.disable_walk {
91        let legacy = read_optional(config.project_path.as_deref())
92            .await?
93            .map(post_process_static);
94        (legacy, None)
95    } else {
96        let project_tier = build_project_tier(config).await?;
97        let legacy = project_tier.to_legacy_tier();
98        (legacy, Some(project_tier))
99    };
100
101    let mut prefix = MemoryPrefix {
102        global,
103        project: project_legacy,
104        project_tier,
105        auto,
106        estimated_tokens: 0,
107        truncated: false,
108    };
109
110    enforce_caps_and_budget(&mut prefix, config);
111    prefix.estimated_tokens = prefix.global.as_ref().map_or(0, |t| t.estimated_tokens)
112        + prefix.project.as_ref().map_or(0, |t| t.estimated_tokens)
113        + prefix.auto.as_ref().map_or(0, |t| t.estimated_tokens);
114
115    Ok(prefix)
116}
117
118/// Build the rich project tier: ancestor walk + `@`-imports per file + rules.
119async fn build_project_tier(config: &MemoryConfig) -> Result<ProjectTier> {
120    let mut tier = ProjectTier::default();
121
122    let mut walked = walk_ancestors(
123        &config.project_walk_root,
124        config.project_walk_stop,
125        &config.claude_md_excludes,
126    );
127    if config.additional_directories_claude_md {
128        for dir in &config.additional_dirs {
129            let extra = walk_ancestors(dir, config.project_walk_stop, &config.claude_md_excludes);
130            walked.extend(extra);
131        }
132    }
133
134    // Effective workspace root for approval is the highest dir reached during
135    // the walk (typically the git root). This means any `@`-import that
136    // resolves *inside* the walked tree never needs approval, even when the
137    // walk started in a deeply-nested subdirectory.
138    let approval_root = walked
139        .first()
140        .and_then(|p| p.parent().map(Path::to_path_buf))
141        .unwrap_or_else(|| config.project_walk_root.clone());
142
143    // Load the import allowlist once.
144    let allowlist = ImportAllowlist::load(&config.imports_allowlist_path).unwrap_or_default();
145    let approval = approval_mode_for(config);
146    let mut state = ImportState::new(approval_root, approval)
147        .with_allowlist(allowlist, Some(config.imports_allowlist_path.clone()));
148
149    for path in walked {
150        let Some(body) = read_capped(&path).await? else {
151            continue;
152        };
153        let resolved = resolve_imports(&body, &path, &mut state);
154        let stripped = strip_html_comments(&resolved);
155        let estimated_tokens = estimate_tokens(&stripped);
156        // Record which canonical paths the import resolver had pulled in for
157        // this file — they're shown in `/memory` for provenance.
158        for imp in &state.loaded {
159            if tier.imports.iter().any(|f| canonical_or(&f.path) == *imp) {
160                continue;
161            }
162            if imp == &canonical_or(&path) {
163                continue;
164            }
165            // Imports are inlined into `resolved` already; we record their
166            // paths via a small stub TierFile (the body is empty since the
167            // real content is in the parent tier file).
168            tier.imports.push(TierFile {
169                path: imp.clone(),
170                body: String::new(),
171                estimated_tokens: 0,
172                truncated_bytes: 0,
173            });
174        }
175        tier.base_files.push(TierFile {
176            path,
177            body: stripped,
178            estimated_tokens,
179            truncated_bytes: 0,
180        });
181    }
182
183    // Rules: always-active ones load into the prompt now; path-scoped rules
184    // wait for a path-touch via AncestryAddendum / RulesActivator.
185    let rule_set = scan_caliban_rules(&config.project_walk_root);
186    for rule in rule_set.always_active() {
187        let resolved = resolve_imports(&rule.body, &rule.path, &mut state);
188        let stripped = strip_html_comments(&resolved);
189        let estimated_tokens = estimate_tokens(&stripped);
190        tier.active_rules.push(TierFile {
191            path: rule.path.clone(),
192            body: stripped,
193            estimated_tokens,
194            truncated_bytes: 0,
195        });
196    }
197
198    Ok(tier)
199}
200
201fn approval_mode_for(config: &MemoryConfig) -> ApprovalMode<'static> {
202    if config.approve_imports {
203        ApprovalMode::AutoAllow
204    } else if config.non_interactive {
205        ApprovalMode::AutoDeny
206    } else {
207        // Default for the library: no interactive prompt available — auto-deny.
208        // Binaries hook a real TUI prompt by replacing this mode at config time
209        // (planned wiring; for v1 we deny silently and log).
210        ApprovalMode::AutoDeny
211    }
212}
213
214async fn read_capped(path: &Path) -> Result<Option<String>> {
215    match tokio::fs::metadata(path).await {
216        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
217        Err(e) => {
218            return Err(MemoryError::Io {
219                path: path.to_path_buf(),
220                source: e,
221            });
222        }
223        Ok(md) if !md.is_file() => return Ok(None),
224        Ok(_) => {}
225    }
226    let raw = tokio::fs::read(path).await.map_err(|e| MemoryError::Io {
227        path: path.to_path_buf(),
228        source: e,
229    })?;
230    let clamped = if raw.len() > MAX_FILE_BYTES {
231        &raw[..MAX_FILE_BYTES]
232    } else {
233        &raw[..]
234    };
235    Ok(Some(String::from_utf8_lossy(clamped).into_owned()))
236}
237
238/// Post-process a tier file that's not the auto tier: strip HTML comments and
239/// re-estimate tokens. Applied to global + project so that conventions /
240/// internal notes the operator hides in comments don't bloat the splice.
241fn post_process_static(mut t: TierFile) -> TierFile {
242    t.body = strip_html_comments(&t.body);
243    t.estimated_tokens = estimate_tokens(&t.body);
244    t
245}
246
247async fn read_optional(path: Option<&Path>) -> Result<Option<TierFile>> {
248    let Some(path) = path else { return Ok(None) };
249    match tokio::fs::metadata(path).await {
250        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
251        Err(e) => {
252            return Err(MemoryError::Io {
253                path: path.to_path_buf(),
254                source: e,
255            });
256        }
257        Ok(_) => {}
258    }
259    let raw = tokio::fs::read(path).await.map_err(|e| MemoryError::Io {
260        path: path.to_path_buf(),
261        source: e,
262    })?;
263    let truncated_bytes = raw.len().saturating_sub(MAX_FILE_BYTES);
264    let clamped = if truncated_bytes > 0 {
265        &raw[..MAX_FILE_BYTES]
266    } else {
267        &raw[..]
268    };
269    let body = String::from_utf8_lossy(clamped).into_owned();
270    Ok(Some(TierFile {
271        path: path.to_path_buf(),
272        estimated_tokens: estimate_tokens(&body),
273        body,
274        truncated_bytes,
275    }))
276}
277
278/// Read with cap-by-lines-or-bytes (whichever wins first). Used for the auto
279/// tier where the spec mandates a strict 200-line / 25 KB ceiling on the
280/// spliced body.
281async fn read_optional_with_caps(
282    path: Option<&Path>,
283    max_lines: usize,
284    max_bytes: usize,
285) -> Result<Option<TierFile>> {
286    let Some(path) = path else { return Ok(None) };
287    match tokio::fs::metadata(path).await {
288        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
289        Err(e) => {
290            return Err(MemoryError::Io {
291                path: path.to_path_buf(),
292                source: e,
293            });
294        }
295        Ok(_) => {}
296    }
297    let raw_bytes = tokio::fs::read(path).await.map_err(|e| MemoryError::Io {
298        path: path.to_path_buf(),
299        source: e,
300    })?;
301    let raw = String::from_utf8_lossy(&raw_bytes).into_owned();
302    let total_bytes = raw.len();
303
304    // Apply caps. We walk lines and accumulate, stopping when either ceiling
305    // would be exceeded.
306    let mut kept = String::new();
307    for (lines_used, line) in raw.split_inclusive('\n').enumerate() {
308        if lines_used >= max_lines {
309            break;
310        }
311        if kept.len() + line.len() > max_bytes {
312            break;
313        }
314        kept.push_str(line);
315    }
316    let truncated_bytes = total_bytes.saturating_sub(kept.len());
317
318    Ok(Some(TierFile {
319        path: path.to_path_buf(),
320        estimated_tokens: estimate_tokens(&kept),
321        body: kept,
322        truncated_bytes,
323    }))
324}
325
326async fn ensure_auto_memory(dir: &Path) -> Result<PathBuf> {
327    tokio::fs::create_dir_all(dir)
328        .await
329        .map_err(|e| MemoryError::AutoMemorySeed {
330            path: dir.to_path_buf(),
331            source: e,
332        })?;
333    let memory_md = dir.join("MEMORY.md");
334    if tokio::fs::try_exists(&memory_md).await.unwrap_or(false) {
335        return Ok(memory_md);
336    }
337    tokio::fs::write(&memory_md, SEED_MEMORY_MD)
338        .await
339        .map_err(|e| MemoryError::AutoMemorySeed {
340            path: memory_md.clone(),
341            source: e,
342        })?;
343    Ok(memory_md)
344}
345
346/// Apply per-scope caps from `config`, then the combined `max_tokens` ceiling.
347///
348/// Per-scope ordering:
349/// 1. `cap_tokens_auto` truncates the auto tier in isolation.
350/// 2. `cap_tokens_claude_md` applies to the combined global + project tiers;
351///    truncates project first (less important within the CLAUDE.md group), then
352///    global if still over.
353/// 3. The combined `max_tokens` ceiling is enforced last via [`enforce_budget`],
354///    which walks tiers in priority order (auto → project → global).
355fn enforce_caps_and_budget(prefix: &mut MemoryPrefix, config: &MemoryConfig) {
356    if let Some(cap) = config.cap_tokens_auto {
357        let effective = config.effective_cap(cap, config.cap_tokens_claude_md);
358        if prefix
359            .auto
360            .as_ref()
361            .is_some_and(|t| t.estimated_tokens > effective)
362        {
363            truncate_tier(prefix, TierKind::Auto, effective);
364            prefix.truncated = true;
365        }
366    }
367    if let Some(cap) = config.cap_tokens_claude_md {
368        let effective = config.effective_cap(cap, config.cap_tokens_auto);
369        let global_t = prefix.global.as_ref().map_or(0, |t| t.estimated_tokens);
370        let project_t = prefix.project.as_ref().map_or(0, |t| t.estimated_tokens);
371        if global_t + project_t > effective {
372            // Truncate project first, then global.
373            let project_allowance = effective.saturating_sub(global_t);
374            if project_allowance < project_t {
375                truncate_tier(prefix, TierKind::Project, project_allowance);
376                prefix.truncated = true;
377            }
378            let project_after = prefix.project.as_ref().map_or(0, |t| t.estimated_tokens);
379            let global_allowance = effective.saturating_sub(project_after);
380            if global_allowance < global_t {
381                truncate_tier(prefix, TierKind::Global, global_allowance);
382                prefix.truncated = true;
383            }
384        }
385    }
386    enforce_budget(prefix, config.max_tokens);
387}
388
389/// Truncate tiers in priority order (auto → project → global) until the total
390/// token estimate fits the budget. Each truncation snips at a line boundary
391/// and appends a marker. Sets `prefix.truncated` accordingly.
392fn enforce_budget(prefix: &mut MemoryPrefix, max_tokens: usize) {
393    let total = total_tokens(prefix);
394    if total <= max_tokens {
395        return;
396    }
397
398    // Reverse priority order: shed auto first, then project, then global.
399    for kind in [TierKind::Auto, TierKind::Project, TierKind::Global] {
400        if total_tokens(prefix) <= max_tokens {
401            return;
402        }
403        let allowance = max_tokens.saturating_sub(other_tokens(prefix, kind));
404        truncate_tier(prefix, kind, allowance);
405        prefix.truncated = true;
406    }
407
408    // If we got here and we're still over budget, the global tier alone is
409    // bigger than the cap. We already truncated it; emit a warning so the
410    // operator's debug log captures this case.
411    if total_tokens(prefix) > max_tokens
412        && let Some(g) = prefix.global.as_ref()
413    {
414        tracing::warn!(
415            target: caliban_common::tracing_targets::TARGET_MEMORY,
416            path = %g.path.display(),
417            estimated_tokens = g.estimated_tokens,
418            cap = max_tokens,
419            "global memory file exceeds budget even after truncation",
420        );
421    }
422}
423
424fn total_tokens(p: &MemoryPrefix) -> usize {
425    p.global.as_ref().map_or(0, |t| t.estimated_tokens)
426        + p.project.as_ref().map_or(0, |t| t.estimated_tokens)
427        + p.auto.as_ref().map_or(0, |t| t.estimated_tokens)
428}
429
430fn other_tokens(p: &MemoryPrefix, exclude: TierKind) -> usize {
431    let mut sum = 0;
432    if !matches!(exclude, TierKind::Global)
433        && let Some(t) = p.global.as_ref()
434    {
435        sum += t.estimated_tokens;
436    }
437    if !matches!(exclude, TierKind::Project)
438        && let Some(t) = p.project.as_ref()
439    {
440        sum += t.estimated_tokens;
441    }
442    if !matches!(exclude, TierKind::Auto)
443        && let Some(t) = p.auto.as_ref()
444    {
445        sum += t.estimated_tokens;
446    }
447    sum
448}
449
450/// Bytes reserved at the end of a truncated tier for the `[truncated: ...]`
451/// marker. Conservative — actual marker is ~80 bytes.
452const MARKER_RESERVE_BYTES: usize = 128;
453
454fn truncate_tier(prefix: &mut MemoryPrefix, kind: TierKind, max_tokens: usize) {
455    let slot: &mut Option<TierFile> = match kind {
456        TierKind::Global => &mut prefix.global,
457        TierKind::Project => &mut prefix.project,
458        TierKind::Auto => &mut prefix.auto,
459    };
460    let Some(tier) = slot.as_mut() else { return };
461    if tier.estimated_tokens <= max_tokens {
462        return;
463    }
464    // Reserve headroom for the marker so the resulting body still fits.
465    let target_bytes = max_tokens
466        .saturating_mul(4)
467        .saturating_sub(MARKER_RESERVE_BYTES);
468    let original_len = tier.body.len();
469    if target_bytes >= original_len {
470        return;
471    }
472    // Snip at the last newline before target_bytes.
473    let cut = tier.body[..target_bytes]
474        .rfind('\n')
475        .map_or(target_bytes, |i| i + 1);
476    let mut new_body = tier.body[..cut].to_string();
477    let shed = original_len - cut;
478    let _ = writeln!(
479        new_body,
480        "\n[truncated: {shed} bytes over budget; raise CALIBAN_MEMORY_BUDGET_TOKENS or trim]",
481    );
482    tier.truncated_bytes = shed;
483    tier.body = new_body;
484    tier.estimated_tokens = estimate_tokens(&tier.body);
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490    use crate::prefix::{MemoryPrefix, TierFile, TierKind};
491
492    fn tier(body: &str) -> TierFile {
493        TierFile {
494            path: std::path::PathBuf::from("/tmp/x.md"),
495            estimated_tokens: estimate_tokens(body),
496            body: body.to_string(),
497            truncated_bytes: 0,
498        }
499    }
500
501    #[test]
502    fn estimate_tokens_uses_chars_div_4() {
503        assert_eq!(estimate_tokens(""), 0);
504        assert_eq!(estimate_tokens("abc"), 0);
505        assert_eq!(estimate_tokens("abcd"), 1);
506        assert_eq!(estimate_tokens(&"a".repeat(40)), 10);
507    }
508
509    #[test]
510    fn budget_under_cap_no_truncation() {
511        let mut p = MemoryPrefix {
512            global: Some(tier("hi")),
513            project: Some(tier("there")),
514            auto: Some(tier("again")),
515            ..MemoryPrefix::default()
516        };
517        enforce_budget(&mut p, 8_000);
518        assert!(!p.truncated);
519        assert_eq!(p.global.unwrap().truncated_bytes, 0);
520        assert_eq!(p.project.unwrap().truncated_bytes, 0);
521        assert_eq!(p.auto.unwrap().truncated_bytes, 0);
522    }
523
524    #[test]
525    fn budget_truncates_auto_first() {
526        let small = "x".repeat(100); // ~25 tokens
527        let big_auto = "line\n".repeat(2_000); // ~2500 tokens
528        let mut p = MemoryPrefix {
529            global: Some(tier(&small)),
530            project: Some(tier(&small)),
531            auto: Some(tier(&big_auto)),
532            ..MemoryPrefix::default()
533        };
534        enforce_budget(&mut p, 200);
535        assert!(p.truncated);
536        assert!(p.auto.as_ref().unwrap().truncated_bytes > 0);
537        // Global + project should be untouched since auto shedding was enough.
538        assert_eq!(p.global.as_ref().unwrap().truncated_bytes, 0);
539        assert_eq!(p.project.as_ref().unwrap().truncated_bytes, 0);
540    }
541
542    #[test]
543    fn truncate_cuts_on_line_boundary() {
544        let mut body = String::new();
545        for i in 0..100 {
546            writeln!(body, "line {i:03}").unwrap();
547        }
548        let mut p = MemoryPrefix {
549            global: None,
550            project: None,
551            auto: Some(tier(&body)),
552            ..MemoryPrefix::default()
553        };
554        enforce_budget(&mut p, 20);
555        let cut_body = &p.auto.as_ref().unwrap().body;
556        // Snipped result must end on a newline (or the marker we appended).
557        // Walk lines and ensure every kept body line is intact.
558        for line in cut_body.lines().take_while(|l| l.starts_with("line ")) {
559            assert!(line.len() == "line NNN".len(), "non-boundary cut: {line:?}");
560        }
561        assert!(cut_body.contains("[truncated:"));
562    }
563
564    #[test]
565    fn budget_truncates_global_when_only_one_tier_present() {
566        let big = "g".repeat(10_000);
567        let mut p = MemoryPrefix {
568            global: Some(tier(&big)),
569            project: None,
570            auto: None,
571            ..MemoryPrefix::default()
572        };
573        enforce_budget(&mut p, 500);
574        assert!(p.truncated);
575        assert!(p.global.unwrap().truncated_bytes > 0);
576    }
577
578    #[test]
579    fn other_tokens_excludes_correct_tier() {
580        let p = MemoryPrefix {
581            global: Some(tier(&"a".repeat(40))),  // 10 tokens
582            project: Some(tier(&"b".repeat(80))), // 20 tokens
583            auto: Some(tier(&"c".repeat(120))),   // 30 tokens
584            ..MemoryPrefix::default()
585        };
586        assert_eq!(other_tokens(&p, TierKind::Auto), 30);
587        assert_eq!(other_tokens(&p, TierKind::Project), 40);
588        assert_eq!(other_tokens(&p, TierKind::Global), 50);
589    }
590
591    #[test]
592    fn enforce_caps_truncates_auto_to_per_scope_cap() {
593        let big_auto = "x".repeat(4_000); // ~1000 tokens
594        let mut p = MemoryPrefix {
595            global: Some(tier("hi")),
596            project: Some(tier("there")),
597            auto: Some(tier(&big_auto)),
598            ..MemoryPrefix::default()
599        };
600        let cfg = MemoryConfig {
601            cap_tokens_auto: Some(100),
602            ..MemoryConfig::for_test(std::path::PathBuf::from("/tmp/m"))
603        };
604        enforce_caps_and_budget(&mut p, &cfg);
605        assert!(p.truncated);
606        let auto = p.auto.as_ref().unwrap();
607        let auto_tokens = auto.estimated_tokens;
608        assert!(
609            auto_tokens <= 100,
610            "auto cap not honored: {auto_tokens} > 100"
611        );
612    }
613
614    #[test]
615    fn enforce_caps_truncates_project_first_then_global_under_claude_md_cap() {
616        let small = "x".repeat(40); // ~10 tokens
617        let big_project = "p".repeat(4_000); // ~1000 tokens
618        let big_global = "g".repeat(4_000); // ~1000 tokens
619        let mut p = MemoryPrefix {
620            global: Some(tier(&big_global)),
621            project: Some(tier(&big_project)),
622            auto: Some(tier(&small)),
623            ..MemoryPrefix::default()
624        };
625        let cfg = MemoryConfig {
626            cap_tokens_claude_md: Some(500),
627            ..MemoryConfig::for_test(std::path::PathBuf::from("/tmp/m"))
628        };
629        enforce_caps_and_budget(&mut p, &cfg);
630        assert!(p.truncated);
631        let global_t = p.global.as_ref().map_or(0, |t| t.estimated_tokens);
632        let project_t = p.project.as_ref().map_or(0, |t| t.estimated_tokens);
633        assert!(
634            global_t + project_t <= 500,
635            "claude_md cap not honored: {global_t} + {project_t} > 500",
636        );
637        // Project should bear the brunt — global stays full (~1000 tokens
638        // exceeds 500 alone, so global is also truncated, but project should
639        // be MORE truncated than global).
640        // Actually since global alone (1000) > cap (500), both will be hit.
641        // Just check project is smaller than global, since project goes first.
642        assert!(
643            project_t == 0 || project_t < global_t,
644            "project should be truncated first: project={project_t} global={global_t}",
645        );
646    }
647
648    #[test]
649    fn enforce_caps_proportional_scaling_when_per_scope_sum_exceeds_combined() {
650        // Both per-scope caps are 20K, combined is 20K → effective caps scale to 10K each.
651        let mut big_auto = String::new();
652        for _ in 0..20_000 {
653            big_auto.push_str("aaaa\n");
654        } // ~25K tokens
655        let mut big_md = String::new();
656        for _ in 0..20_000 {
657            big_md.push_str("bbbb\n");
658        } // ~25K tokens
659        let mut p = MemoryPrefix {
660            global: Some(tier(&big_md)),
661            project: None,
662            auto: Some(tier(&big_auto)),
663            ..MemoryPrefix::default()
664        };
665        let cfg = MemoryConfig {
666            max_tokens: 20_000,
667            cap_tokens_auto: Some(20_000),
668            cap_tokens_claude_md: Some(20_000),
669            ..MemoryConfig::for_test(std::path::PathBuf::from("/tmp/m"))
670        };
671        enforce_caps_and_budget(&mut p, &cfg);
672        // After per-scope scaling: each effective cap = 10K.
673        // Then combined enforce_budget(20K) is a no-op since total = 10K + 10K = 20K.
674        let auto_t = p.auto.as_ref().unwrap().estimated_tokens;
675        let global_t = p.global.as_ref().unwrap().estimated_tokens;
676        assert!(auto_t <= 10_000, "auto effective cap: {auto_t}");
677        assert!(global_t <= 10_000, "global effective cap: {global_t}");
678        assert!(auto_t + global_t <= 20_000);
679    }
680
681    #[tokio::test]
682    async fn auto_load_caps_at_two_hundred_lines() {
683        let tmp = tempfile::TempDir::new().unwrap();
684        let dir = tmp.path().join("memory");
685        std::fs::create_dir_all(&dir).unwrap();
686        let mut body = String::new();
687        for i in 0..400 {
688            writeln!(body, "line-{i:04}").unwrap();
689        }
690        std::fs::write(dir.join("MEMORY.md"), &body).unwrap();
691
692        let cfg = MemoryConfig::for_test(dir.clone());
693        let p = load(&cfg).await.unwrap();
694        let auto = p.auto.as_ref().expect("auto loaded");
695        // Strip the conventions block we add before counting.
696        let kept_lines = auto.body.lines().filter(|l| l.starts_with("line-")).count();
697        assert_eq!(kept_lines, AUTO_MAX_LINES);
698        assert!(auto.truncated_bytes > 0);
699    }
700
701    #[tokio::test]
702    async fn auto_load_caps_at_byte_ceiling() {
703        let tmp = tempfile::TempDir::new().unwrap();
704        let dir = tmp.path().join("memory");
705        std::fs::create_dir_all(&dir).unwrap();
706        // 10 lines but each line is ~5 KB → cap on bytes hits before the line cap.
707        let mut body = String::new();
708        for i in 0..10 {
709            let chunk = "x".repeat(5_000);
710            writeln!(body, "{i}-{chunk}").unwrap();
711        }
712        std::fs::write(dir.join("MEMORY.md"), &body).unwrap();
713
714        let cfg = MemoryConfig::for_test(dir.clone());
715        let p = load(&cfg).await.unwrap();
716        let auto = p.auto.as_ref().expect("auto loaded");
717        // Body kept ≤ 25 KB before we appended conventions.
718        // We can't assert exact byte length post-conventions, but truncated_bytes
719        // must be set and the kept body shouldn't contain every line.
720        assert!(auto.truncated_bytes > 0);
721        let lines_with_x = auto.body.lines().filter(|l| l.contains("xxxx")).count();
722        assert!(
723            lines_with_x < 10,
724            "expected truncation, got {lines_with_x} lines"
725        );
726    }
727
728    #[tokio::test]
729    async fn html_comments_stripped_from_auto_splice() {
730        let tmp = tempfile::TempDir::new().unwrap();
731        let dir = tmp.path().join("memory");
732        std::fs::create_dir_all(&dir).unwrap();
733        std::fs::write(
734            dir.join("MEMORY.md"),
735            "# Memory index\n<!-- secret comment -->\n- [foo](foo.md) — user: visible\n",
736        )
737        .unwrap();
738        let cfg = MemoryConfig::for_test(dir.clone());
739        let p = load(&cfg).await.unwrap();
740        let auto = p.auto.as_ref().unwrap();
741        assert!(!auto.body.contains("secret comment"));
742        assert!(auto.body.contains("[foo](foo.md)"));
743    }
744
745    // ----- env-var driven tests -----
746    //
747    // `std::env::set_var` / `remove_var` were marked `unsafe` in Rust 2024
748    // because mutating the process environment is racy with other threads
749    // (especially `getenv` in libc). The workspace lint denies `unsafe_code`
750    // — we localize the `#[allow]` to the env-guard helper which is only
751    // reachable from `#[cfg(test)]` and runs single-threaded under
752    // `cargo test -p caliban-memory` (no other crate in the workspace mutates
753    // these vars). The guard restores the previous value on drop so leakage
754    // across tests is contained.
755    //
756    // SAFETY: see comment above. We accept the documented race in test-only
757    // code in exchange for being able to assert the env-driven branches.
758    #[allow(unsafe_code)]
759    fn set_env(key: &str, value: Option<&str>) {
760        match value {
761            // SAFETY: see module-level comment above the env tests.
762            Some(v) => unsafe { std::env::set_var(key, v) },
763            // SAFETY: see module-level comment above the env tests.
764            None => unsafe { std::env::remove_var(key) },
765        }
766    }
767
768    struct EnvGuard {
769        key: &'static str,
770        prior: Option<std::ffi::OsString>,
771    }
772
773    impl Drop for EnvGuard {
774        fn drop(&mut self) {
775            set_env(self.key, self.prior.as_ref().and_then(|s| s.to_str()));
776        }
777    }
778
779    fn env_guard(key: &'static str) -> EnvGuard {
780        EnvGuard {
781            key,
782            prior: std::env::var_os(key),
783        }
784    }
785
786    #[tokio::test]
787    async fn disable_auto_config_skips_auto_tier() {
788        let tmp = tempfile::TempDir::new().unwrap();
789        let dir = tmp.path().join("memory");
790        // Pre-populate so we *would* load if the flag weren't set.
791        std::fs::create_dir_all(&dir).unwrap();
792        std::fs::write(dir.join("MEMORY.md"), "# Memory index\n").unwrap();
793
794        // The disable flag lives on the config (resolved from
795        // `CALIBAN_DISABLE_AUTO_MEMORY` at `from_env` time), so this test sets
796        // it directly rather than mutating the process environment — which used
797        // to race with parallel tests that call `load()`.
798        let mut cfg = MemoryConfig::for_test(dir.clone());
799        cfg.disable_auto = true;
800        let p = load(&cfg).await.unwrap();
801        assert!(p.auto.is_none(), "auto tier should be dropped");
802    }
803
804    #[test]
805    fn config_honors_auto_memory_directory_override() {
806        let _g1 = env_guard("CALIBAN_AUTO_MEMORY_DIRECTORY");
807        set_env(
808            "CALIBAN_AUTO_MEMORY_DIRECTORY",
809            Some("/tmp/custom-auto-mem-xyz"),
810        );
811        let cfg = MemoryConfig::from_env(std::path::Path::new("/tmp/whatever"));
812        assert_eq!(
813            cfg.auto_memory_dir,
814            std::path::PathBuf::from("/tmp/custom-auto-mem-xyz")
815        );
816    }
817}