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
5impl ProjectKnowledge {
6    /// Record one consolidated insight, then losslessly reclaim history to its
7    /// capacity headroom. Returns the number of older insights archived (0 when
8    /// under cap). Was a hard `drain` that dropped old history permanently (#995).
9    pub fn consolidate(
10        &mut self,
11        summary: &str,
12        session_ids: Vec<String>,
13        policy: &MemoryPolicy,
14    ) -> usize {
15        self.history.push(ConsolidatedInsight {
16            summary: summary.to_string(),
17            from_sessions: session_ids,
18            timestamp: chrono::Utc::now(),
19        });
20
21        let archived = crate::core::memory_capacity::reclaim_store(
22            crate::core::memory_archive::MemoryStore::History,
23            Some(&self.project_hash),
24            &mut self.history,
25            policy.knowledge.max_history,
26            policy.lifecycle.reclaim_headroom_pct,
27            policy.lifecycle.reclaim_enabled,
28            |a, b| {
29                b.timestamp
30                    .cmp(&a.timestamp)
31                    .then_with(|| b.summary.cmp(&a.summary))
32            },
33        );
34        self.updated_at = chrono::Utc::now();
35        archived.len()
36    }
37
38    pub fn format_summary(&self) -> String {
39        let mut out = String::new();
40        let current_facts: Vec<&KnowledgeFact> =
41            self.facts.iter().filter(|f| f.is_current()).collect();
42
43        if !current_facts.is_empty() {
44            out.push_str("PROJECT KNOWLEDGE:\n");
45            let mut rooms: Vec<(String, usize)> = self.list_rooms();
46            rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
47
48            let total_rooms = rooms.len();
49            rooms.truncate(crate::core::budgets::KNOWLEDGE_SUMMARY_ROOMS_LIMIT);
50
51            for (cat, _count) in rooms {
52                out.push_str(&format!("  [{cat}]\n"));
53
54                let mut facts_in_cat: Vec<&KnowledgeFact> = current_facts
55                    .iter()
56                    .copied()
57                    .filter(|f| f.category == cat)
58                    .collect();
59                facts_in_cat.sort_by(|a, b| sort_fact_for_output(a, b));
60
61                let total_in_cat = facts_in_cat.len();
62                facts_in_cat.truncate(crate::core::budgets::KNOWLEDGE_SUMMARY_FACTS_PER_ROOM_LIMIT);
63
64                for f in facts_in_cat {
65                    let key = crate::core::sanitize::neutralize_metadata(&f.key);
66                    let val = crate::core::sanitize::neutralize_metadata(&f.value);
67                    out.push_str(&format!(
68                        "    {}: {} (confidence: {:.0}%)\n",
69                        key,
70                        val,
71                        f.confidence * 100.0
72                    ));
73                }
74                if total_in_cat > crate::core::budgets::KNOWLEDGE_SUMMARY_FACTS_PER_ROOM_LIMIT {
75                    out.push_str(&format!(
76                        "    … +{} more\n",
77                        total_in_cat - crate::core::budgets::KNOWLEDGE_SUMMARY_FACTS_PER_ROOM_LIMIT
78                    ));
79                }
80            }
81
82            if total_rooms > crate::core::budgets::KNOWLEDGE_SUMMARY_ROOMS_LIMIT {
83                out.push_str(&format!(
84                    "  … +{} more rooms\n",
85                    total_rooms - crate::core::budgets::KNOWLEDGE_SUMMARY_ROOMS_LIMIT
86                ));
87            }
88        }
89
90        if !self.patterns.is_empty() {
91            out.push_str("PROJECT PATTERNS:\n");
92            let mut patterns = self.patterns.clone();
93            patterns.sort_by(|a, b| {
94                b.created_at
95                    .cmp(&a.created_at)
96                    .then_with(|| a.pattern_type.cmp(&b.pattern_type))
97                    .then_with(|| a.description.cmp(&b.description))
98            });
99            let total = patterns.len();
100            patterns.truncate(crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT);
101            for p in &patterns {
102                let ty = crate::core::sanitize::neutralize_metadata(&p.pattern_type);
103                let desc = crate::core::sanitize::neutralize_metadata(&p.description);
104                out.push_str(&format!("  [{ty}] {desc}\n"));
105            }
106            if total > crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT {
107                out.push_str(&format!(
108                    "  … +{} more\n",
109                    total - crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT
110                ));
111            }
112        }
113
114        if out.is_empty() {
115            out
116        } else {
117            crate::core::sanitize::fence_content("project_knowledge", out.trim_end())
118        }
119    }
120
121    pub fn format_aaak(&self) -> String {
122        // #212 — pre-prompt sensitivity floor: never inject facts whose stored or
123        // freshly-classified sensitivity meets/exceeds the configured floor. The
124        // short-circuit means zero classification cost when disabled (default).
125        // Persona floors (persona-spec-v1) are folded in via
126        // `sensitivity_effective`, so e.g. `support` keeps `internal`+ facts
127        // out of the prompt without any `[sensitivity]` config.
128        let sens = crate::core::config::Config::load().sensitivity_effective();
129        let current_facts: Vec<&KnowledgeFact> = self
130            .facts
131            .iter()
132            .filter(|f| f.is_current())
133            .filter(|f| {
134                !sens.enabled_effective()
135                    || !crate::core::sensitivity::floor_blocks(
136                        f.sensitivity
137                            .max(crate::core::sensitivity::classify_content(&f.value)),
138                        &sens,
139                    )
140            })
141            .collect();
142
143        if current_facts.is_empty() && self.patterns.is_empty() {
144            return String::new();
145        }
146
147        let mut out = String::new();
148
149        let mut rooms: Vec<(String, usize)> = self.list_rooms();
150        rooms.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
151        rooms.truncate(crate::core::budgets::KNOWLEDGE_AAAK_ROOMS_LIMIT);
152
153        for (cat, _count) in rooms {
154            let mut facts_in_cat: Vec<&KnowledgeFact> = current_facts
155                .iter()
156                .copied()
157                .filter(|f| f.category == cat)
158                .collect();
159            facts_in_cat.sort_by(|a, b| sort_fact_for_output(a, b));
160            facts_in_cat.truncate(crate::core::budgets::KNOWLEDGE_AAAK_FACTS_PER_ROOM_LIMIT);
161
162            let items: Vec<String> = facts_in_cat
163                .iter()
164                .map(|f| {
165                    let stars = confidence_stars(f.confidence);
166                    let key = crate::core::sanitize::neutralize_metadata(&f.key);
167                    let val = crate::core::sanitize::neutralize_metadata(&f.value);
168                    format!("{key}={val}{stars}")
169                })
170                .collect();
171            out.push_str(&format!(
172                "{}:{}\n",
173                crate::core::sanitize::neutralize_metadata(&cat.to_uppercase()),
174                items.join("|")
175            ));
176        }
177
178        if !self.patterns.is_empty() {
179            let mut patterns = self.patterns.clone();
180            patterns.sort_by(|a, b| {
181                b.created_at
182                    .cmp(&a.created_at)
183                    .then_with(|| a.pattern_type.cmp(&b.pattern_type))
184                    .then_with(|| a.description.cmp(&b.description))
185            });
186            patterns.truncate(crate::core::budgets::KNOWLEDGE_PATTERNS_LIMIT);
187            let pat_items: Vec<String> = patterns
188                .iter()
189                .map(|p| {
190                    let ty = crate::core::sanitize::neutralize_metadata(&p.pattern_type);
191                    let desc = crate::core::sanitize::neutralize_metadata(&p.description);
192                    format!("{ty}.{desc}")
193                })
194                .collect();
195            out.push_str(&format!("PAT:{}\n", pat_items.join("|")));
196        }
197
198        if out.is_empty() {
199            out
200        } else {
201            crate::core::sanitize::fence_content("project_memory_aaak", out.trim_end())
202        }
203    }
204
205    pub fn format_wakeup(&self) -> String {
206        let current_facts: Vec<&KnowledgeFact> = self
207            .facts
208            .iter()
209            .filter(|f| f.is_current() && f.confidence >= 0.7)
210            .collect();
211
212        if current_facts.is_empty() {
213            return String::new();
214        }
215
216        // Theta-gamma chunking (#543): salience-ordered top-K facts grouped
217        // into 4±1-sized thematic chunks; shared headers amortize category
218        // prefixes (token savings) and prime related facts (recall).
219        let mut top_facts: Vec<&KnowledgeFact> = current_facts;
220        top_facts.sort_by(|a, b| sort_fact_for_output(a, b));
221        top_facts.truncate(20);
222
223        let clusters = super::chunking::cluster_facts(&top_facts);
224        crate::core::sanitize::fence_content(
225            "project_facts_wakeup",
226            &super::chunking::render_chunked(&clusters),
227        )
228    }
229}