1use chrono::{DateTime, Utc};
16
17use crate::core::archive::bindings::{self, Item};
18use crate::core::knowledge::{KnowledgeFact, ProjectKnowledge};
19use crate::core::memory_policy::MemoryPolicy;
20use crate::core::session::{Finding, SessionState};
21
22#[derive(Debug, Clone, Copy)]
24pub struct ConsolidationBudgets {
25 pub max_decisions: usize,
26 pub max_findings: usize,
27}
28
29impl Default for ConsolidationBudgets {
30 fn default() -> Self {
31 Self {
32 max_decisions: 5,
33 max_findings: 8,
34 }
35 }
36}
37
38#[derive(Debug, Clone)]
41pub struct ConsolidationOutcome {
42 pub promoted: u32,
43 pub promoted_decisions: u32,
44 pub promoted_findings: u32,
45 pub lifecycle_archived: usize,
46 pub lifecycle_remaining: usize,
47}
48
49#[derive(Debug, Clone)]
53pub struct ConsolidateOptions {
54 pub import_session: bool,
56 pub decision_budget: Option<usize>,
58 pub finding_budget: Option<usize>,
60 pub finding_salience_floor: Option<u32>,
62 pub decision_confidence: f32,
64 pub finding_confidence: f32,
66 pub incremental: bool,
69 pub run_lifecycle: bool,
71 pub reclaim_stores: bool,
73 pub emit_event: bool,
75 pub dry_run: bool,
77}
78
79impl ConsolidateOptions {
80 pub fn manual() -> Self {
84 Self {
85 import_session: true,
86 decision_budget: None,
87 finding_budget: None,
88 finding_salience_floor: Some(45),
89 decision_confidence: 0.85,
90 finding_confidence: 0.7,
91 incremental: false,
92 run_lifecycle: true,
93 reclaim_stores: true,
94 emit_event: false,
95 dry_run: false,
96 }
97 }
98
99 pub fn scheduled(b: ConsolidationBudgets) -> Self {
102 Self {
103 import_session: true,
104 decision_budget: Some(b.max_decisions),
105 finding_budget: Some(b.max_findings),
106 finding_salience_floor: Some(45),
107 decision_confidence: 0.9,
108 finding_confidence: 0.75,
109 incremental: false,
110 run_lifecycle: true,
111 reclaim_stores: false,
112 emit_event: true,
113 dry_run: false,
114 }
115 }
116
117 pub fn incremental_auto() -> Self {
119 Self {
120 import_session: true,
121 decision_budget: None,
122 finding_budget: None,
123 finding_salience_floor: Some(45),
124 decision_confidence: 0.85,
125 finding_confidence: 0.7,
126 incremental: true,
127 run_lifecycle: false,
128 reclaim_stores: false,
129 emit_event: false,
130 dry_run: false,
131 }
132 }
133
134 #[must_use]
136 pub fn into_dry_run(mut self) -> Self {
137 self.dry_run = true;
138 self
139 }
140}
141
142#[derive(Debug, Default, Clone, Copy)]
144pub struct ImportCounts {
145 pub decisions: usize,
146 pub findings: usize,
147}
148
149impl ImportCounts {
150 pub fn total(self) -> usize {
151 self.decisions + self.findings
152 }
153}
154
155pub fn import_session_into(
160 knowledge: &mut ProjectKnowledge,
161 session: &SessionState,
162 opts: &ConsolidateOptions,
163 policy: &MemoryPolicy,
164 watermark: Option<DateTime<Utc>>,
165) -> ImportCounts {
166 let is_new = |ts: DateTime<Utc>| watermark.is_none_or(|w| ts > w);
167
168 let mut decisions: Vec<&crate::core::session::Decision> = session
169 .decisions
170 .iter()
171 .filter(|d| is_new(d.timestamp))
172 .collect();
173 decisions.sort_by_key(|d| std::cmp::Reverse(d.timestamp));
174 let mut findings: Vec<&Finding> = session
175 .findings
176 .iter()
177 .filter(|f| is_new(f.timestamp))
178 .collect();
179 findings.sort_by_key(|f| std::cmp::Reverse(f.timestamp));
180 if let Some(n) = opts.decision_budget {
181 decisions.truncate(n);
182 }
183 let mut decision_count = 0;
184 for d in &decisions {
185 let key = slug_key(&d.summary, 50);
186 knowledge.remember(
187 "decision",
188 &key,
189 &d.summary,
190 &session.id,
191 opts.decision_confidence,
192 policy,
193 );
194 decision_count += 1;
195 }
196
197 let mut finding_count = 0;
198 for f in &findings {
199 if opts.finding_budget.is_some_and(|n| finding_count >= n) {
200 break;
201 }
202 if crate::core::auto_findings::is_activity_record(f.summary.trim()) {
203 continue;
204 }
205 if let Some(floor) = opts.finding_salience_floor
206 && crate::core::memory_salience::text_salience(&f.summary) < floor
207 {
208 continue;
209 }
210 let key = finding_key(f);
211 let stored = store_derived(
214 knowledge,
215 &[finding_item(session, f)],
216 session.project_root.as_deref(),
217 ["finding", &key, &f.summary],
218 opts.dry_run,
219 |knowledge| {
220 knowledge.remember(
221 "finding",
222 &key,
223 &f.summary,
224 &session.id,
225 opts.finding_confidence,
226 policy,
227 );
228 },
229 );
230 if stored {
231 finding_count += 1;
232 }
233 }
234
235 if opts.import_session {
236 store_session_facts(knowledge, session, opts.dry_run);
237 }
238
239 ImportCounts {
240 decisions: decision_count,
241 findings: finding_count,
242 }
243}
244
245fn finding_item<'a>(session: &'a SessionState, finding: &'a Finding) -> (Item<'a>, &'a str) {
246 let item = Item::SessionFinding {
247 session: &session.id,
248 file: finding.file.as_deref(),
249 };
250 (item, finding.summary.as_str())
251}
252
253pub(crate) fn store_session_facts(
258 knowledge: &mut ProjectKnowledge,
259 session: &SessionState,
260 dry_run: bool,
261) {
262 for (fact, finding) in session.derived_facts() {
263 let Some(finding) = finding else {
264 knowledge.add_fact(fact);
265 continue;
266 };
267 let (category, key, value) = (fact.category.clone(), fact.key.clone(), fact.value.clone());
268 store_derived(
269 knowledge,
270 &[finding_item(session, finding)],
271 session.project_root.as_deref(),
272 [&category, &key, &value],
273 dry_run,
274 |knowledge| {
275 knowledge.add_fact(fact);
276 },
277 );
278 }
279}
280
281pub(crate) fn store_derived(
299 knowledge: &mut ProjectKnowledge,
300 origins: &[(Item<'_>, &str)],
301 reader_project: Option<&str>,
302 fields: [&str; 3],
303 dry_run: bool,
304 store: impl FnOnce(&mut ProjectKnowledge),
305) -> bool {
306 let [category, key, text] = fields;
307 let root = knowledge.project_root.clone();
308 let fact = Item::KnowledgeFact {
309 project: &root,
310 category,
311 key,
312 };
313 let carried = bindings::carry(&fact, text, origins, reader_project);
314 store_carried(knowledge, carried, fields, dry_run, store)
315}
316
317pub fn store_carried(
324 knowledge: &mut ProjectKnowledge,
325 carried: Result<Option<bindings::Carried>, crate::core::context_admission::carrier::Withheld>,
326 [category, key, text]: [&str; 3],
327 dry_run: bool,
328 store: impl FnOnce(&mut ProjectKnowledge),
329) -> bool {
330 let enforced = crate::core::context_admission::stored::source_authority_required();
331 let root = knowledge.project_root.clone();
332 let fact = Item::KnowledgeFact {
333 project: &root,
334 category,
335 key,
336 };
337 let Ok(carried) = carried else {
338 return false;
339 };
340 let verbatim = carried.is_some() && knowledge.admits_verbatim([category, key, text]);
341 if enforced && carried.is_some() && !verbatim {
342 return false;
343 }
344 store(knowledge);
345 let kept = |stored: &KnowledgeFact| {
346 stored.is_current()
347 && stored.category == category
348 && stored.key == key
349 && stored.value == text
350 };
351 let names_files = carried.is_some()
354 || [key, text]
355 .iter()
356 .any(|part| !crate::core::content_chunk::extract_file_references(part).is_empty());
357 if names_files {
358 for stored in knowledge.facts.iter_mut().filter(|stored| kept(stored)) {
359 stored
360 .validity
361 .get_or_insert(crate::core::knowledge::FactValidity::Content);
362 }
363 }
364 let Some(carried) = carried.filter(|_| verbatim && !dry_run) else {
365 return true;
366 };
367 if !knowledge.facts.iter().any(kept) || carried.commit(&fact, text) || !enforced {
368 return true;
369 }
370 knowledge.facts.retain(|stored| !kept(stored));
371 knowledge.rebuild_index();
372 false
373}
374
375pub fn finding_key(f: &Finding) -> String {
379 match (&f.file, f.line) {
380 (Some(file), Some(line)) => format!("{file}:{line}"),
381 (Some(file), None) => file.clone(),
382 (None, _) => format!("finding-{}", slug_key(&f.summary, 36)),
383 }
384}
385
386pub fn consolidate_latest(
393 project_root: &str,
394 budgets: ConsolidationBudgets,
395) -> Result<ConsolidationOutcome, String> {
396 let opts = ConsolidateOptions::scheduled(budgets);
397 let report = crate::core::knowledge_consolidate::consolidate_project_knowledge_with(
398 project_root,
399 &opts,
400 )?;
401
402 Ok(ConsolidationOutcome {
403 promoted: (report.imported_decisions + report.imported_findings) as u32,
404 promoted_decisions: report.imported_decisions as u32,
405 promoted_findings: report.imported_findings as u32,
406 lifecycle_archived: report.lifecycle.archived_count,
407 lifecycle_remaining: report.lifecycle.remaining_facts,
408 })
409}
410
411pub fn slug_key(s: &str, max: usize) -> String {
414 let mut out = String::new();
415 for ch in s.chars() {
416 if out.len() >= max {
417 break;
418 }
419 if ch.is_ascii_alphanumeric() {
420 out.push(ch.to_ascii_lowercase());
421 } else if (ch.is_whitespace() || ch == '-' || ch == '_')
422 && !out.ends_with('-')
423 && !out.is_empty()
424 {
425 out.push('-');
426 }
427 }
428 out.trim_matches('-').to_string()
429}
430
431#[cfg(test)]
432pub mod tests {
433 use super::*;
434
435 #[test]
436 fn consolidate_promotes_decisions_and_salient_findings_only() {
437 let _lock = crate::core::data_dir::test_env_lock();
438 let tmp = tempfile::tempdir().expect("tempdir");
439 crate::test_env::set_var(
440 "LEAN_CTX_DATA_DIR",
441 tmp.path().to_string_lossy().to_string(),
442 );
443
444 let project_root = tmp.path().join("proj");
445 std::fs::create_dir_all(&project_root).expect("mkdir");
446 let project_root_str = project_root.to_string_lossy().to_string();
447
448 let mut session = SessionState::new();
449 session.project_root = Some(project_root_str.clone());
450 session.add_decision("Use archive-only memory lifecycle", None);
451 session.add_finding(None, None, "panic: index out of bounds");
452 session.add_finding(None, None, "just a note");
453 session.save().expect("save session");
454 let budgets = ConsolidationBudgets {
455 max_decisions: 5,
456 max_findings: 5,
457 };
458
459 let out = consolidate_latest(&project_root_str, budgets).expect("consolidate");
463 assert_eq!(out.promoted, 0);
464 let leaked = ProjectKnowledge::load(&project_root_str)
465 .is_some_and(|k| k.facts.iter().any(|f| f.value.contains("archive-only")));
466 assert!(
467 !leaked,
468 "another agent's decision reached project knowledge"
469 );
470
471 let own = std::sync::Arc::new(tokio::sync::RwLock::new(session));
473 let out = crate::core::session::caller::scope(Some(own), || {
474 consolidate_latest(&project_root_str, budgets)
475 })
476 .expect("consolidate");
477 assert!(out.promoted_decisions >= 1);
478 assert!(out.promoted_findings >= 1);
479
480 let k = ProjectKnowledge::load(&project_root_str).expect("knowledge saved");
481 let active = k.facts.iter().filter(|f| f.is_current()).count();
482 assert!(active >= 2, "expected promoted facts");
483
484 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
485 }
486
487 #[test]
488 fn finding_key_is_content_based_and_deterministic() {
489 let f1 = Finding {
490 file: Some("src/main.rs".into()),
491 line: Some(42),
492 summary: "boom".into(),
493 timestamp: Utc::now(),
494 };
495 assert_eq!(finding_key(&f1), "src/main.rs:42");
496
497 let f2 = Finding {
498 file: None,
499 line: None,
500 summary: "Race condition in cache".into(),
501 timestamp: Utc::now(),
502 };
503 assert_eq!(finding_key(&f2), finding_key(&f2));
505 assert_eq!(finding_key(&f2), "finding-race-condition-in-cache");
506 }
507
508 use crate::core::archive::delivered::LineageRefusal;
509 use crate::core::context_admission::stored::lineage_support::{
510 deliver, governed, project, reusable, scoped,
511 };
512 use crate::core::policy::runtime::TestPolicyOverride;
513
514 const BOUND: &str = "panic: the pool fails to lease";
515 const TYPED: &str = "error: the deploy fails without a token";
516
517 fn governed_with_auto_capture() -> crate::core::data_dir::IsolatedDataDir {
523 let data = governed();
524 let dir = crate::core::paths::config_dir_read_only().expect("isolated config dir");
525 std::fs::write(
526 dir.join("config.toml"),
527 "auto_capture = true\n\n[context_gateway]\nmode = \"governed\"\n",
528 )
529 .expect("config");
530 data
531 }
532
533 fn session_with_two_findings(root: &str) -> SessionState {
535 std::fs::write(format!("{root}/pool.rs"), "pub fn lease() {}\n").expect("source");
536 let mut session = SessionState::new();
537 session.project_root = Some(root.to_owned());
538 session.add_finding_for_file(Some("pool.rs"), Some(1), BOUND);
539 session.add_finding_for_file(None, None, TYPED);
540 session
541 }
542
543 fn import(
545 root: &str,
546 session: &SessionState,
547 dry_run: bool,
548 ) -> (ProjectKnowledge, ImportCounts) {
549 let options = ConsolidateOptions {
550 finding_salience_floor: None,
551 dry_run,
552 ..ConsolidateOptions::manual()
553 };
554 scoped(root, || {
555 let mut knowledge = ProjectKnowledge::new(root);
556 let counts = import_session_into(
557 &mut knowledge,
558 session,
559 &options,
560 &MemoryPolicy::default(),
561 None,
562 );
563 (knowledge, counts)
564 })
565 }
566
567 fn derived(knowledge: &ProjectKnowledge, category: &str, text: &str) -> Option<KnowledgeFact> {
568 knowledge
569 .facts
570 .iter()
571 .find(|fact| {
572 fact.is_current() && fact.category == category && fact.value.contains(text)
573 })
574 .cloned()
575 }
576
577 fn recall(root: &str, fact: &KnowledgeFact) -> (String, usize) {
579 deliver(root, || {
580 let item = Item::KnowledgeFact {
581 project: root,
582 category: &fact.category,
583 key: &fact.key,
584 };
585 match bindings::serve(&item, &fact.value, Some(root)) {
586 Ok(()) => format!("[{}] {}", fact.category, fact.value),
587 Err(withheld) => withheld.notice(),
588 }
589 })
590 }
591
592 #[test]
598 fn a_fact_derived_from_a_finding_keeps_the_binding_of_that_finding() {
599 let _data = governed_with_auto_capture();
600 let _community = TestPolicyOverride::set(None);
601 let (_directory, root) = project();
602 let session = session_with_two_findings(&root);
603
604 let (knowledge, counts) = import(&root, &session, false);
605 assert_eq!(counts.findings, 2);
606 let mut bound_facts = Vec::new();
607 for category in ["finding", "auto:blocker"] {
608 let bound = derived(&knowledge, category, BOUND).expect(category);
609 let (reply, recorded) = recall(&root, &bound);
610 assert!(reply.contains(BOUND), "{reply}");
611 assert_eq!(recorded, 1, "{category}: bound to pool.rs");
612 assert_eq!(reusable(&root, &reply), Ok(()));
613 bound_facts.push((bound, reply));
614
615 let typed = derived(&knowledge, category, TYPED).expect(category);
616 let (reply, recorded) = recall(&root, &typed);
617 assert!(reply.contains(TYPED), "{reply}");
618 assert_eq!(recorded, 0, "{category}: no binding to carry");
619 assert_eq!(reusable(&root, &reply), Err(LineageRefusal::Unknown));
620 }
621
622 std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
623 for (fact, reply) in &bound_facts {
624 assert_eq!(
625 reusable(&root, reply),
626 Err(LineageRefusal::SourceNoLongerAdmitted)
627 );
628 let (withheld, recorded) = recall(&root, fact);
629 assert!(!withheld.contains(BOUND), "{withheld}");
630 assert_eq!(recorded, 0);
631 }
632 let (knowledge, counts) = import(&root, &session, false);
633 assert_eq!(
634 counts.findings, 1,
635 "the finding that is not served is not imported"
636 );
637 for category in ["finding", "auto:blocker"] {
638 assert!(derived(&knowledge, category, BOUND).is_none(), "{category}");
639 assert!(derived(&knowledge, category, TYPED).is_some(), "{category}");
640 }
641 }
642
643 #[test]
645 fn a_dry_run_import_writes_no_binding() {
646 let _data = governed_with_auto_capture();
647 let _community = TestPolicyOverride::set(None);
648 let (_directory, root) = project();
649 let session = session_with_two_findings(&root);
650 let (knowledge, counts) = import(&root, &session, true);
651 assert_eq!(counts.findings, 2);
652 let previewed = derived(&knowledge, "auto:blocker", BOUND).expect("previewed");
653 assert_eq!(
654 recall(&root, &previewed).1,
655 0,
656 "no register entry was written"
657 );
658 }
659
660 #[test]
663 fn an_import_in_developer_mode_refuses_nothing() {
664 let _data = crate::core::data_dir::isolated_data_dir();
665 let config = crate::core::paths::config_dir_read_only().expect("isolated config dir");
667 std::fs::create_dir_all(&config).expect("config dir");
668 std::fs::write(config.join("config.toml"), "auto_capture = true\n").expect("config");
669 let _community = TestPolicyOverride::set(None);
670 let (_directory, root) = project();
671 let session = session_with_two_findings(&root);
672 std::fs::remove_file(format!("{root}/pool.rs")).expect("delete");
673 let (knowledge, counts) = import(&root, &session, false);
674 assert_eq!(counts.findings, 2);
675 for category in ["finding", "auto:blocker"] {
676 assert!(derived(&knowledge, category, BOUND).is_some(), "{category}");
677 }
678 }
679}