Skip to main content

context_forge/distill/
mod.rs

1//! Distillation: turning a raw conversation transcript into durable memory.
2//!
3//! This module defines the [`Distiller`] trait and the data types it
4//! produces. The trait itself is always available (not feature-gated) so
5//! that callers can implement their own distillers — for example, a remote
6//! API client, a different local model runtime, or a test stub.
7//!
8//! The `OpenAiCompatDistiller` implementation in the `openai_compat`
9//! submodule (behind the `distill-http` feature) talks to an
10//! OpenAI-compatible chat completions endpoint such as Ollama or
11//! llama-server. It is the only place in this crate that performs HTTP, and
12//! only when that feature is enabled.
13
14#[cfg(feature = "distill-http")]
15pub mod openai_compat;
16
17use serde::{Deserialize, Serialize};
18
19use crate::traits::Result;
20
21/// The result of distilling a conversation transcript into durable memory.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct DistilledMemory {
24    /// A summary of the conversation, intended to be under 150 words.
25    pub summary: String,
26    /// Individual facts worth remembering across future sessions.
27    pub facts: Vec<Fact>,
28}
29
30/// A single distilled fact extracted from a transcript.
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Fact {
33    /// The category of this fact.
34    pub kind: FactKind,
35    /// One self-contained sentence describing the fact, understandable
36    /// without the original transcript.
37    pub text: String,
38}
39
40/// The category of a distilled [`Fact`].
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "lowercase")]
43#[non_exhaustive]
44pub enum FactKind {
45    /// A decision that was made, and (ideally) why.
46    Decision,
47    /// A correction the user gave.
48    Correction,
49    /// A user preference.
50    Preference,
51    /// A state change ("X is now Y").
52    State,
53}
54
55/// Maximum number of facts persisted from a single distillation. Excess
56/// facts (untrusted model output) are dropped.
57pub const MAX_FACTS: usize = 64;
58/// Maximum character length of a single distilled fact's text. Longer text
59/// is truncated on a `char` boundary.
60pub const MAX_FACT_CHARS: usize = 2_048;
61/// Maximum character length of a distilled summary. Longer text is
62/// truncated on a `char` boundary.
63pub const MAX_SUMMARY_CHARS: usize = 8_192;
64
65/// Truncates `text` to at most `max_chars` Unicode scalar values, keeping
66/// the beginning and dropping the tail. Truncation always lands on a `char`
67/// boundary.
68fn truncate_keep_start(text: &str, max_chars: usize) -> &str {
69    match text.char_indices().nth(max_chars) {
70        Some((byte_idx, _)) => &text[..byte_idx],
71        None => text,
72    }
73}
74
75/// Caps a [`DistilledMemory`] produced by an untrusted [`Distiller`] so that
76/// it cannot persist unbounded data.
77///
78/// The summary is truncated to at most [`MAX_SUMMARY_CHARS`] characters, the
79/// fact list is truncated to at most [`MAX_FACTS`] entries, and each
80/// surviving fact's text is truncated to at most [`MAX_FACT_CHARS`]
81/// characters. All truncation keeps the beginning of the text and is
82/// silent: no error, no marker, no logging.
83pub(crate) fn cap_distilled_memory(memory: DistilledMemory) -> DistilledMemory {
84    let DistilledMemory { summary, mut facts } = memory;
85
86    let summary = truncate_keep_start(&summary, MAX_SUMMARY_CHARS).to_owned();
87
88    facts.truncate(MAX_FACTS);
89    for fact in &mut facts {
90        if fact.text.chars().count() > MAX_FACT_CHARS {
91            fact.text = truncate_keep_start(&fact.text, MAX_FACT_CHARS).to_owned();
92        }
93    }
94
95    DistilledMemory { summary, facts }
96}
97
98/// Produces [`DistilledMemory`] from a raw conversation transcript.
99///
100/// Implementations must be thread-safe (`Send + Sync`) so a single
101/// distiller instance can be shared across worker threads.
102pub trait Distiller: Send + Sync {
103    /// Distill `transcript` into a summary and a list of durable facts.
104    ///
105    /// # Security
106    ///
107    /// Implementations transmit `transcript` verbatim to the underlying
108    /// model or service — no secret scrubbing is applied at this layer.
109    /// [`ContextForge::distill_and_save`](crate::ContextForge::distill_and_save)
110    /// is the only entry point that scrubs secrets (via
111    /// [`scrub_secrets`](crate::scrub_secrets)) before a transcript reaches
112    /// a [`Distiller`]; callers invoking [`Distiller::distill`] directly are
113    /// responsible for scrubbing first.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if distillation fails (e.g. the backing model or
118    /// service is unavailable, or its response cannot be parsed).
119    fn distill(&self, transcript: &str) -> Result<DistilledMemory>;
120}
121
122#[cfg(test)]
123mod tests {
124    use super::{
125        cap_distilled_memory, DistilledMemory, Fact, FactKind, MAX_FACTS, MAX_FACT_CHARS,
126        MAX_SUMMARY_CHARS,
127    };
128
129    fn fact(text: impl Into<String>) -> Fact {
130        Fact {
131            kind: FactKind::State,
132            text: text.into(),
133        }
134    }
135
136    #[test]
137    fn cap_drops_excess_facts() {
138        let facts = (0..MAX_FACTS + 50)
139            .map(|i| fact(format!("fact {i}")))
140            .collect();
141        let memory = DistilledMemory {
142            summary: "summary".to_owned(),
143            facts,
144        };
145
146        let capped = cap_distilled_memory(memory);
147
148        assert_eq!(capped.facts.len(), MAX_FACTS);
149    }
150
151    #[test]
152    fn cap_truncates_long_fact_text() {
153        let long_text = "a".repeat(MAX_FACT_CHARS + 1000);
154        let memory = DistilledMemory {
155            summary: "summary".to_owned(),
156            facts: vec![fact(long_text.clone())],
157        };
158
159        let capped = cap_distilled_memory(memory);
160
161        assert_eq!(capped.facts[0].text.chars().count(), MAX_FACT_CHARS);
162        assert!(long_text.starts_with(&capped.facts[0].text));
163    }
164
165    #[test]
166    fn cap_truncates_long_summary() {
167        let long_summary = "b".repeat(MAX_SUMMARY_CHARS + 1000);
168        let memory = DistilledMemory {
169            summary: long_summary.clone(),
170            facts: vec![],
171        };
172
173        let capped = cap_distilled_memory(memory);
174
175        assert_eq!(capped.summary.chars().count(), MAX_SUMMARY_CHARS);
176        assert!(long_summary.starts_with(&capped.summary));
177    }
178
179    #[test]
180    fn cap_respects_char_boundaries() {
181        let long_text = "é".repeat(MAX_FACT_CHARS + 10);
182        let memory = DistilledMemory {
183            summary: "🎉".repeat(MAX_SUMMARY_CHARS + 10),
184            facts: vec![fact(long_text)],
185        };
186
187        let capped = cap_distilled_memory(memory);
188
189        assert_eq!(capped.facts[0].text.chars().count(), MAX_FACT_CHARS);
190        assert_eq!(capped.summary.chars().count(), MAX_SUMMARY_CHARS);
191    }
192
193    #[test]
194    fn cap_leaves_compliant_memory_unchanged() {
195        let memory = DistilledMemory {
196            summary: "A short summary.".to_owned(),
197            facts: vec![fact("A short fact.")],
198        };
199
200        let capped = cap_distilled_memory(memory.clone());
201
202        assert_eq!(capped.summary, memory.summary);
203        assert_eq!(capped.facts.len(), memory.facts.len());
204        assert_eq!(capped.facts[0].text, memory.facts[0].text);
205    }
206}