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}