1use chrono::Utc;
2use std::collections::{HashMap, HashSet};
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex, OnceLock};
5
6use super::ranking::hash_project_root;
7use super::types::{ConsolidatedInsight, KnowledgeFact, ProjectKnowledge, ProjectPattern};
8use crate::core::memory_policy::MemoryPolicy;
9
10fn knowledge_dir(project_hash: &str) -> Result<PathBuf, String> {
11 Ok(crate::core::data_dir::lean_ctx_data_dir()?
12 .join("knowledge")
13 .join(project_hash))
14}
15
16pub fn knowledge_dir_of(project_root: &str) -> Result<PathBuf, String> {
18 knowledge_dir(&hash_project_root(project_root))
19}
20
21const KNOWLEDGE_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
27
28fn knowledge_lock(project_hash: &str) -> Arc<Mutex<()>> {
34 static KNOWLEDGE_LOCKS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
35 let map = KNOWLEDGE_LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
36 let mut guard = map
37 .lock()
38 .unwrap_or_else(std::sync::PoisonError::into_inner);
39 guard
40 .entry(project_hash.to_string())
41 .or_insert_with(|| Arc::new(Mutex::new(())))
42 .clone()
43}
44
45#[cfg(feature = "embeddings")]
52fn acquire_file_lock(dir: &Path) -> Option<std::fs::File> {
53 use fs2::FileExt;
54 let lock_path = dir.join(".knowledge.lock");
55 let file = std::fs::OpenOptions::new()
56 .create(true)
57 .truncate(false)
58 .write(true)
59 .open(&lock_path)
60 .ok()?;
61 #[cfg(unix)]
62 {
63 use std::os::unix::fs::PermissionsExt;
64 let _ = std::fs::set_permissions(&lock_path, std::fs::Permissions::from_mode(0o600));
65 }
66 file.lock_exclusive().ok()?;
69 Some(file)
70}
71
72fn write_json_atomic(path: &Path, json: &str) -> Result<(), String> {
79 #[cfg(unix)]
80 let owner_only: Option<std::fs::Permissions> =
81 Some(std::os::unix::fs::PermissionsExt::from_mode(0o600));
82 #[cfg(not(unix))]
83 let owner_only: Option<std::fs::Permissions> = None;
84 crate::core::atomic_fs::try_atomic_write(path, json.as_bytes(), owner_only.as_ref())
85 .map_err(|_| "knowledge publication failed".to_string())
86}
87
88fn parse_store_cached(path: &Path) -> Result<ProjectKnowledge, &'static str> {
95 type Key = (u64, Option<std::time::SystemTime>, u64);
96 static PARSED: OnceLock<Mutex<HashMap<PathBuf, (Key, ProjectKnowledge)>>> = OnceLock::new();
97 const KEPT: usize = 8;
98
99 let metadata =
100 std::fs::symlink_metadata(path).map_err(|_| "knowledge input metadata unavailable")?;
101 if !metadata.is_file() {
102 return Err("knowledge input is unavailable or uninspectable");
103 }
104 #[cfg(unix)]
105 let inode = std::os::unix::fs::MetadataExt::ino(&metadata);
106 #[cfg(not(unix))]
107 let inode = 0;
108 let key: Key = (metadata.len(), metadata.modified().ok(), inode);
109 let cache = PARSED.get_or_init(Mutex::default);
110 if let Some((cached_key, knowledge)) = cache
111 .lock()
112 .unwrap_or_else(std::sync::PoisonError::into_inner)
113 .get(path)
114 && *cached_key == key
115 {
116 return Ok(knowledge.clone());
117 }
118 let text = crate::core::policy::diagnostics::Target::read_store_file(path)
119 .ok_or("knowledge input is unavailable or uninspectable")?;
120 let knowledge: ProjectKnowledge =
121 serde_json::from_str(&text).map_err(|_| "knowledge input is invalid")?;
122 let mut parsed = cache
123 .lock()
124 .unwrap_or_else(std::sync::PoisonError::into_inner);
125 if parsed.len() >= KEPT && !parsed.contains_key(path) {
126 parsed.clear();
127 }
128 parsed.insert(path.to_path_buf(), (key, knowledge.clone()));
129 Ok(knowledge)
130}
131
132fn store_is_policy_safe(
137 store: &serde_json::Value,
138 policy: &crate::core::policy::runtime::ActivePolicy,
139) -> bool {
140 let unchanged = |value: &serde_json::Value| {
141 crate::core::policy::diagnostics::inspect(value, Some(policy)).as_ref() == Some(value)
142 };
143 match store {
144 serde_json::Value::Object(fields) => fields.values().all(|field| match field {
145 serde_json::Value::Array(records) => records.iter().all(unchanged),
146 other => unchanged(other),
147 }),
148 other => unchanged(other),
149 }
150}
151
152pub(crate) const MACHINE_DERIVED_PREFIX: &str = "auto:";
159
160pub fn is_machine_derived(category: &str, key: &str) -> bool {
162 category.trim_start().starts_with(MACHINE_DERIVED_PREFIX)
163 || key.trim_start().starts_with(MACHINE_DERIVED_PREFIX)
164}
165
166pub(crate) fn machine_derived_writes_allowed() -> bool {
182 crate::core::auto_capture::is_enabled()
183}
184
185impl ProjectKnowledge {
186 pub fn add_fact(&mut self, mut fact: KnowledgeFact) -> bool {
189 let trimmed_cat = fact.category.trim().to_string();
190 fact.category = trimmed_cat;
191 let trimmed_val = fact.value.trim().to_string();
192 fact.value = trimmed_val;
193 if fact.category.is_empty() || fact.value.is_empty() {
194 return false;
195 }
196 if is_machine_derived(&fact.category, &fact.key) && !machine_derived_writes_allowed() {
200 return false;
201 }
202 let Some([category, key, value]) = super::core::admit_fact_fields(
204 &self.project_root,
205 [&fact.category, &fact.key, &fact.value],
206 ) else {
207 return false;
208 };
209 (fact.category, fact.key, fact.value) = (category, key, value);
210
211 if let Some(existing) = self.facts.iter_mut().find(|existing| {
212 existing.category == fact.category
213 && existing.value == fact.value
214 && existing.origin == fact.origin
215 }) {
216 if fact.last_confirmed > existing.last_confirmed {
217 existing.last_confirmed = fact.last_confirmed;
218 }
219 existing.confidence = existing.confidence.max(fact.confidence);
220 existing.confirmation_count = existing.confirmation_count.saturating_add(1);
221 existing.update_fidelity();
222 } else {
223 fact.update_fidelity();
224 self.facts.push(fact);
225 self.rebuild_index();
226 }
227
228 self.updated_at = Utc::now();
229 true
230 }
231
232 pub fn recent_decisions(&self, limit: usize) -> Vec<KnowledgeFact> {
233 let mut decisions: Vec<KnowledgeFact> = self
234 .facts
235 .iter()
236 .filter(|fact| {
237 let category = fact.category.to_ascii_lowercase();
238 fact.is_current()
239 && (category.contains("decision")
240 || category.contains("architecture")
241 || category.contains("solution"))
242 })
243 .cloned()
244 .collect();
245 decisions.sort_by(|a, b| {
246 b.last_confirmed
247 .cmp(&a.last_confirmed)
248 .then_with(|| b.created_at.cmp(&a.created_at))
249 });
250 decisions.truncate(limit);
251 decisions
252 }
253 pub fn search_semantic(&self, query: &str, limit: usize) -> Vec<KnowledgeFact> {
258 let query_lower = query.to_lowercase();
259 let terms: Vec<&str> = query_lower.split_whitespace().collect();
260 if terms.is_empty() {
261 return Vec::new();
262 }
263 let mut scored: Vec<(usize, &KnowledgeFact)> = self
264 .facts
265 .iter()
266 .filter(|f| f.valid_until.is_none_or(|t| t > chrono::Utc::now()))
267 .map(|fact| {
268 let haystack = format!(
269 "{} {} {}",
270 fact.category.to_lowercase(),
271 fact.value.to_lowercase(),
272 fact.key.to_lowercase(),
273 );
274 let score = terms.iter().filter(|t| haystack.contains(*t)).count();
275 (score, fact)
276 })
277 .filter(|(score, _)| *score > 0)
278 .collect();
279 scored.sort_by_key(|a| std::cmp::Reverse(a.0));
280 scored
281 .into_iter()
282 .take(limit)
283 .map(|(_, f)| f.clone())
284 .collect()
285 }
286
287 pub fn list_project_roots() -> Result<Vec<String>, String> {
288 let base = crate::core::data_dir::lean_ctx_data_dir()?.join("knowledge");
289 if !base.exists() {
290 return Ok(Vec::new());
291 }
292
293 let mut roots = Vec::new();
294 let mut seen = HashSet::new();
295 let entries = std::fs::read_dir(&base).map_err(|e| e.to_string())?;
296 for entry in entries.flatten() {
297 let path = entry.path().join("knowledge.json");
298 if !path.is_file() {
299 continue;
300 }
301 let Ok(content) = std::fs::read_to_string(&path) else {
302 continue;
303 };
304 let Ok(knowledge) = serde_json::from_str::<Self>(&content) else {
305 continue;
306 };
307 if seen.insert(knowledge.project_root.clone()) {
308 roots.push(knowledge.project_root);
309 }
310 }
311
312 roots.sort();
313 Ok(roots)
314 }
315
316 pub fn load_for_admission(
319 project_root: &str,
320 admission: &super::protection::KnowledgeAdmission,
321 ) -> Result<Option<Self>, String> {
322 let hash = hash_project_root(project_root);
323 let path = knowledge_dir(&hash)?.join("knowledge.json");
324 match std::fs::symlink_metadata(&path) {
325 Ok(metadata) if metadata.is_file() => {}
326 Ok(_) => return Err("knowledge store is not a regular file".into()),
327 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
328 let _ = Self::load_for_checked_capture(project_root)?;
331 admission.authorize_gateway()?;
332 return Ok(None);
333 }
334 Err(_) => return Err("knowledge store metadata unavailable".into()),
335 }
336 let raw = Self::load_for_checked_capture(project_root)?;
337 let view = admission.view(&raw)?.admit_sources();
338 admission.authorize_gateway()?;
339 Ok(Some(view))
340 }
341
342 pub fn load_for_checked_capture(project_root: &str) -> Result<Self, String> {
345 let hash = hash_project_root(project_root);
346 let path = knowledge_dir(&hash)?.join("knowledge.json");
347 match std::fs::symlink_metadata(&path) {
348 Ok(_) => {
349 let mut knowledge = parse_store_cached(&path)?;
350 let canonical = |root: &str| {
351 crate::core::pathutil::safe_canonicalize_bounded(Path::new(root), 2000)
352 };
353 if canonical(&knowledge.project_root) != canonical(project_root) {
357 return Err("knowledge input belongs to a different project".into());
358 }
359 knowledge.project_hash = hash;
360 knowledge.rebuild_index();
361 return Ok(knowledge);
362 }
363 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
364 Err(_) => return Err("knowledge input metadata unavailable".into()),
365 }
366 let mut legacy = crate::core::project_hash::legacy_unnormalized_hashes(project_root);
367 legacy.push(crate::core::project_hash::hash_path_only(project_root));
368 for old_hash in legacy.into_iter().filter(|old| old != &hash) {
369 match std::fs::symlink_metadata(knowledge_dir(&old_hash)?.join("knowledge.json")) {
370 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
371 _ => return Err("knowledge requires an explicit legacy migration".into()),
372 }
373 }
374 Ok(Self::new(project_root))
375 }
376
377 pub(crate) fn save_policy_checked(
378 &self,
379 source_tool: &str,
380 original_content: &str,
381 ) -> Result<(), String> {
382 if let Some(policy) =
383 crate::core::policy::runtime::for_project(Path::new(&self.project_root))?
384 {
385 if !policy.tool_allowed("ctx_knowledge")
386 || !policy.tool_allowed(source_tool)
387 || crate::core::policy::content::evaluate_text(original_content, &policy).blocked
388 {
389 return Err("knowledge persistence withheld by policy".into());
390 }
391 let original =
392 serde_json::to_value(self).map_err(|_| "knowledge is not inspectable")?;
393 if !store_is_policy_safe(&original, &policy) {
394 return Err("knowledge persistence requires policy-safe fields".into());
398 }
399 }
400 self.save_unlocked()
402 }
403
404 pub fn save(&self) -> Result<(), String> {
405 let admission = super::protection::KnowledgeAdmission::capture(&self.project_root)?;
406 if Path::new(&self.project_root).is_dir() {
407 crate::core::policy::runtime::with_explicit_project_source_view(
408 Path::new(&self.project_root),
409 |authority| {
410 Self::with_project_lock_checked(&self.project_root, || {
411 admission.authorize_publication(authority)?;
412 self.save_with_admission(&admission)
413 })?
414 },
415 )?
416 } else {
417 if crate::core::policy::runtime::inherited_source_view().is_some() {
418 return Err("knowledge project root unavailable".into());
419 }
420 Self::with_project_lock_checked(&self.project_root, || {
421 self.save_with_admission(&admission)
422 })?
423 }
424 }
425
426 fn save_unlocked(&self) -> Result<(), String> {
427 let admission = super::protection::KnowledgeAdmission::capture(&self.project_root)?;
428 self.save_with_admission(&admission)
429 }
430
431 fn save_with_admission(
432 &self,
433 admission: &super::protection::KnowledgeAdmission,
434 ) -> Result<(), String> {
435 let candidate = if self.withheld.is_empty() {
436 self.clone()
437 } else {
438 let existing = Self::load_for_checked_capture(&self.project_root)?;
439 self.complete_for_storage(&existing)?
440 };
441 candidate.save_complete_with_admission(admission)
442 }
443
444 fn save_complete_with_admission(
445 &self,
446 admission: &super::protection::KnowledgeAdmission,
447 ) -> Result<(), String> {
448 if self.project_hash != hash_project_root(&self.project_root) {
449 return Err("knowledge storage identity mismatch".into());
450 }
451 let existing = Self::load_for_checked_capture(&self.project_root)?;
452 admission.require_safe(&existing)?;
453 admission.require_safe(self)?;
454
455 let dir = knowledge_dir(&self.project_hash)?;
456 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
457 let _ = crate::core::secure_fs::restrict_to_owner(&dir);
458
459 let path = dir.join("knowledge.json");
460 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
461 admission.authorize_gateway()?;
462 write_json_atomic(&path, &json)?;
463 super::journal::append(
466 &dir,
467 &super::journal::diff(&existing.facts, &self.facts),
468 &Utc::now().to_rfc3339(),
469 );
470 Ok(())
471 }
472
473 #[cfg(feature = "embeddings")]
484 pub fn with_project_lock<T>(project_root: &str, f: impl FnOnce() -> T) -> T {
485 let hash = hash_project_root(project_root);
486 let lock = knowledge_lock(&hash);
487 let _guard = lock
488 .lock()
489 .unwrap_or_else(std::sync::PoisonError::into_inner);
490
491 let _file_lock = match knowledge_dir(&hash) {
495 Ok(dir) => {
496 let _ = std::fs::create_dir_all(&dir);
497 acquire_file_lock(&dir)
498 }
499 Err(_) => None,
500 };
501
502 f()
503 }
504
505 pub(crate) fn with_project_lock_checked<T>(
514 project_root: &str,
515 f: impl FnOnce() -> T,
516 ) -> Result<T, String> {
517 use fs2::FileExt;
518 let hash = hash_project_root(project_root);
519 let dir = knowledge_dir(&hash)?;
520 let _turn =
525 crate::core::file_lock::take_turn(&dir.join(".knowledge.lock"), KNOWLEDGE_LOCK_WAIT)
526 .ok_or("knowledge lock unavailable")?;
527 let lock = knowledge_lock(&hash);
528 let deadline = std::time::Instant::now() + KNOWLEDGE_LOCK_WAIT;
529 let _guard = loop {
530 match lock.try_lock() {
531 Ok(guard) => break guard,
532 Err(std::sync::TryLockError::WouldBlock)
533 if std::time::Instant::now() < deadline =>
534 {
535 std::thread::sleep(std::time::Duration::from_millis(5));
536 }
537 Err(_) => return Err("knowledge lock unavailable".into()),
538 }
539 };
540 std::fs::create_dir_all(&dir).map_err(|_| "knowledge directory unavailable")?;
541 let file = crate::core::policy::diagnostics::open_append(&dir.join(".knowledge.lock"))
542 .map_err(|_| "knowledge file lock unavailable")?;
543 loop {
544 match file.try_lock_exclusive() {
545 Ok(()) => break,
546 Err(e)
547 if crate::core::file_lock::is_contended(&e)
548 && std::time::Instant::now() < deadline =>
549 {
550 std::thread::sleep(std::time::Duration::from_millis(5));
551 }
552 Err(_) => return Err("knowledge file lock unavailable".into()),
553 }
554 }
555 let output = f();
556 let _ = FileExt::unlock(&file);
557 Ok(output)
558 }
559
560 pub fn mutate_locked<T>(
561 project_root: &str,
562 f: impl FnOnce(&mut Self) -> T,
563 ) -> Result<(Self, T), String> {
564 let admission = super::protection::KnowledgeAdmission::capture(project_root)?;
565 Self::mutate_locked_with_admission(project_root, &admission, f)
566 }
567
568 pub fn mutate_locked_with_admission<T>(
569 project_root: &str,
570 admission: &super::protection::KnowledgeAdmission,
571 f: impl FnOnce(&mut Self) -> T,
572 ) -> Result<(Self, T), String> {
573 crate::core::providers::provenance::with_reuse_deadline(|| {
574 Self::mutate_locked_in_scope(project_root, admission, f)
575 })
576 }
577
578 fn mutate_locked_in_scope<T>(
579 project_root: &str,
580 admission: &super::protection::KnowledgeAdmission,
581 f: impl FnOnce(&mut Self) -> T,
582 ) -> Result<(Self, T), String> {
583 if Path::new(project_root).is_dir() {
584 crate::core::policy::runtime::with_explicit_project_source_view(
585 Path::new(project_root),
586 |authority| Self::mutate_locked_scoped(project_root, admission, Some(authority), f),
587 )?
588 } else {
589 if crate::core::policy::runtime::inherited_source_view().is_some() {
590 return Err("knowledge project root unavailable".into());
591 }
592 if let Some(request) = crate::core::policy::diagnostics::request_project()
593 && crate::core::pathutil::safe_canonicalize_bounded(&request, 2000)
594 != crate::core::pathutil::safe_canonicalize_bounded(
595 Path::new(project_root),
596 2000,
597 )
598 {
599 return Err("knowledge project does not match protected request".into());
600 }
601 Self::mutate_locked_scoped(project_root, admission, None, f)
602 }
603 }
604
605 fn mutate_locked_scoped<T>(
606 project_root: &str,
607 admission: &super::protection::KnowledgeAdmission,
608 authority: Option<&crate::core::policy::runtime::PublicationAuthority>,
609 f: impl FnOnce(&mut Self) -> T,
610 ) -> Result<(Self, T), String> {
611 Self::with_project_lock_checked(project_root, || {
612 let current = knowledge_dir(&hash_project_root(project_root))?.join("knowledge.json");
616 if !current.exists()
617 && let Some(old) =
618 crate::core::legacy_adoption::drifted_knowledge_partition(project_root)
619 {
620 crate::core::legacy_adoption::copy_knowledge_partition(&old, project_root)?;
621 }
622 let raw = Self::load_for_checked_capture(project_root)?;
623 admission.require_safe(&raw)?;
624 let mut knowledge = raw.admit_sources();
625 let out = f(&mut knowledge);
626 admission.require_safe(&knowledge)?;
627 admission.authorize_gateway()?;
628 if let Some(authority) = authority {
629 authority.verify()?;
630 }
631 knowledge.save_with_admission(admission)?;
632 Ok((knowledge.admit_sources(), out))
633 })?
634 }
635
636 pub fn load(project_root: &str) -> Option<Self> {
637 let admission = super::protection::KnowledgeAdmission::capture(project_root).ok()?;
638 let legacy_source_safe = |legacy_hash: &str| {
639 let Ok(path) = knowledge_dir(legacy_hash) else {
640 return false;
641 };
642 let path = path.join("knowledge.json");
643 match std::fs::symlink_metadata(&path) {
644 Err(error) if error.kind() == std::io::ErrorKind::NotFound => true,
645 Err(_) => false,
646 Ok(_) => {
647 let Some(content) =
648 crate::core::policy::diagnostics::Target::read_store_file(&path)
649 else {
650 return false;
651 };
652 let Ok(legacy) = serde_json::from_str::<Self>(&content) else {
653 return false;
654 };
655 crate::core::pathutil::safe_canonicalize_bounded(
656 Path::new(&legacy.project_root),
657 2000,
658 ) == crate::core::pathutil::safe_canonicalize_bounded(
659 Path::new(project_root),
660 2000,
661 ) && admission.require_safe(&legacy).is_ok()
662 }
663 }
664 };
665 match super::protection::current(project_root) {
666 Ok(Some(_)) => {
667 let path = knowledge_dir(&hash_project_root(project_root))
668 .ok()?
669 .join("knowledge.json");
670 std::fs::symlink_metadata(path).ok()?;
671 let raw = Self::load_for_checked_capture(project_root).ok()?;
672 return admission.view(&raw).ok().map(Self::admit_sources);
673 }
674 Err(_) => return None,
675 Ok(None) => {}
676 }
677 let hash = hash_project_root(project_root);
678 let dir = knowledge_dir(&hash).ok()?;
679 let path = dir.join("knowledge.json");
680
681 if let Ok(mut k) = parse_store_cached(&path) {
682 if crate::core::pathutil::safe_canonicalize_bounded(Path::new(&k.project_root), 2000)
685 != crate::core::pathutil::safe_canonicalize_bounded(Path::new(project_root), 2000)
686 {
687 return None;
688 }
689 k.project_hash = hash;
690 k.rebuild_index();
691 return admission.view(&k).ok().map(Self::admit_sources);
692 }
693
694 let old_hash = crate::core::project_hash::hash_path_only(project_root);
695 if old_hash != hash {
696 if !legacy_source_safe(&old_hash) || admission.authorize_gateway().is_err() {
697 return None;
698 }
699 crate::core::project_hash::migrate_if_needed(&old_hash, &hash, project_root);
700 if let Ok(content) = std::fs::read_to_string(&path)
701 && let Ok(mut k) = serde_json::from_str::<Self>(&content)
702 {
703 k.project_hash = hash;
704 k.rebuild_index();
705 if admission.require_safe(&k).is_err()
706 || admission.authorize_gateway().is_err()
707 || k.save_with_admission(&admission).is_err()
708 {
709 return None;
710 }
711 return admission.view(&k).ok().map(Self::admit_sources);
712 }
713 }
714
715 for legacy_hash in crate::core::project_hash::legacy_unnormalized_hashes(project_root) {
720 if legacy_hash == hash {
721 continue;
722 }
723 if !legacy_source_safe(&legacy_hash) || admission.authorize_gateway().is_err() {
724 return None;
725 }
726 crate::core::project_hash::migrate_if_needed(&legacy_hash, &hash, project_root);
727 if let Ok(content) = std::fs::read_to_string(&path)
728 && let Ok(mut k) = serde_json::from_str::<Self>(&content)
729 {
730 k.project_hash = hash;
731 k.rebuild_index();
732 if admission.require_safe(&k).is_err()
733 || admission.authorize_gateway().is_err()
734 || k.save_with_admission(&admission).is_err()
735 {
736 return None;
737 }
738 return admission.view(&k).ok().map(Self::admit_sources);
739 }
740 }
741
742 if let Some(old_hash) =
747 crate::core::legacy_adoption::drifted_knowledge_partition(project_root)
748 {
749 if !legacy_source_safe(&old_hash) || admission.authorize_gateway().is_err() {
750 return None;
751 }
752 crate::core::legacy_adoption::copy_knowledge_partition(&old_hash, project_root).ok()?;
753 if let Ok(content) = std::fs::read_to_string(&path)
754 && let Ok(mut k) = serde_json::from_str::<Self>(&content)
755 && crate::core::pathutil::safe_canonicalize_bounded(
756 Path::new(&k.project_root),
757 2000,
758 ) == crate::core::pathutil::safe_canonicalize_bounded(
759 Path::new(project_root),
760 2000,
761 )
762 {
763 k.project_hash = hash;
764 k.rebuild_index();
765 if admission.require_safe(&k).is_err()
766 || admission.authorize_gateway().is_err()
767 || k.save_with_admission(&admission).is_err()
768 {
769 return None;
770 }
771 return admission.view(&k).ok().map(Self::admit_sources);
772 }
773 }
774
775 None
776 }
777
778 pub fn load_or_create(project_root: &str) -> Self {
779 Self::load(project_root).unwrap_or_else(|| Self::new(project_root))
780 }
781
782 pub fn migrate_legacy_empty_root(
785 target_root: &str,
786 policy: &MemoryPolicy,
787 ) -> Result<bool, String> {
788 if target_root.trim().is_empty() {
789 return Ok(false);
790 }
791
792 if super::protection::current(target_root)?.is_some() {
793 return Err("unscoped legacy knowledge requires explicit protected migration".into());
794 }
795 let mut legacy_store = None;
798 for hash in [
799 hash_project_root(""),
800 crate::core::project_hash::hash_path_only(""),
801 ] {
802 let path = knowledge_dir(&hash)?.join("knowledge.json");
803 match std::fs::symlink_metadata(&path) {
804 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
805 Err(_) => return Err("legacy knowledge input unavailable".into()),
806 Ok(_) => {}
807 }
808 let text = crate::core::policy::diagnostics::Target::read_store_file(&path)
809 .ok_or("legacy knowledge input is uninspectable")?;
810 let legacy: Self =
811 serde_json::from_str(&text).map_err(|_| "invalid legacy knowledge")?;
812 legacy_store = Some((legacy, path));
813 break;
814 }
815 let Some((legacy, legacy_path)) = legacy_store else {
816 return Ok(false);
817 };
818
819 if !legacy.project_root.trim().is_empty() {
820 return Ok(false);
821 }
822 if legacy.facts.is_empty() && legacy.patterns.is_empty() && legacy.history.is_empty() {
823 return Ok(false);
824 }
825
826 Self::with_project_lock_checked(target_root, || {
827 if super::protection::current(target_root)?.is_some() {
828 return Err("legacy migration withheld by changed project policy".into());
829 }
830 let mut target = Self::load_or_create(target_root);
831
832 fn fact_key(f: &KnowledgeFact) -> String {
833 format!(
834 "{}|{}|{}|{}|{}",
835 f.category, f.key, f.value, f.source_session, f.created_at
836 )
837 }
838 fn pattern_key(p: &ProjectPattern) -> String {
839 format!(
840 "{}|{}|{}|{}",
841 p.pattern_type, p.description, p.source_session, p.created_at
842 )
843 }
844 fn history_key(h: &ConsolidatedInsight) -> String {
845 format!(
846 "{}|{}|{}",
847 h.summary,
848 h.from_sessions.join(","),
849 h.timestamp
850 )
851 }
852
853 let mut seen_facts: std::collections::HashSet<String> =
854 target.facts.iter().map(fact_key).collect();
855 for f in legacy.facts {
856 if seen_facts.insert(fact_key(&f)) {
857 target.facts.push(f);
858 }
859 }
860
861 let mut seen_patterns: std::collections::HashSet<String> =
862 target.patterns.iter().map(pattern_key).collect();
863 for p in legacy.patterns {
864 if seen_patterns.insert(pattern_key(&p)) {
865 target.patterns.push(p);
866 }
867 }
868
869 let mut seen_history: std::collections::HashSet<String> =
870 target.history.iter().map(history_key).collect();
871 for h in legacy.history {
872 if seen_history.insert(history_key(&h)) {
873 target.history.push(h);
874 }
875 }
876
877 target.facts.sort_by(|a, b| {
878 b.created_at
879 .cmp(&a.created_at)
880 .then_with(|| b.confidence.total_cmp(&a.confidence))
881 });
882 target
883 .patterns
884 .sort_by_key(|x| std::cmp::Reverse(x.created_at));
885 if target.patterns.len() > policy.knowledge.max_patterns {
886 target.patterns.truncate(policy.knowledge.max_patterns);
887 }
888 target
889 .history
890 .sort_by_key(|x| std::cmp::Reverse(x.timestamp));
891 if target.history.len() > policy.knowledge.max_history {
892 target.history.truncate(policy.knowledge.max_history);
893 }
894
895 target.updated_at = Utc::now();
896 if super::protection::current(target_root)?.is_some() {
897 return Err("legacy migration withheld by changed project policy".into());
898 }
899 target.save_unlocked()?;
900
901 let legacy_dir = legacy_path.parent().ok_or("legacy directory unavailable")?;
902 if legacy_path.exists() {
903 let ts = Utc::now().format("%Y%m%d-%H%M%S");
904 let backup = legacy_dir.join(format!("knowledge.legacy-empty-root.{ts}.json"));
905 std::fs::hard_link(&legacy_path, &backup)
906 .map_err(|_| "legacy backup could not be preserved")?;
907 std::fs::remove_file(&legacy_path)
908 .map_err(|_| "legacy backup retained but source cleanup failed")?;
909 }
910
911 Ok(true)
912 })?
913 }
914}
915
916#[cfg(test)]
917mod tests {
918 use super::*;
919 #[cfg(feature = "embeddings")]
920 use fs2::FileExt;
921
922 fn machine_fact(category: &str, key: &str) -> KnowledgeFact {
925 let now = chrono::Utc::now();
926 KnowledgeFact {
927 anchors: Vec::new(),
928 review: None,
929 capture: Default::default(),
930 origin: crate::core::knowledge::FactOrigin::Local,
931 category: category.to_string(),
932 key: key.to_string(),
933 value: "Finding: run-guards.ts: Read run-guards.ts (92L)".to_string(),
934 source_session: "session-1".to_string(),
935 confidence: 0.8,
936 created_at: now,
937 last_confirmed: now,
938 retrieval_count: 0,
939 last_retrieved: None,
940 valid_from: None,
941 valid_until: None,
942 supersedes: None,
943 confirmation_count: 1,
944 feedback_up: 0,
945 feedback_down: 0,
946 last_feedback: None,
947 privacy: Default::default(),
948 sensitivity: Default::default(),
949 imported_from: None,
950 archetype: Default::default(),
951 fidelity: None,
952 revision_count: 0,
953 status: None,
954 validity: None,
955 }
956 }
957
958 #[test]
959 fn machine_derived_is_recognised_in_either_ingestion_shape() {
960 assert!(is_machine_derived("auto:blocker", "run-guards.ts"));
963 assert!(is_machine_derived("blocker", "auto:config.rs"));
964 assert!(!is_machine_derived("konventionen", "naming"));
965 assert!(!is_machine_derived("ops", "deploy"));
966 }
967
968 #[test]
969 fn a_curated_fact_is_never_affected_by_the_gate() {
970 let _data = crate::core::data_dir::isolated_data_dir();
975 let _community = crate::core::policy::runtime::TestPolicyOverride::set(None);
976 let knowledge_root = tempfile::tempdir().unwrap();
977 let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
978 assert!(
979 knowledge.add_fact(machine_fact("konventionen", "naming")),
980 "curated facts must always be accepted"
981 );
982 }
983
984 #[test]
985 fn disabled_capture_blocks_every_machine_derived_shape() {
986 let _env = crate::core::data_dir::test_env_lock();
990 crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "0");
991
992 let knowledge_root = tempfile::tempdir().unwrap();
993
994 let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
995 let category_marked = knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
996 let key_marked = knowledge.add_fact(machine_fact("blocker", "auto:config.rs"));
997
998 crate::test_env::remove_var("LEAN_CTX_AUTO_CAPTURE");
999
1000 assert!(!category_marked, "auto:* category must be refused");
1001 assert!(!key_marked, "auto:* key must be refused");
1002 assert!(
1003 knowledge.facts.is_empty(),
1004 "nothing machine-derived may reach the durable store"
1005 );
1006 }
1007
1008 #[test]
1009 fn enabled_capture_still_stores_machine_derived_facts() {
1010 let _env = crate::core::data_dir::test_env_lock();
1011 crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "1");
1012
1013 let knowledge_root = tempfile::tempdir().unwrap();
1014
1015 let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
1016 let stored = knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
1017
1018 crate::test_env::remove_var("LEAN_CTX_AUTO_CAPTURE");
1019
1020 assert!(stored, "the default behaviour must be unchanged");
1021 }
1022
1023 #[test]
1024 fn disabled_capture_does_not_refresh_existing_machine_facts() {
1025 let _env = crate::core::data_dir::test_env_lock();
1030 crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "1");
1031 let knowledge_root = tempfile::tempdir().unwrap();
1032 let mut knowledge = ProjectKnowledge::new(knowledge_root.path().to_str().unwrap());
1033 knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
1034 let confirmed_while_enabled = knowledge.facts[0].last_confirmed;
1035
1036 crate::test_env::set_var("LEAN_CTX_AUTO_CAPTURE", "0");
1037 let refreshed = knowledge.add_fact(machine_fact("auto:blocker", "run-guards.ts"));
1038 crate::test_env::remove_var("LEAN_CTX_AUTO_CAPTURE");
1039
1040 assert!(!refreshed);
1041 assert_eq!(knowledge.facts.len(), 1);
1042 assert_eq!(
1043 knowledge.facts[0].last_confirmed, confirmed_while_enabled,
1044 "a refused write must not refresh the existing fact"
1045 );
1046 }
1047
1048 #[cfg(feature = "embeddings")]
1049 #[test]
1050 fn file_lock_is_exclusive_across_handles() {
1051 let dir = tempfile::tempdir().unwrap();
1057 let held = acquire_file_lock(dir.path()).expect("first lock must succeed");
1058
1059 let second = std::fs::OpenOptions::new()
1060 .create(true)
1061 .truncate(false)
1062 .write(true)
1063 .open(dir.path().join(".knowledge.lock"))
1064 .unwrap();
1065 assert!(
1066 second.try_lock_exclusive().is_err(),
1067 "a second handle must not acquire the lock while it is held"
1068 );
1069
1070 drop(held);
1071 let mut reacquired = false;
1077 for _ in 0..50 {
1078 if second.try_lock_exclusive().is_ok() {
1079 reacquired = true;
1080 break;
1081 }
1082 std::thread::sleep(std::time::Duration::from_millis(10));
1083 }
1084 assert!(
1085 reacquired,
1086 "lock must be acquirable within 500ms of release"
1087 );
1088 }
1089
1090 #[test]
1091 fn write_json_atomic_leaves_valid_file_and_no_temp() {
1092 let dir = tempfile::tempdir().unwrap();
1093 let path = dir.path().join("knowledge.json");
1094 write_json_atomic(&path, "{\"ok\":true}").unwrap();
1095 assert_eq!(std::fs::read_to_string(&path).unwrap(), "{\"ok\":true}");
1096 let leftover = std::fs::read_dir(dir.path())
1097 .unwrap()
1098 .filter_map(Result::ok)
1099 .any(|e| e.file_name().to_string_lossy().contains(".tmp."));
1100 assert!(!leftover, "no temp file should remain");
1101 }
1102
1103 #[test]
1104 fn load_rebuilds_ephemeral_index_without_changing_json() {
1105 let _isolated = crate::core::data_dir::isolated_data_dir();
1106 let project = tempfile::tempdir().unwrap();
1107 let root = project.path().to_str().unwrap();
1108 let policy = MemoryPolicy::default();
1109 let mut knowledge = ProjectKnowledge::new(root);
1110 knowledge.remember(
1111 "architecture",
1112 "database",
1113 "PostgreSQL",
1114 "test",
1115 0.9,
1116 &policy,
1117 );
1118 knowledge.save().unwrap();
1119
1120 let loaded = ProjectKnowledge::load(root).expect("saved knowledge should load");
1121 assert!(loaded.index.token_positions.contains_key("postgresql"));
1122 assert_eq!(loaded.recall("postgresql").len(), 1);
1123
1124 let json = serde_json::to_string(&loaded).unwrap();
1125 assert!(!json.contains("\"index\""));
1126 }
1127}