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