Skip to main content

lean_ctx/core/knowledge/
format.rs

1use super::ranking::{confidence_stars, sort_fact_for_output};
2use super::types::{ConsolidatedInsight, KnowledgeFact, ProjectKnowledge};
3use crate::core::memory_policy::MemoryPolicy;
4
5/// Result of a delta-aware AAAK generation.
6pub struct AaakDelta {
7    pub content: String,
8    pub hash: String,
9    pub is_full: bool,
10    pub fact_count: usize,
11}
12
13impl ProjectKnowledge {
14    /// Record one consolidated insight, then losslessly reclaim history to its
15    /// capacity headroom. Returns the number of older insights archived (0 when
16    /// under cap).
17    pub fn consolidate(
18        &mut self,
19        summary: &str,
20        session_ids: Vec<String>,
21        policy: &MemoryPolicy,
22    ) -> Result<usize, String> {
23        self.history.push(ConsolidatedInsight {
24            summary: summary.to_string(),
25            from_sessions: session_ids,
26            timestamp: chrono::Utc::now(),
27        });
28
29        let archived = crate::core::memory_capacity::reclaim_store(
30            crate::core::memory_archive::MemoryStore::History,
31            Some(&self.project_hash),
32            &mut self.history,
33            policy.knowledge.max_history,
34            policy.lifecycle.reclaim_headroom_pct,
35            policy.lifecycle.reclaim_enabled,
36            |a, b| {
37                b.timestamp
38                    .cmp(&a.timestamp)
39                    .then_with(|| b.summary.cmp(&a.summary))
40            },
41        )?;
42        self.updated_at = chrono::Utc::now();
43        Ok(archived.len())
44    }
45
46    pub fn format_summary(&self) -> String {
47        let mut out = String::new();
48        let current_facts: Vec<&KnowledgeFact> =
49            self.facts.iter().filter(|f| f.is_current()).collect();
50
51        if !current_facts.is_empty() {
52            out.push_str("PROJECT KNOWLEDGE:\n");
53            let mut rooms: Vec<(String, usize)> = self.list_rooms();
54            rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
55
56            let total_rooms = rooms.len();
57            rooms.truncate(crate::core::budgets::KNOWLEDGE_SUMMARY_ROOMS_LIMIT);
58
59            for (cat, _count) in rooms {
60                out.push_str(&format!("  [{cat}]\n"));
61
62                let mut facts_in_cat: Vec<&KnowledgeFact> = current_facts
63                    .iter()
64                    .copied()
65                    .filter(|f| f.category == cat)
66                    .collect();
67                facts_in_cat.sort_by(|a, b| sort_fact_for_output(a, b));
68
69                let total_in_cat = facts_in_cat.len();
70                facts_in_cat.truncate(crate::core::budgets::KNOWLEDGE_SUMMARY_FACTS_PER_ROOM_LIMIT);
71
72                for f in facts_in_cat {
73                    let key = crate::core::sanitize::neutralize_metadata(&f.key);
74                    let val = crate::core::sanitize::neutralize_metadata(&f.value);
75                    out.push_str(&format!(
76                        "    {}: {} (confidence: {:.0}%)\n",
77                        key,
78                        val,
79                        f.confidence * 100.0
80                    ));
81                }
82                if total_in_cat > crate::core::budgets::KNOWLEDGE_SUMMARY_FACTS_PER_ROOM_LIMIT {
83                    out.push_str(&format!(
84                        "    … +{} more\n",
85                        total_in_cat - crate::core::budgets::KNOWLEDGE_SUMMARY_FACTS_PER_ROOM_LIMIT
86                    ));
87                }
88            }
89
90            if total_rooms > crate::core::budgets::KNOWLEDGE_SUMMARY_ROOMS_LIMIT {
91                out.push_str(&format!(
92                    "  … +{} more rooms\n",
93                    total_rooms - crate::core::budgets::KNOWLEDGE_SUMMARY_ROOMS_LIMIT
94                ));
95            }
96        }
97
98        if !self.patterns.is_empty() {
99            out.push_str("PROJECT PATTERNS:\n");
100            let mut patterns = self.patterns.clone();
101            patterns.sort_by(|a, b| {
102                b.created_at
103                    .cmp(&a.created_at)
104                    .then_with(|| a.pattern_type.cmp(&b.pattern_type))
105                    .then_with(|| a.description.cmp(&b.description))
106            });
107            let total = patterns.len();
108            patterns.truncate(crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT);
109            for p in &patterns {
110                let ty = crate::core::sanitize::neutralize_metadata(&p.pattern_type);
111                let desc = crate::core::sanitize::neutralize_metadata(&p.description);
112                out.push_str(&format!("  [{ty}] {desc}\n"));
113            }
114            if total > crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT {
115                out.push_str(&format!(
116                    "  … +{} more\n",
117                    total - crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT
118                ));
119            }
120        }
121
122        if out.is_empty() {
123            out
124        } else {
125            crate::core::sanitize::fence_content("project_knowledge", out.trim_end())
126        }
127    }
128
129    pub fn format_aaak(&self) -> String {
130        // #212 — pre-prompt sensitivity floor: never inject facts whose stored or
131        // freshly-classified sensitivity meets/exceeds the configured floor. The
132        // short-circuit means zero classification cost when disabled (default).
133        // Persona floors (persona-spec-v1) are folded in via
134        // `sensitivity_effective`, so e.g. `support` keeps `internal`+ facts
135        // out of the prompt without any `[sensitivity]` config.
136        let sens = crate::core::config::Config::load().sensitivity_effective();
137        let current_facts: Vec<&KnowledgeFact> = self
138            .facts
139            .iter()
140            .filter(|f| f.is_current())
141            .filter(|f| {
142                !sens.enabled_effective()
143                    || !crate::core::sensitivity::floor_blocks(
144                        f.sensitivity
145                            .max(crate::core::sensitivity::classify_content(&f.value)),
146                        &sens,
147                    )
148            })
149            .collect();
150
151        if current_facts.is_empty() && self.patterns.is_empty() {
152            return String::new();
153        }
154
155        let mut out = String::new();
156
157        let mut rooms: Vec<(String, usize)> = self.list_rooms();
158        rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
159        rooms.truncate(crate::core::budgets::KNOWLEDGE_AAAK_ROOMS_LIMIT);
160
161        for (cat, _count) in rooms {
162            let mut facts_in_cat: Vec<&KnowledgeFact> = current_facts
163                .iter()
164                .copied()
165                .filter(|f| f.category == cat)
166                .collect();
167            facts_in_cat.sort_by(|a, b| sort_fact_for_output(a, b));
168            facts_in_cat.truncate(crate::core::budgets::KNOWLEDGE_AAAK_FACTS_PER_ROOM_LIMIT);
169
170            let items: Vec<String> = facts_in_cat
171                .iter()
172                .map(|f| {
173                    let stars = confidence_stars(f.confidence);
174                    let key = crate::core::sanitize::neutralize_metadata(&f.key);
175                    let val = crate::core::sanitize::neutralize_metadata(&f.value);
176                    format!("{key}={val}{stars}")
177                })
178                .collect();
179            out.push_str(&format!(
180                "{}:{}\n",
181                crate::core::sanitize::neutralize_metadata(&cat.to_uppercase()),
182                items.join("|")
183            ));
184        }
185
186        if !self.patterns.is_empty() {
187            let mut patterns = self.patterns.clone();
188            patterns.sort_by(|a, b| {
189                b.created_at
190                    .cmp(&a.created_at)
191                    .then_with(|| a.pattern_type.cmp(&b.pattern_type))
192                    .then_with(|| a.description.cmp(&b.description))
193            });
194            patterns.truncate(crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT);
195            let pat_items: Vec<String> = patterns
196                .iter()
197                .map(|p| {
198                    let ty = crate::core::sanitize::neutralize_metadata(&p.pattern_type);
199                    let desc = crate::core::sanitize::neutralize_metadata(&p.description);
200                    format!("{ty}.{desc}")
201                })
202                .collect();
203            out.push_str(&format!("PAT:{}\n", pat_items.join("|")));
204        }
205
206        if out.is_empty() {
207            out
208        } else {
209            crate::core::sanitize::fence_content("project_memory_aaak", out.trim_end())
210        }
211    }
212
213    pub fn format_aaak_delta(&self, last_hash: Option<&str>) -> AaakDelta {
214        let full = self.format_aaak();
215        let fact_count = self.facts.iter().filter(|f| f.is_current()).count();
216        if full.is_empty() {
217            return AaakDelta {
218                content: String::new(),
219                hash: String::new(),
220                is_full: false,
221                fact_count,
222            };
223        }
224        let hash = blake3::hash(full.as_bytes()).to_hex().to_string();
225        let is_full = last_hash.is_none_or(|prev| prev != hash);
226        let content = if is_full {
227            full
228        } else {
229            format!(
230                "[AAAK unchanged — {fact_count} facts, hash {}]",
231                &hash[..12]
232            )
233        };
234        AaakDelta {
235            content,
236            hash,
237            is_full,
238            fact_count,
239        }
240    }
241
242    pub fn format_aaak_budgeted(&self, token_budget: usize) -> String {
243        let sens = crate::core::config::Config::load().sensitivity_effective();
244        let current_facts: Vec<&KnowledgeFact> = self
245            .facts
246            .iter()
247            .filter(|f| f.is_current())
248            .filter(|f| {
249                !sens.enabled_effective()
250                    || !crate::core::sensitivity::floor_blocks(
251                        f.sensitivity
252                            .max(crate::core::sensitivity::classify_content(&f.value)),
253                        &sens,
254                    )
255            })
256            .collect();
257        if current_facts.is_empty() && self.patterns.is_empty() {
258            return String::new();
259        }
260        let mut out = String::new();
261        let mut tokens_used: usize = 0;
262        let mut rooms_omitted: usize = 0;
263        let mut facts_omitted: usize = 0;
264        let mut rooms: Vec<(String, usize)> = self.list_rooms();
265        rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
266        for (cat, _count) in &rooms {
267            let mut facts_in_cat: Vec<&KnowledgeFact> = current_facts
268                .iter()
269                .copied()
270                .filter(|f| &f.category == cat)
271                .collect();
272            facts_in_cat.sort_by(|a, b| sort_fact_for_output(a, b));
273            let items: Vec<String> = facts_in_cat
274                .iter()
275                .map(|f| {
276                    let stars = confidence_stars(f.confidence);
277                    let key = crate::core::sanitize::neutralize_metadata(&f.key);
278                    let val = crate::core::sanitize::neutralize_metadata(&f.value);
279                    format!("{key}={val}{stars}")
280                })
281                .collect();
282            let line = format!(
283                "{}:{}\n",
284                crate::core::sanitize::neutralize_metadata(&cat.to_uppercase()),
285                items.join("|")
286            );
287            let line_tokens = crate::core::tokens::count_tokens(&line);
288            if tokens_used + line_tokens > token_budget {
289                rooms_omitted += 1;
290                facts_omitted += facts_in_cat.len();
291                continue;
292            }
293            out.push_str(&line);
294            tokens_used += line_tokens;
295        }
296        if rooms_omitted > 0 {
297            out.push_str(&format!(
298                "[+{facts_omitted} facts in {rooms_omitted} rooms omitted — budget {token_budget} tokens]\n"
299            ));
300        }
301        if out.is_empty() {
302            out
303        } else {
304            crate::core::sanitize::fence_content("project_memory_aaak", out.trim_end())
305        }
306    }
307
308    pub fn format_wakeup(&self) -> String {
309        let current_facts: Vec<&KnowledgeFact> = self
310            .facts
311            .iter()
312            .filter(|f| f.is_current() && f.confidence >= 0.7)
313            .collect();
314
315        if current_facts.is_empty() {
316            return String::new();
317        }
318
319        // Theta-gamma chunking (#543): salience-ordered top-K facts grouped
320        // into 4±1-sized thematic chunks; shared headers amortize category
321        // prefixes (token savings) and prime related facts (recall).
322        let mut top_facts: Vec<&KnowledgeFact> = current_facts;
323        top_facts.sort_by(|a, b| sort_fact_for_output(a, b));
324        top_facts.truncate(20);
325
326        let clusters = super::chunking::cluster_facts(&top_facts);
327        crate::core::sanitize::fence_content(
328            "project_facts_wakeup",
329            &super::chunking::render_chunked(&clusters),
330        )
331    }
332}