lean_ctx/tools/registered/
ctx_knowledge.rs1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{
6 McpTool, ToolContext, ToolOutput, get_f64, get_str, get_str_array,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxKnowledgeTool;
11
12impl McpTool for CtxKnowledgeTool {
13 fn name(&self) -> &'static str {
14 "ctx_knowledge"
15 }
16
17 fn tool_def(&self) -> Tool {
18 tool_def(
19 "ctx_knowledge",
20 "Persistent memory across sessions — remember decisions, patterns, and facts for recall.\n\
21 WORKFLOW: save after completing significant tasks; recall at session start.\n\
22 action=remember value='Y' saves a fact (key optional — derived from value; content= is an accepted alias).\n\
23 action=recall query='X' retrieves it (bare recall lists recent facts). action=status shows all categories.\n\
24action=consolidate imports latest session if present, runs lifecycle, then frees 25% facts/history/procedures capacity.\n\
25 action=gotcha trigger='X' resolution='Y' for known pitfalls.\n\
26 mode=semantic|exact for recall. category groups related facts.",
27 json!({
28 "type": "object",
29 "properties": {
30 "action": {
31 "type": "string",
32 "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_*)"
33 },
34 "trigger": { "type": "string", "description": "gotcha trigger pattern" },
35 "resolution": { "type": "string", "description": "gotcha resolution/fix" },
36 "severity": { "type": "string", "description": "gotcha: critical|warning|info" },
37 "category": { "type": "string", "description": "Fact category" },
38 "key": { "type": "string" },
39 "value": { "type": "string" },
40 "query": { "type": "string", "description": "Query for recall/search/relate/restore" },
41 "mode": { "type": "string", "description": "auto|exact|semantic|hybrid" },
42 "as_of": { "type": "string", "description": "YYYY-MM-DD date filter" },
43 "pattern_type": { "type": "string" },
44 "examples": { "type": "array", "items": { "type": "string" } },
45 "confidence": { "type": "number", "description": "0.0-1.0" },
46 "store": { "type": "string", "description": "restore: facts|history|procedures|patterns (default: all)" },
47 "limit": { "type": "number", "description": "restore: max items to recover (default 50)" },
48 "dry_run": { "type": "boolean", "description": "consolidate: preview imports/reclaim without writing" },
49 "format": { "type": "string", "description": "export: json|okf (okf = portable Markdown bundle)" },
50 "path": { "type": "string", "description": "export/import: bundle directory (OKF) or file path" },
51 "merge": { "type": "string", "description": "import: replace|append|skip-existing (default skip-existing)" }
52 },
53 "required": ["action"]
54 }),
55 )
56 }
57
58 fn handle(
59 &self,
60 args: &Map<String, Value>,
61 ctx: &ToolContext,
62 ) -> Result<ToolOutput, ErrorData> {
63 let action = get_str(args, "action")
64 .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
65 let category = get_str(args, "category");
66 let key = get_str(args, "key");
67 let value = get_str(args, "value").or_else(|| get_str(args, "content"));
70 let query = get_str(args, "query");
71 let mode = get_str(args, "mode");
72 let as_of = get_str(args, "as_of");
73 let pattern_type = get_str(args, "pattern_type");
74 let examples = get_str_array(args, "examples");
75 let confidence = get_f64(args, "confidence").map(|v| v as f32);
76
77 let session_handle = ctx
78 .session
79 .as_ref()
80 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
81 let (session_id, project_root) = {
82 let timeout_dur =
83 crate::core::io_health::adaptive_timeout(std::time::Duration::from_secs(10));
84 let read_result = tokio::task::block_in_place(|| {
85 tokio::runtime::Handle::current()
86 .block_on(tokio::time::timeout(timeout_dur, session_handle.read()))
87 });
88 if let Ok(session) = read_result {
89 let sid = session.id.clone();
90 let root = session
91 .project_root
92 .clone()
93 .unwrap_or_else(|| ctx.project_root.clone());
94 (sid, root)
95 } else {
96 tracing::warn!("ctx_knowledge: session read-lock timeout, using fallback");
97 ("unknown".to_string(), ctx.project_root.clone())
98 }
99 };
100
101 if action == "gotcha" {
102 let trigger = get_str(args, "trigger").unwrap_or_default();
103 let resolution = get_str(args, "resolution").unwrap_or_default();
104 let severity = get_str(args, "severity").unwrap_or_default();
105 let cat = category.as_deref().unwrap_or("convention");
106
107 if trigger.is_empty() || resolution.is_empty() {
108 return Ok(text_output(
109 &action,
110 "ERROR: trigger and resolution are required for gotcha action".to_string(),
111 ));
112 }
113
114 let mut store = crate::core::gotcha_tracker::GotchaStore::load(&project_root);
115 let msg = match store.report_gotcha(&trigger, &resolution, cat, &severity, &session_id)
116 {
117 Some(gotcha) => {
118 let conf = (gotcha.confidence * 100.0) as u32;
119 let label = gotcha.category.short_label();
120 format!("Gotcha recorded: [{label}] {trigger} (confidence: {conf}%)")
121 }
122 None => {
123 format!("Gotcha noted: {trigger} (evicted by higher-confidence entries)")
124 }
125 };
126 let _ = store.save(&project_root);
127 return Ok(text_output(&action, msg));
128 }
129
130 if action == "restore" {
134 let store = match get_str(args, "store").as_deref() {
135 Some(s) => match crate::core::memory_archive::MemoryStore::parse(s) {
136 Some(ms) => Some(ms),
137 None => {
138 return Ok(text_output(
139 &action,
140 format!(
141 "Unknown store: {s}. Use: facts, history, procedures, patterns"
142 ),
143 ));
144 }
145 },
146 None => None,
147 };
148 let limit = get_f64(args, "limit")
149 .map_or(crate::tools::ctx_knowledge::DEFAULT_RESTORE_LIMIT, |v| {
150 v as usize
151 });
152 let opts =
153 crate::tools::ctx_knowledge::RestoreOptions::new(store, query.clone(), limit);
154 let text = match crate::tools::ctx_knowledge::run_restore(&project_root, &opts) {
155 Ok(report) => crate::tools::ctx_knowledge::format_restore_report(&report),
156 Err(e) => e,
157 };
158 return Ok(text_output(&action, text));
159 }
160
161 let dry_run = args
163 .get("dry_run")
164 .and_then(Value::as_bool)
165 .unwrap_or(false);
166 if action == "consolidate" && dry_run {
167 let text = match crate::tools::ctx_knowledge::consolidate_project_knowledge_with(
168 &project_root,
169 &crate::core::consolidation_engine::ConsolidateOptions::manual().into_dry_run(),
170 ) {
171 Ok(report) => crate::tools::ctx_knowledge::format_consolidation_report(&report),
172 Err(e) => e,
173 };
174 return Ok(text_output(&action, text));
175 }
176
177 if action == "export" && get_str(args, "format").as_deref() == Some("okf") {
180 let out = get_str(args, "path").or_else(|| get_str(args, "output"));
181 return Ok(text_output(
182 &action,
183 crate::tools::ctx_knowledge::handle_export_okf(&project_root, out.as_deref()),
184 ));
185 }
186 if action == "import" {
187 let Some(path) = get_str(args, "path").or_else(|| query.clone()) else {
188 return Ok(text_output(
189 &action,
190 "ERROR: import requires `path` (a file or an OKF directory)".to_string(),
191 ));
192 };
193 let merge = get_str(args, "merge")
194 .and_then(|s| crate::core::knowledge::ImportMerge::parse(&s))
195 .unwrap_or(crate::core::knowledge::ImportMerge::SkipExisting);
196 return Ok(text_output(
197 &action,
198 crate::tools::ctx_knowledge::handle_import(
199 &project_root,
200 &path,
201 merge,
202 &session_id,
203 ),
204 ));
205 }
206
207 let result = crate::tools::ctx_knowledge::handle(
208 &project_root,
209 &action,
210 category.as_deref(),
211 key.as_deref(),
212 value.as_deref(),
213 query.as_deref(),
214 &session_id,
215 pattern_type.as_deref(),
216 examples,
217 confidence,
218 mode.as_deref(),
219 as_of.as_deref(),
220 );
221
222 Ok(text_output(&action, result))
223 }
224}
225
226fn text_output(action: &str, text: String) -> ToolOutput {
229 ToolOutput {
230 text,
231 original_tokens: 0,
232 saved_tokens: 0,
233 mode: Some(action.to_string()),
234 path: None,
235 changed: false,
236 shell_outcome: None,
237 }
238}