Skip to main content

caliban_memory/
prefix.rs

1//! `MemoryPrefix` — assembled tier blocks + splice rendering.
2
3use std::fmt::Write as _;
4use std::path::PathBuf;
5
6/// Where a [`TierFile`] originated.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum TierFileSource {
9    /// Operator-global CLAUDE.md.
10    Global,
11    /// Discovered via the project-tier ancestor walk.
12    Walk,
13    /// Inlined via an `@`-import inside another tier file.
14    Import,
15    /// Added on-demand after the model touched a file in this subtree.
16    Nested,
17    /// Path-glob-matched rule from `.caliban/rules/`.
18    Rule,
19    /// Per-workspace auto-memory `MEMORY.md`.
20    Auto,
21    /// Legacy single-file project tier (regression escape).
22    LegacyProject,
23}
24
25impl TierFileSource {
26    /// Splice attribute value.
27    #[must_use]
28    pub const fn as_str(self) -> &'static str {
29        match self {
30            Self::Global => "global",
31            Self::Walk => "walk",
32            Self::Import => "import",
33            Self::Nested => "nested",
34            Self::Rule => "rule",
35            Self::Auto => "auto",
36            Self::LegacyProject => "legacy",
37        }
38    }
39}
40
41/// One loaded tier file with provenance.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct TierFile {
44    /// Absolute path of the file on disk.
45    pub path: PathBuf,
46    /// File contents (UTF-8 lossy; may have a truncation suffix when over-budget).
47    pub body: String,
48    /// Estimated tokens (`body.len() / 4`).
49    pub estimated_tokens: usize,
50    /// Bytes shed by budget truncation; `0` when the file fit.
51    pub truncated_bytes: usize,
52}
53
54/// Tier identifiers used by the splice output and the `/memory` summary.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum TierKind {
57    /// Operator-global `CLAUDE.md` (XDG config).
58    Global,
59    /// Project `CLAUDE.md` at the workspace root.
60    Project,
61    /// Per-workspace auto-memory `MEMORY.md`.
62    Auto,
63}
64
65impl TierKind {
66    /// XML tag name written into the system-prompt prefix.
67    #[must_use]
68    pub const fn tag(self) -> &'static str {
69        match self {
70            Self::Global => "global-claude-md",
71            Self::Project => "project-claude-md",
72            Self::Auto => "auto-memory-index",
73        }
74    }
75
76    /// Short label used by `/memory` summary lines.
77    #[must_use]
78    pub const fn label(self) -> &'static str {
79        match self {
80            Self::Global => "global",
81            Self::Project => "project",
82            Self::Auto => "auto",
83        }
84    }
85}
86
87/// Rich project tier with all sub-collections preserved. Useful for the
88/// `/memory` overlay and the ancestry-addendum subsystem.
89#[derive(Debug, Clone, Default)]
90pub struct ProjectTier {
91    /// Files discovered by the ancestor walk (broad → narrow order).
92    pub base_files: Vec<TierFile>,
93    /// Imports resolved from any walk / rule / nested file — surfaced for
94    /// provenance display; the bodies are already inlined in their owning
95    /// tier file via `<!-- imported from … -->` markers.
96    pub imports: Vec<TierFile>,
97    /// Path-glob-matched rules (loaded lazily on first matching path touch).
98    pub active_rules: Vec<TierFile>,
99    /// Files added on-demand mid-session by `Read`/`Edit`/`Glob` hooks.
100    pub nested: Vec<TierFile>,
101}
102
103impl ProjectTier {
104    /// Total estimated tokens across every collection.
105    #[must_use]
106    pub fn estimated_tokens(&self) -> usize {
107        self.base_files
108            .iter()
109            .map(|t| t.estimated_tokens)
110            .sum::<usize>()
111            + self
112                .imports
113                .iter()
114                .map(|t| t.estimated_tokens)
115                .sum::<usize>()
116            + self
117                .active_rules
118                .iter()
119                .map(|t| t.estimated_tokens)
120                .sum::<usize>()
121            + self
122                .nested
123                .iter()
124                .map(|t| t.estimated_tokens)
125                .sum::<usize>()
126    }
127
128    /// Concatenate all base files + always-active rules into a single body for
129    /// the legacy `project: Option<TierFile>` slot used by `splice_into`.
130    /// The first file's path is used as the slot's `path` for provenance.
131    #[must_use]
132    pub fn to_legacy_tier(&self) -> Option<TierFile> {
133        if self.base_files.is_empty() && self.active_rules.is_empty() {
134            return None;
135        }
136        let mut body = String::new();
137        let mut tokens = 0usize;
138        for f in &self.base_files {
139            let _ = writeln!(
140                body,
141                "<project-claude-md path=\"{}\" source=\"walk\">",
142                f.path.display(),
143            );
144            body.push_str(&f.body);
145            if !body.ends_with('\n') {
146                body.push('\n');
147            }
148            body.push_str("</project-claude-md>\n\n");
149            tokens = tokens.saturating_add(f.estimated_tokens);
150        }
151        for r in &self.active_rules {
152            let _ = writeln!(
153                body,
154                "<project-rule path=\"{}\" source=\"rule\">",
155                r.path.display(),
156            );
157            body.push_str(&r.body);
158            if !body.ends_with('\n') {
159                body.push('\n');
160            }
161            body.push_str("</project-rule>\n\n");
162            tokens = tokens.saturating_add(r.estimated_tokens);
163        }
164        let path = self
165            .base_files
166            .first()
167            .or_else(|| self.active_rules.first())
168            .map(|t| t.path.clone())
169            .unwrap_or_default();
170        Some(TierFile {
171            path,
172            body,
173            estimated_tokens: tokens,
174            truncated_bytes: 0,
175        })
176    }
177}
178
179/// Assembled memory prefix.
180///
181/// Tiers are present when the corresponding file existed and read successfully.
182/// `estimated_tokens` is the *combined* token estimate across all present tiers.
183#[derive(Debug, Clone, Default)]
184pub struct MemoryPrefix {
185    /// Operator-global `CLAUDE.md`, if any.
186    pub global: Option<TierFile>,
187    /// Workspace `CLAUDE.md`, if any. This is the **flattened** view of
188    /// [`Self::project_tier`] used by `splice_into` for backward compat; the
189    /// rich view is in `project_tier`.
190    pub project: Option<TierFile>,
191    /// Rich project-tier collections (walk + imports + rules + nested).
192    pub project_tier: Option<ProjectTier>,
193    /// Per-workspace auto-memory `MEMORY.md`, if any.
194    pub auto: Option<TierFile>,
195    /// Sum of `estimated_tokens` across present tiers.
196    pub estimated_tokens: usize,
197    /// `true` if any tier was truncated by budget enforcement.
198    pub truncated: bool,
199}
200
201impl MemoryPrefix {
202    /// Render the memory prefix and append the operator's default-body system
203    /// prompt. Tier order is global → project → auto; missing tiers contribute
204    /// zero bytes. When all tiers are missing, returns `default_body` as-is.
205    #[must_use]
206    pub fn splice_into(&self, default_body: &str) -> String {
207        let mut out = String::new();
208        for (kind, tier) in [
209            (TierKind::Global, self.global.as_ref()),
210            (TierKind::Project, self.project.as_ref()),
211            (TierKind::Auto, self.auto.as_ref()),
212        ] {
213            let Some(tier) = tier else { continue };
214            out.push('<');
215            out.push_str(kind.tag());
216            out.push_str(" path=\"");
217            out.push_str(&tier.path.display().to_string());
218            out.push_str("\">\n");
219            out.push_str(&tier.body);
220            if !tier.body.ends_with('\n') {
221                out.push('\n');
222            }
223            out.push_str("</");
224            out.push_str(kind.tag());
225            out.push_str(">\n\n");
226        }
227        out.push_str(default_body);
228        out
229    }
230
231    /// Human-readable summary lines for the `/memory` slash command.
232    #[must_use]
233    pub fn summary_lines(&self) -> Vec<String> {
234        let mut out = Vec::with_capacity(6);
235        for (kind, tier) in [
236            (TierKind::Global, self.global.as_ref()),
237            (TierKind::Project, self.project.as_ref()),
238            (TierKind::Auto, self.auto.as_ref()),
239        ] {
240            match tier {
241                Some(t) => out.push(format!(
242                    "  {:<8} {} ({} tokens{})",
243                    kind.label(),
244                    t.path.display(),
245                    t.estimated_tokens,
246                    if t.truncated_bytes > 0 {
247                        format!(", truncated {} bytes", t.truncated_bytes)
248                    } else {
249                        String::new()
250                    },
251                )),
252                None => out.push(format!("  {:<8} (missing)", kind.label())),
253            }
254        }
255        if let Some(pt) = self.project_tier.as_ref() {
256            for f in &pt.base_files {
257                out.push(format!(
258                    "    walk     {} ({} tokens)",
259                    f.path.display(),
260                    f.estimated_tokens,
261                ));
262            }
263            for f in &pt.imports {
264                out.push(format!(
265                    "    import   {} ({} tokens)",
266                    f.path.display(),
267                    f.estimated_tokens,
268                ));
269            }
270            for f in &pt.active_rules {
271                out.push(format!(
272                    "    rule     {} ({} tokens)",
273                    f.path.display(),
274                    f.estimated_tokens,
275                ));
276            }
277            for f in &pt.nested {
278                out.push(format!(
279                    "    nested   {} ({} tokens)",
280                    f.path.display(),
281                    f.estimated_tokens,
282                ));
283            }
284        }
285        out
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292
293    fn tier(label: TierKind, body: &str) -> TierFile {
294        TierFile {
295            path: PathBuf::from(format!("/tmp/{}.md", label.label())),
296            estimated_tokens: body.len() / 4,
297            body: body.to_string(),
298            truncated_bytes: 0,
299        }
300    }
301
302    fn raw_tier(path: &str, body: &str) -> TierFile {
303        TierFile {
304            path: PathBuf::from(path),
305            estimated_tokens: body.len() / 4,
306            body: body.to_string(),
307            truncated_bytes: 0,
308        }
309    }
310
311    #[test]
312    fn splice_into_orders_tiers_correctly() {
313        let p = MemoryPrefix {
314            global: Some(tier(TierKind::Global, "GLOBAL")),
315            project: Some(tier(TierKind::Project, "PROJECT")),
316            auto: Some(tier(TierKind::Auto, "AUTO")),
317            ..MemoryPrefix::default()
318        };
319        let out = p.splice_into("BODY");
320        let g = out.find("GLOBAL").expect("global present");
321        let pj = out.find("PROJECT").expect("project present");
322        let a = out.find("AUTO").expect("auto present");
323        let b = out.find("BODY").expect("body present");
324        assert!(g < pj && pj < a && a < b, "wrong order: {out}");
325    }
326
327    #[test]
328    fn splice_into_omits_missing_tiers() {
329        let p = MemoryPrefix {
330            global: None,
331            project: Some(tier(TierKind::Project, "PROJECT")),
332            auto: None,
333            ..MemoryPrefix::default()
334        };
335        let out = p.splice_into("BODY");
336        assert!(!out.contains("global-claude-md"));
337        assert!(!out.contains("auto-memory-index"));
338        assert!(out.contains("project-claude-md"));
339        assert!(out.contains("BODY"));
340    }
341
342    #[test]
343    fn splice_into_preserves_default_body() {
344        let p = MemoryPrefix::default();
345        let out = p.splice_into("the default body verbatim");
346        assert_eq!(out, "the default body verbatim");
347    }
348
349    #[test]
350    fn summary_lines_show_missing_tiers() {
351        let p = MemoryPrefix {
352            global: None,
353            project: Some(tier(TierKind::Project, "x")),
354            auto: None,
355            ..MemoryPrefix::default()
356        };
357        let lines = p.summary_lines();
358        assert_eq!(lines.len(), 3);
359        assert!(lines[0].contains("(missing)"));
360        assert!(lines[1].contains("project"));
361        assert!(lines[2].contains("(missing)"));
362    }
363
364    #[test]
365    fn project_tier_flattens_walk_and_rules_into_legacy_tier() {
366        let pt = ProjectTier {
367            base_files: vec![
368                raw_tier("/tmp/root/CLAUDE.md", "ROOT-BODY"),
369                raw_tier("/tmp/root/sub/CLAUDE.md", "SUB-BODY"),
370            ],
371            active_rules: vec![raw_tier("/tmp/root/.caliban/rules/x.md", "RULE-BODY")],
372            ..ProjectTier::default()
373        };
374        let flat = pt.to_legacy_tier().expect("flat tier built");
375        assert!(flat.body.contains("ROOT-BODY"));
376        assert!(flat.body.contains("SUB-BODY"));
377        assert!(flat.body.contains("RULE-BODY"));
378        assert!(flat.body.contains("project-claude-md"));
379        assert!(flat.body.contains("project-rule"));
380        // ROOT should come before SUB (broad → narrow).
381        assert!(flat.body.find("ROOT-BODY").unwrap() < flat.body.find("SUB-BODY").unwrap(),);
382    }
383
384    #[test]
385    fn project_tier_empty_returns_none_legacy_tier() {
386        let pt = ProjectTier::default();
387        assert!(pt.to_legacy_tier().is_none());
388    }
389}