1use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{
7 McpTool, ToolContext, ToolOutput, get_f64, get_str, get_str_array,
8};
9use lean_ctx::tool_defs::tool_def;
10
11pub struct CtxKnowledgeTool;
12
13impl McpTool for CtxKnowledgeTool {
14 fn name(&self) -> &'static str {
15 "ctx_knowledge"
16 }
17
18 fn tool_def(&self) -> Tool {
19 tool_def(
20 "ctx_knowledge",
21 "Persistent memory across sessions — remember decisions, patterns, and facts for recall.\n\
22 WORKFLOW: save after completing significant tasks; recall at session start.\n\
23 action=remember value='Y' saves a fact (key optional — derived from value; content= is an accepted alias).\n\
24 action=recall query='X' retrieves it (bare recall lists recent facts). action=status shows all categories.\n\
25action=consolidate imports latest session if present, runs lifecycle, then frees 25% history/patterns capacity.\n\
26 action=gotcha trigger='X' resolution='Y' for known pitfalls.\n\
27 mode=semantic|exact for recall. category groups related facts.",
28 json!({
29 "type": "object",
30 "properties": {
31 "action": {
32 "type": "string",
33 "description": "remember|recall|search|pattern|gotcha|relate|relations|consolidate|restore|status|timeline|rooms|wakeup|remove|export|import (also: feedback, unrelate, relations_diagram, health, lifecycle_report, policy, embeddings_*; changes query=<lsn>|session, repin, coverage)"
34 },
35 "trigger": { "type": "string", "description": "gotcha trigger pattern" },
36 "resolution": { "type": "string", "description": "gotcha resolution/fix" },
37 "severity": { "type": "string", "description": "gotcha: critical|warning|info" },
38 "category": { "type": "string", "description": "Fact category" },
39 "key": { "type": "string" },
40 "value": { "type": "string", "description": "remember/pattern: the text. wakeup (optional): the memory hash a full briefing ended with, only while that briefing is still in your context — answered \"unchanged\" only if exactly that briefing was released to a request of the same host presenter capability; in every other case the full briefing is returned" },
41 "content": { "type": "string", "description": "Alias for value (remember)" },
42 "sources": { "type": "array", "items": { "type": "string" }, "description": "remember: project files the fact is derived from (read now and bound to it)" },
43 "anchors": { "type": "array", "items": { "type": "string" }, "description": "remember: code the fact is about, path or path::symbol (max 16); a later change there marks the fact to verify" },
44 "type": { "type": "string", "description": "remember: fact|decision|gotcha|procedure|finding (Hive capture)" },
45 "refs": { "type": "array", "items": { "type": "string" }, "description": "remember: references for Hive capture (max 10): file:<path>[#L<a>-<b>], sym:<path>#<symbol>, test:<path>::<name>, commit:<sha>, https://…" },
46 "evidence": { "type": "string", "description": "remember: observed|stated|imported" },
47 "steps": { "type": "array", "items": { "type": "string" }, "description": "remember type=procedure: steps (value = title)" },
48 "commands": { "type": "array", "items": { "type": "object", "properties": { "shell": { "type": "string" }, "cmd": { "type": "string" }, "cwd_hint": { "type": "string" } }, "required": ["shell", "cmd"] }, "description": "remember type=procedure: commands as data (never executed)" },
49 "validity": { "type": "string", "description": "remember: convention (project rule, shared with linked checkouts regardless of revision) | content (bound to its sources' revision). Default: convention for category convention/decision, content when sources bind" },
50 "query": { "type": "string", "description": "Query for recall/search/relate/restore" },
51 "mode": { "type": "string", "description": "auto|exact|semantic|hybrid" },
52 "as_of": { "type": "string", "description": "YYYY-MM-DD date filter" },
53 "pattern_type": { "type": "string" },
54 "examples": { "type": "array", "items": { "type": "string" } },
55 "confidence": { "type": "number", "description": "0.0-1.0" },
56 "store": { "type": "string", "description": "restore: facts|history|patterns (default: all)" },
57 "limit": { "type": "number", "description": "restore: max items to recover (default 50)" },
58 "dry_run": { "type": "boolean", "description": "consolidate: preview imports/reclaim without writing" },
59 "format": { "type": "string", "description": "export: json|okf (okf = portable Markdown bundle)" },
60 "path": { "type": "string", "description": "export/import: bundle directory (OKF) or file path" },
61 "merge": { "type": "string", "description": "import: replace|append|skip-existing (default skip-existing)" }
62 },
63 "allOf": [
64 {
65 "if": { "properties": { "action": { "const": "remember" } }, "required": ["action"] },
66 "then": { "required": ["category"], "anyOf": [{ "required": ["value"] }, { "required": ["content"] }] }
67 },
68 {
69 "if": { "properties": { "action": { "const": "pattern" } }, "required": ["action"] },
70 "then": { "required": ["value"] }
71 },
72 {
73 "if": { "properties": { "action": { "const": "search" } }, "required": ["action"] },
74 "then": { "required": ["query"] }
75 },
76 {
77 "if": { "properties": { "action": { "const": "gotcha" } }, "required": ["action"] },
78 "then": { "required": ["trigger", "resolution"] }
79 }
80 ],
81 "required": ["action"]
82 }),
83 )
84 }
85
86 fn handle(
87 &self,
88 args: &Map<String, Value>,
89 ctx: &ToolContext,
90 ) -> Result<ToolOutput, ErrorData> {
91 let action = get_str(args, "action")
92 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
93 let category = get_str(args, "category");
94 let key = get_str(args, "key");
95 let value = get_str(args, "value").or_else(|| get_str(args, "content"));
98 let query = get_str(args, "query");
99 let mode = get_str(args, "mode");
100 let as_of = get_str(args, "as_of");
101 let pattern_type = get_str(args, "pattern_type");
102 let examples = get_str_array(args, "examples");
103 let confidence = get_f64(args, "confidence").map(|v| v as f32);
104 let validity = match get_str(args, "validity") {
105 None => None,
106 Some(raw) => Some(
107 lean_ctx::core::knowledge::FactValidity::parse(&raw).ok_or_else(|| {
108 ErrorData::invalid_params("validity must be convention or content", None)
109 })?,
110 ),
111 };
112
113 let session_handle = ctx
114 .session
115 .as_ref()
116 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
117 let (session_id, project_root) = {
118 let read_result =
119 crate::server::bounded_lock::read(session_handle, "ctx_knowledge session read");
120 if let Some(session) = read_result {
121 let sid = session.id.clone();
122 let root = session
123 .project_root
124 .clone()
125 .unwrap_or_else(|| ctx.project_root.clone());
126 (sid, root)
127 } else {
128 tracing::warn!("ctx_knowledge: session read-lock timeout, using fallback");
129 ("unknown".to_string(), ctx.project_root.clone())
130 }
131 };
132
133 if action == "gotcha" {
134 let trigger = get_str(args, "trigger").unwrap_or_default();
135 let resolution = get_str(args, "resolution").unwrap_or_default();
136 let severity = get_str(args, "severity").unwrap_or_default();
137 let cat = category.as_deref().unwrap_or("convention");
138
139 if trigger.is_empty() || resolution.is_empty() {
140 return Ok(text_output(
141 &action,
142 "ERROR: trigger and resolution are required for gotcha action".to_string(),
143 ));
144 }
145
146 let mut store = lean_ctx::core::gotcha_tracker::GotchaStore::load(&project_root);
147 let msg = match store.report_gotcha(&trigger, &resolution, cat, &severity, &session_id)
148 {
149 Some(gotcha) => {
150 let conf = (gotcha.confidence * 100.0) as u32;
151 let label = gotcha.category.short_label();
152 format!("Gotcha recorded: [{label}] {trigger} (confidence: {conf}%)")
153 }
154 None => {
155 format!("Gotcha noted: {trigger} (evicted by higher-confidence entries)")
156 }
157 };
158 let _ = store.save(&project_root);
159 return Ok(text_output(&action, msg));
160 }
161
162 if action == "restore" {
166 let store = match get_str(args, "store").as_deref() {
167 Some(s) if lean_ctx::core::memory_archive::MemoryStore::is_retired(s) => {
168 return Ok(text_output(
169 &action,
170 lean_ctx::core::memory_archive::RETIRED_PROCEDURES_STORE_NOTICE.to_string(),
171 ));
172 }
173 Some(s) => match lean_ctx::core::memory_archive::MemoryStore::parse(s) {
174 Some(ms) => Some(ms),
175 None => {
176 return Ok(text_output(
177 &action,
178 format!("Unknown store: {s}. Use: facts, history, patterns"),
179 ));
180 }
181 },
182 None => None,
183 };
184 let limit = get_f64(args, "limit")
185 .map_or(crate::tools::ctx_knowledge::DEFAULT_RESTORE_LIMIT, |v| {
186 v as usize
187 });
188 let opts =
189 crate::tools::ctx_knowledge::RestoreOptions::new(store, query.clone(), limit);
190 let text = match crate::tools::ctx_knowledge::run_restore(&project_root, &opts) {
191 Ok(report) => crate::tools::ctx_knowledge::format_restore_report(&report),
192 Err(e) => e,
193 };
194 return Ok(text_output(&action, text));
195 }
196
197 let dry_run = args
199 .get("dry_run")
200 .and_then(Value::as_bool)
201 .unwrap_or(false);
202 if action == "consolidate" && dry_run {
203 let text = match crate::tools::ctx_knowledge::consolidate_project_knowledge_with(
204 &project_root,
205 &lean_ctx::core::consolidation_engine::ConsolidateOptions::manual().into_dry_run(),
206 ) {
207 Ok(report) => crate::tools::ctx_knowledge::format_consolidation_report(&report),
208 Err(e) => e,
209 };
210 return Ok(text_output(&action, text));
211 }
212
213 if action == "export" && get_str(args, "format").as_deref() == Some("okf") {
216 let out = match get_str(args, "path").or_else(|| get_str(args, "output")) {
217 Some(p) => match jailed_path("ctx_knowledge.export", &p, &project_root) {
218 Ok(p) => {
220 match lean_ctx::core::workspace_authority::confine(&p, &project_root, true)
221 {
222 Ok(p) => Some(p),
223 Err(error) => {
224 return Ok(text_output(&action, format!("ERROR: {error}")));
225 }
226 }
227 }
228 Err(e) => return Ok(text_output(&action, e)),
229 },
230 None => None,
231 };
232 return Ok(text_output(
233 &action,
234 crate::tools::ctx_knowledge::handle_export_okf(&project_root, out.as_deref()),
235 ));
236 }
237 let journal_before = journal_head(&project_root);
241 if action == "import" {
242 let Some(path) = get_str(args, "path").or_else(|| query.clone()) else {
243 return Ok(text_output(
244 &action,
245 "ERROR: import requires `path` (a file or an OKF directory)".to_string(),
246 ));
247 };
248 let path =
249 match jailed_path("ctx_knowledge.import", &path, &project_root).and_then(|p| {
250 lean_ctx::core::workspace_authority::confine(&p, &project_root, false)
252 .map_err(|error| format!("ERROR: {error}"))
253 }) {
254 Ok(p) => p,
255 Err(e) => return Ok(text_output(&action, e)),
256 };
257 let merge = get_str(args, "merge")
258 .and_then(|s| lean_ctx::core::knowledge::ImportMerge::parse(&s))
259 .unwrap_or(lean_ctx::core::knowledge::ImportMerge::SkipExisting);
260 let text = crate::tools::ctx_knowledge::handle_import(
261 &project_root,
262 &path,
263 merge,
264 &session_id,
265 );
266 push_if_written(&project_root, journal_before);
267 return Ok(text_output(&action, text));
268 }
269
270 let outcome = crate::tools::ctx_knowledge::handle_outcome(
275 &project_root,
276 &action,
277 category.as_deref(),
278 key.as_deref(),
279 value.as_deref(),
280 query.as_deref(),
281 &session_id,
282 pattern_type.as_deref(),
283 examples,
284 confidence,
285 mode.as_deref(),
286 as_of.as_deref(),
287 );
288 let failed = outcome.is_err();
289 let (Ok(result) | Err(result)) = outcome;
290 let named = get_str_array(args, "sources").unwrap_or_default();
291 let result = if action == "remember" && !failed {
292 remembered_with(
293 result,
294 &project_root,
295 category.as_deref(),
296 key.as_deref(),
297 value.as_deref(),
298 &named,
299 validity,
300 )
301 } else {
302 result
303 };
304 let anchors = get_str_array(args, "anchors").unwrap_or_default();
306 let result = match (
307 action == "remember" && !failed && !anchors.is_empty(),
308 category.as_deref(),
309 value.as_deref(),
310 ) {
311 (true, Some(category), Some(value)) => {
312 match crate::tools::ctx_knowledge::anchor_remembered(
313 &project_root,
314 category,
315 key.as_deref(),
316 value,
317 &anchors,
318 ) {
319 Ok(set) => format!("{result}\nanchors: {} recorded", set.len()),
320 Err(reason) => {
321 format!("{result}\nanchors: not recorded ({reason}); the fact has none")
322 }
323 }
324 }
325 _ => result,
326 };
327 let result = match (
329 action == "remember" && !failed,
330 category.as_deref(),
331 value.as_deref(),
332 ) {
333 (true, Some(category), Some(value)) => match capture_fields(args) {
334 Ok(None) => result,
335 Ok(Some(fields)) => match crate::tools::ctx_knowledge::capture_remembered(
336 &project_root,
337 category,
338 key.as_deref(),
339 value,
340 fields,
341 ) {
342 Ok(()) => format!("{result}\ncapture: recorded"),
343 Err(reason) => format!("{result}\ncapture: not recorded ({reason})"),
344 },
345 Err(reason) => format!("{result}\ncapture: not recorded ({reason})"),
346 },
347 _ => result,
348 };
349 let result = match (action == "remember" && !failed, value.as_deref()) {
352 (true, Some(value)) => {
353 let has_ref = get_str_array(args, "refs").is_some_and(|r| !r.is_empty())
354 || !anchors.is_empty();
355 match lean_ctx_hive::capture::sharing::note(&project_root, value, has_ref) {
356 Some(line) => format!("{result}\nhive: {line}"),
357 None => result,
358 }
359 }
360 _ => result,
361 };
362 push_if_written(&project_root, journal_before);
363
364 if failed {
365 return Err(crate::server::tool_trait::tool_execution_error(result));
366 }
367 Ok(text_output(&action, result))
368 }
369
370 fn publishes_every_source(&self, args: Option<&Map<String, Value>>) -> bool {
386 matches!(
387 args.and_then(|args| get_str(args, "action")).as_deref(),
388 Some("recall" | "timeline" | "search")
389 )
390 }
391}
392
393fn journal_head(project_root: &str) -> Option<u64> {
395 lean_ctx::core::knowledge::knowledge_dir_of(project_root)
396 .ok()
397 .map(|dir| lean_ctx::core::knowledge::journal::head_lsn(&dir))
398}
399
400fn push_if_written(project_root: &str, before: Option<u64>) {
403 if before.is_some() && journal_head(project_root) != before {
404 lean_ctx_hive::sync::on_write::push_soon(project_root);
405 }
406}
407
408fn capture_fields(
410 args: &Map<String, Value>,
411) -> Result<Option<lean_ctx::core::knowledge::FactCapture>, String> {
412 use lean_ctx::core::knowledge::{EvidenceLevel, FactCapture, NodeType, ProcedureCommand};
413 let node_type = get_str(args, "type")
414 .map(|t| NodeType::parse(&t).ok_or_else(|| format!("type {t:?}: not a node type")))
415 .transpose()?;
416 let evidence = get_str(args, "evidence")
417 .map(|e| EvidenceLevel::parse(&e).ok_or_else(|| format!("evidence {e:?}: not a level")))
418 .transpose()?;
419 let commands: Vec<ProcedureCommand> = match args.get("commands") {
420 Some(v) => serde_json::from_value(v.clone()).map_err(|e| format!("commands: {e}"))?,
421 None => Vec::new(),
422 };
423 let fields = FactCapture {
424 node_type,
425 steps: get_str_array(args, "steps").unwrap_or_default(),
426 commands,
427 refs: get_str_array(args, "refs").unwrap_or_default(),
428 evidence,
429 changed_at: None,
430 };
431 Ok((fields != FactCapture::default()).then_some(fields))
432}
433
434const SOURCES_BOUND: &str = "sources: bound to the fact";
435const SOURCES_NOT_BOUND: &str = "sources: not bound (a named source is not an admitted project file, or the fact was not stored as given); the fact has no source binding";
436const SOURCES_NOT_BOUND_CONVENTION: &str =
437 "sources: not bound (a convention is not bound to a revision)";
438
439fn remembered_with(
445 reply: String,
446 project_root: &str,
447 category: Option<&str>,
448 key: Option<&str>,
449 value: Option<&str>,
450 named: &[String],
451 validity: Option<lean_ctx::core::knowledge::FactValidity>,
452) -> String {
453 use lean_ctx::core::knowledge::FactValidity;
454 let mut reply = reply;
455 if !named.is_empty() {
456 let line = if validity == Some(FactValidity::Convention) {
457 SOURCES_NOT_BOUND_CONVENTION
458 } else if bind_remembered(project_root, category, key, value, named) {
459 SOURCES_BOUND
460 } else {
461 SOURCES_NOT_BOUND
462 };
463 reply = format!("{reply}\n{line}");
464 }
465 let class = validity.or((!named.is_empty()).then_some(FactValidity::Content));
468 if let (Some(class), Some(category), Some(value)) = (class, category, value)
469 && crate::tools::ctx_knowledge::classify_remembered(
470 project_root,
471 category,
472 key,
473 value,
474 class,
475 )
476 {
477 reply = format!("{reply}\nvalidity: {}", class.as_str());
478 }
479 reply
480}
481
482fn bind_remembered(
488 project_root: &str,
489 category: Option<&str>,
490 key: Option<&str>,
491 value: Option<&str>,
492 sources: &[String],
493) -> bool {
494 use lean_ctx::core::archive::bindings::{self, Item};
495 let (Some(category), Some(value)) = (category, value) else {
496 return false;
497 };
498 let Some(knowledge) = lean_ctx::core::knowledge::ProjectKnowledge::load(project_root) else {
499 return false;
500 };
501 let Some(fact) = knowledge.facts.iter().rev().find(|fact| {
502 fact.is_current()
503 && fact.category == category
504 && fact.value == value
505 && key.is_none_or(|key| fact.key == key)
506 }) else {
507 return false;
508 };
509 let Some(sources) = bindings::sources_at(project_root, sources, "ctx_knowledge") else {
510 return false;
511 };
512 let item = Item::KnowledgeFact {
513 project: project_root,
514 category: &fact.category,
515 key: &fact.key,
516 };
517 bindings::bind(&item, &fact.value, project_root, sources)
518}
519
520fn jailed_path(tool: &str, raw: &str, project_root: &str) -> Result<String, String> {
524 let root = std::path::Path::new(project_root);
525 let candidate = std::path::Path::new(raw);
526 let candidate = if candidate.is_absolute() {
527 candidate.to_path_buf()
528 } else {
529 root.join(candidate)
530 };
531 lean_ctx::core::io_boundary::jail_and_check_path(tool, &candidate, root)
532 .map(|(p, _warning)| p.to_string_lossy().into_owned())
533}
534
535fn text_output(action: &str, text: String) -> ToolOutput {
538 ToolOutput {
539 text,
540 original_tokens: 0,
541 saved_tokens: 0,
542 mode: Some(action.to_string()),
543 path: None,
544 changed: false,
545 shell_outcome: None,
546 content_blocks: None,
547 }
548}
549
550#[cfg(test)]
551mod source_binding_tests {
552 use super::*;
553 use lean_ctx::core::archive::bindings::{self, Item};
554 use lean_ctx::core::context_admission::stored::lineage_support::{
555 deliver, governed, project, reusable,
556 };
557
558 fn remember(root: &str, key: &str, value: &str) {
559 let reply = crate::tools::ctx_knowledge::handle(
560 root,
561 "remember",
562 Some("architecture"),
563 Some(key),
564 Some(value),
565 None,
566 "session-1",
567 None,
568 None,
569 None,
570 None,
571 None,
572 );
573 assert!(!reply.starts_with("ERROR"), "{reply}");
574 }
575
576 #[test]
580 fn remember_binds_the_stored_fact_to_the_sources_named_with_it() {
581 let _data = governed();
582 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
583 let (_directory, root) = project();
584 std::fs::write(format!("{root}/auth.rs"), "pub fn verify_rs256() {}\n").expect("source");
585 std::fs::write(format!("{root}/plan.md"), "*** TOP SECRET ***\nlaunch\n").expect("marked");
586 let value = "Tokens are verified with RS256 in the auth module";
587 remember(&root, "token-check", value);
588 let key = |sources: &[&str]| {
589 let sources: Vec<String> = sources.iter().map(|path| (*path).to_owned()).collect();
590 bind_remembered(
591 &root,
592 Some("architecture"),
593 Some("token-check"),
594 Some(value),
595 &sources,
596 )
597 };
598 assert!(!key(&["plan.md"]), "a restricted source binds nothing");
599 assert!(!key(&["auth.rs", "missing.rs"]), "all sources or none");
600 assert!(!bind_remembered(
601 &root,
602 Some("architecture"),
603 Some("token-check"),
604 Some("another value"),
605 &["auth.rs".to_owned()],
606 ));
607 let item = Item::KnowledgeFact {
608 project: &root,
609 category: "architecture",
610 key: "token-check",
611 };
612 let recall = || {
613 deliver(&root, || {
614 bindings::serve(&item, value, Some(&root)).expect("served");
615 format!("[architecture/token-check]: {value}")
616 })
617 };
618 assert_eq!(recall().1, 0, "stored, and originless so far");
619
620 assert!(key(&["auth.rs"]));
621 let (reply, recorded) = recall();
622 assert_eq!(recorded, 1);
623 assert_eq!(reusable(&root, &reply), Ok(()));
624 }
625
626 fn git(dir: &str, args: &[&str]) {
627 let status = std::process::Command::new("git")
628 .args(["-c", "user.name=t", "-c", "user.email=t@example.invalid"])
629 .args(args)
630 .current_dir(dir)
631 .env_remove("GIT_DIR")
632 .env_remove("GIT_WORK_TREE")
633 .status()
634 .expect("git runs");
635 assert!(status.success(), "git {args:?}");
636 }
637
638 #[test]
645 fn linked_worktrees_share_conventions_and_withhold_stale_code() {
646 use lean_ctx::core::project_link::{ProjectLink, link, link_time};
647 let _data = governed();
648 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
649 let (_main_dir, main) = project();
650 let (_other_dir, other_parent) = project();
651 let feature = format!("{other_parent}/feature");
652 std::fs::write(format!("{main}/retry.rs"), "fn retry() { /* bounded */ }\n").unwrap();
653 git(&main, &["init", "-q", "-b", "main"]);
654 git(&main, &["add", "retry.rs"]);
655 git(&main, &["commit", "-q", "-m", "retry"]);
656 git(&main, &["worktree", "add", "-q", "-b", "feature", &feature]);
657 let feature = lean_ctx::core::pathutil::safe_canonicalize(std::path::Path::new(&feature))
658 .unwrap()
659 .to_str()
660 .unwrap()
661 .to_owned();
662 std::fs::write(format!("{feature}/retry.rs"), "fn retry() { loop {} }\n").unwrap();
663 git(&feature, &["commit", "-q", "-am", "unbounded"]);
664 for root in [&main, &feature] {
665 link(ProjectLink {
666 workspace_root: root.clone(),
667 team_project_id: "gateway".into(),
668 organization_id: None,
669 label: None,
670 linked_at: link_time(),
671 })
672 .unwrap();
673 }
674
675 let convention = "Branch names follow the pattern type slash slug";
676 let code = "The retry loop in retry.rs is bounded by its budget";
677 let local = "The release captain promotes deployments on Fridays";
678 let remember_in = |category: &str, key: &str, value: &str| {
679 let reply = crate::tools::ctx_knowledge::handle(
680 &main,
681 "remember",
682 Some(category),
683 Some(key),
684 Some(value),
685 None,
686 "session-1",
687 None,
688 None,
689 None,
690 None,
691 None,
692 );
693 assert!(reply.starts_with("Remembered"), "{reply}");
694 };
695 remember_in("decision", "branch-naming", convention);
696 remember_in("architecture", "retry-bound", code);
697 remember_in("architecture", "release-duty", local);
698 let reply = remembered_with(
699 String::new(),
700 &main,
701 Some("architecture"),
702 Some("retry-bound"),
703 Some(code),
704 &["retry.rs".to_owned()],
705 None,
706 );
707 assert!(reply.contains(SOURCES_BOUND), "{reply}");
708 assert!(reply.contains("validity: content"), "{reply}");
709
710 let recall = |root: &str| {
711 crate::tools::ctx_knowledge::handle(
712 root,
713 "recall",
714 None,
715 None,
716 None,
717 None,
718 "session-1",
719 None,
720 None,
721 None,
722 None,
723 None,
724 )
725 };
726 let own = recall(&main);
727 assert!(own.contains(&format!("{convention} (confidence")), "{own}");
728 assert!(own.contains("[convention]") && own.contains(code), "{own}");
729 assert!(own.contains(local), "{own}");
730
731 let linked = recall(&feature);
732 assert!(
733 linked.contains(&format!("{convention} [convention]")),
734 "{linked}"
735 );
736 assert!(!linked.contains(code), "{linked}");
737 assert!(
738 linked.contains("1 code-bound fact(s) withheld: stale"),
739 "{linked}"
740 );
741 assert!(!linked.contains(local), "{linked}");
742
743 std::fs::write(
744 format!("{feature}/retry.rs"),
745 "fn retry() { /* bounded */ }\n",
746 )
747 .unwrap();
748 let same = recall(&feature);
749 assert!(same.contains(&format!("{code} [content]")), "{same}");
750 assert!(!same.contains("withheld"), "{same}");
751 }
752
753 fn declares(action: &str) -> bool {
754 let mut args = Map::new();
755 args.insert("action".to_owned(), Value::String(action.to_owned()));
756 CtxKnowledgeTool.publishes_every_source(Some(&args))
757 }
758
759 fn recall(root: &str) -> (String, usize) {
762 deliver(root, || {
763 crate::tools::ctx_knowledge::handle(
764 root,
765 "recall",
766 Some("architecture"),
767 None,
768 None,
769 None,
770 "session-1",
771 None,
772 None,
773 None,
774 None,
775 None,
776 )
777 })
778 }
779
780 #[test]
786 fn a_declared_recall_carries_the_lineage_of_its_bound_facts() {
787 use lean_ctx::core::archive::delivered::LineageRefusal;
788 let _data = governed();
789 let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
790 let (_directory, root) = project();
791 std::fs::write(format!("{root}/auth.rs"), "pub fn verify_rs256() {}\n").expect("source");
792 let value = "Tokens are verified with RS256 in the auth module";
793 remember(&root, "token-check", value);
794 assert!(bind_remembered(
795 &root,
796 Some("architecture"),
797 Some("token-check"),
798 Some(value),
799 &["auth.rs".to_owned()],
800 ));
801 assert!(declares("recall") && declares("timeline") && declares("search"));
802 assert!(!declares("remember") && !declares("status") && !declares("wakeup"));
803
804 let (reply, recorded) = recall(&root);
805 assert!(reply.contains(value), "{reply}");
806 assert_eq!(recorded, 1);
807 assert_eq!(reusable(&root, &reply), Ok(()));
808
809 let typed = "Deployments are promoted by the release captain on Fridays";
810 remember(&root, "release-duty", typed);
811 let (mixed, recorded) = recall(&root);
812 assert!(mixed.contains(value) && mixed.contains(typed), "{mixed}");
813 assert_eq!(recorded, 0, "one fact without a source: unknown");
814 assert_eq!(reusable(&root, &mixed), Err(LineageRefusal::Unknown));
815
816 std::fs::write(
819 format!("{root}/auth.rs"),
820 "*** TOP SECRET ***\npub fn verify_rs256() {}\n",
821 )
822 .expect("mark");
823 assert_eq!(
824 reusable(&root, &reply),
825 Err(LineageRefusal::SourceNoLongerAdmitted)
826 );
827 let (withheld, recorded) = recall(&root);
828 assert!(!withheld.contains(value), "{withheld}");
829 assert!(withheld.contains(typed), "{withheld}");
830 assert_eq!(recorded, 0);
831
832 std::fs::remove_file(format!("{root}/auth.rs")).expect("delete");
833 assert_eq!(
834 reusable(&root, &reply),
835 Err(LineageRefusal::SourceNoLongerAdmitted)
836 );
837 assert!(!recall(&root).0.contains(value));
838 }
839}
840
841#[cfg(all(test, not(feature = "no-jail")))]
843mod tests {
844 use super::*;
845
846 fn call(ctx: &ToolContext, args: &Value) -> String {
847 CtxKnowledgeTool
848 .handle(args.as_object().unwrap(), ctx)
849 .unwrap()
850 .text
851 }
852
853 #[test]
854 fn okf_export_and_import_paths_stay_in_the_project_jail() {
855 let _data = lean_ctx::core::data_dir::isolated_data_dir();
856 let root = tempfile::tempdir().unwrap();
857 let outside = tempfile::tempdir().unwrap();
858 let ctx = ToolContext {
859 project_root: root.path().to_string_lossy().into_owned(),
860 session: Some(std::sync::Arc::new(tokio::sync::RwLock::new(
861 lean_ctx::core::session::SessionState::new(),
862 ))),
863 ..ToolContext::default()
864 };
865 call(
866 &ctx,
867 &json!({"action": "remember", "category": "architecture",
868 "key": "parser", "value": "the parser is bounded"}),
869 );
870
871 let export_dir = outside.path().join("okf");
872 let text = call(
873 &ctx,
874 &json!({"action": "export", "format": "okf", "path": export_dir}),
875 );
876 assert!(!export_dir.exists(), "OKF export left the jail: {text}");
877
878 let facts = outside.path().join("facts.json");
879 std::fs::write(
880 &facts,
881 r#"[{"category":"secret","key":"outside","value":"OUTSIDE-JAIL"}]"#,
882 )
883 .unwrap();
884 let text = call(&ctx, &json!({"action": "import", "path": facts}));
885 assert!(
886 !text.contains("Import complete"),
887 "import read outside the jail: {text}"
888 );
889
890 let inside = root.path().join("okf-export");
891 let text = call(
892 &ctx,
893 &json!({"action": "export", "format": "okf", "path": inside}),
894 );
895 assert!(inside.join("index.md").exists(), "{text}");
896 let text = call(&ctx, &json!({"action": "import", "path": inside}));
897 assert!(text.contains("import"), "{text}");
898 }
899
900 #[test]
904 fn a_remembered_fact_asks_for_a_push_and_a_recall_does_not() {
905 use lean_ctx_hive::sync::on_write::requested;
906 let _data = lean_ctx::core::data_dir::isolated_data_dir();
907 let dir = tempfile::tempdir().unwrap();
908 let root = dir
909 .path()
910 .canonicalize()
911 .unwrap()
912 .to_string_lossy()
913 .into_owned();
914 let ctx = ToolContext {
915 project_root: root.clone(),
916 session: Some(std::sync::Arc::new(tokio::sync::RwLock::new(
917 lean_ctx::core::session::SessionState::new(),
918 ))),
919 ..ToolContext::default()
920 };
921 assert_eq!(requested(&root), 0);
922 call(
923 &ctx,
924 &json!({"action": "remember", "category": "architecture",
925 "key": "retry", "value": "payments retry three times"}),
926 );
927 assert_eq!(requested(&root), 1, "the write asks for a push");
928 call(&ctx, &json!({"action": "recall", "query": "retry"}));
929 assert_eq!(requested(&root), 1, "a recall writes nothing");
930 }
931}