1use crate::protocol::ToolCallResult;
4use serde_json::json;
5use somatize_memory::{ExperimentRecord, FileKnowledgeBase, KnowledgeBase, MemoryKnowledgeBase};
6use std::path::{Path, PathBuf};
7
8pub struct SomaContext {
10 pub project_dir: PathBuf,
12 pub kb: Box<dyn KnowledgeBase>,
14 kb_path: Option<PathBuf>,
16}
17
18impl SomaContext {
19 pub fn new(project_dir: impl Into<PathBuf>) -> Self {
25 Self::with_env_override(project_dir, std::env::var("SOMA_KB_PATH").ok())
26 }
27
28 pub fn with_env_override(
33 project_dir: impl Into<PathBuf>,
34 env_override: Option<String>,
35 ) -> Self {
36 let project_dir = project_dir.into();
37 let resolved = Self::kb_path(&project_dir, env_override);
38 let mut kb_path = None;
39 let kb: Box<dyn KnowledgeBase> = match &resolved {
40 Some(path) => match FileKnowledgeBase::open(path) {
41 Ok(kb) => {
42 kb_path = resolved.clone();
43 Box::new(kb)
44 }
45 Err(e) => {
46 eprintln!(
47 "soma-mcp: failed to open knowledge base at {} ({e}); using in-memory",
48 path.display()
49 );
50 Box::new(MemoryKnowledgeBase::new())
51 }
52 },
53 None => Box::new(MemoryKnowledgeBase::new()),
54 };
55 Self {
56 project_dir,
57 kb,
58 kb_path,
59 }
60 }
61
62 pub fn refresh_kb(&mut self) {
66 if let Err(e) = self.kb.refresh() {
67 eprintln!("soma-mcp: could not refresh the knowledge base: {e}");
68 }
69 }
70
71 pub fn kb_location(&self) -> Option<String> {
73 self.kb_path.as_ref().map(|p| p.display().to_string())
74 }
75
76 pub fn tracking_root(&self) -> PathBuf {
78 self.project_dir.join(".soma")
79 }
80
81 fn kb_path(project_dir: &Path, env_override: Option<String>) -> Option<PathBuf> {
85 if let Some(path) = env_override.filter(|p| !p.is_empty()) {
86 return Some(PathBuf::from(path));
87 }
88 let soma_dir = project_dir.join(".soma");
89 soma_dir
90 .is_dir()
91 .then(|| soma_dir.join("experiments.jsonl"))
92 }
93
94 pub fn list_filters(&self, params: &serde_json::Value) -> ToolCallResult {
107 let dir = params
108 .get("path")
109 .and_then(|v| v.as_str())
110 .map(PathBuf::from)
111 .unwrap_or_else(|| self.project_dir.clone());
112
113 match find_filter_files(&dir) {
114 Ok(files) => {
115 let result: Vec<serde_json::Value> = files
116 .iter()
117 .map(|f| json!({ "path": f.to_string_lossy() }))
118 .collect();
119 ToolCallResult::text(serde_json::to_string_pretty(&result).unwrap_or_default())
120 }
121 Err(e) => ToolCallResult::error(format!("Failed to list filters: {e}")),
122 }
123 }
124
125 pub fn read_filter_source(&self, params: &serde_json::Value) -> ToolCallResult {
128 let Some(path) = params.get("file_path").and_then(|v| v.as_str()) else {
129 return ToolCallResult::error("Missing required parameter: file_path");
130 };
131
132 let full_path = self.resolve_path(path);
133 match std::fs::read_to_string(&full_path) {
134 Ok(content) => ToolCallResult::text(content),
135 Err(e) => ToolCallResult::error(format!("Failed to read {}: {e}", full_path.display())),
136 }
137 }
138
139 pub fn write_filter_source(&self, params: &serde_json::Value) -> ToolCallResult {
144 let Some(path) = params.get("file_path").and_then(|v| v.as_str()) else {
145 return ToolCallResult::error("Missing required parameter: file_path");
146 };
147 let Some(content) = params.get("content").and_then(|v| v.as_str()) else {
148 return ToolCallResult::error("Missing required parameter: content");
149 };
150
151 let full_path = self.resolve_path(path);
152
153 if full_path.exists() {
155 let backup = full_path.with_extension("bak");
156 if let Err(e) = std::fs::copy(&full_path, &backup) {
157 return ToolCallResult::error(format!("Failed to create backup: {e}"));
158 }
159 }
160
161 if let Some(parent) = full_path.parent() {
163 let _ = std::fs::create_dir_all(parent);
164 }
165
166 match std::fs::write(&full_path, content) {
167 Ok(()) => ToolCallResult::text(format!(
168 "Written {} bytes to {}",
169 content.len(),
170 full_path.display()
171 )),
172 Err(e) => ToolCallResult::error(format!("Failed to write: {e}")),
173 }
174 }
175
176 pub fn run_pipeline(&self, params: &serde_json::Value) -> ToolCallResult {
190 let Some(nodes) = params.get("nodes").and_then(|v| v.as_array()) else {
191 return ToolCallResult::error(
192 "Missing required parameter: nodes (a list of \
193 {id, filter, config})",
194 );
195 };
196 if nodes.is_empty() {
197 return ToolCallResult::error("`nodes` is empty: there is no graph to run");
198 }
199 let mut spec = params.clone();
200 spec["kind"] = json!("pipeline");
201 match crate::exec::GraphRunner::new(&self.project_dir).run(&spec) {
202 Ok(payload) => ToolCallResult::text(crate::render::render_pipeline_run(&payload)),
203 Err(e) => ToolCallResult::error(e),
204 }
205 }
206
207 pub fn run_study(&self, params: &serde_json::Value) -> ToolCallResult {
214 let Some(nodes) = params.get("nodes").and_then(|v| v.as_array()) else {
215 return ToolCallResult::error(
216 "Missing required parameter: nodes (a list of \
217 {id, filter, config}); mark the values to search with \
218 {\"__search__\": {\"low\": …, \"high\": …}}",
219 );
220 };
221 if nodes.is_empty() {
222 return ToolCallResult::error("`nodes` is empty: there is no graph to search");
223 }
224 let mut spec = params.clone();
225 spec["kind"] = json!("study");
226 match crate::exec::GraphRunner::new(&self.project_dir).run(&spec) {
227 Ok(payload) => ToolCallResult::text(crate::render::render_study_run(&payload)),
228 Err(e) => ToolCallResult::error(e),
229 }
230 }
231
232 pub fn record_experiment(&mut self, params: &serde_json::Value) -> ToolCallResult {
242 let Some(id) = params.get("id").and_then(|v| v.as_str()) else {
243 return ToolCallResult::error("Missing required parameter: id");
244 };
245 let Some(name) = params.get("name").and_then(|v| v.as_str()) else {
246 return ToolCallResult::error("Missing required parameter: name");
247 };
248
249 let mut record = ExperimentRecord::new(id, name);
250
251 if let Some(h) = params.get("hypothesis").and_then(|v| v.as_str()) {
252 record = record.with_hypothesis(h);
253 }
254 if let Some(l) = params.get("research_line").and_then(|v| v.as_str()) {
255 record = record.with_research_line(l);
256 }
257 if let Some(p) = params.get("pipeline_summary").and_then(|v| v.as_str()) {
258 record = record.with_pipeline(p);
259 }
260 if let Some(parent) = params.get("parent").and_then(|v| v.as_str()) {
261 record = record.with_parent(parent);
262 }
263 if let Some(notes) = params.get("notes").and_then(|v| v.as_str()) {
264 record = record.with_notes(notes);
265 }
266 if let Some(tags) = params.get("tags").and_then(|v| v.as_array()) {
267 let tags: Vec<String> = tags
268 .iter()
269 .filter_map(|t| t.as_str().map(String::from))
270 .collect();
271 record = record.with_tags(tags);
272 }
273 if let Some(metrics) = params.get("metrics").and_then(|v| v.as_object()) {
274 let m: std::collections::BTreeMap<String, f64> = metrics
275 .iter()
276 .filter_map(|(k, v)| v.as_f64().map(|val| (k.clone(), val)))
277 .collect();
278 record = record.with_metrics(m);
279 }
280 if let Some(p) = params.get("params").and_then(|v| v.as_object()) {
281 let params_map: std::collections::BTreeMap<String, serde_json::Value> =
282 p.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
283 record = record.with_params(params_map);
284 }
285
286 match self.kb.record(record) {
287 Ok(()) => ToolCallResult::text(format!("Experiment '{id}' recorded successfully")),
288 Err(e) => ToolCallResult::error(format!("Failed to record experiment: {e}")),
289 }
290 }
291
292 pub fn query_knowledge_base(&self, params: &serde_json::Value) -> ToolCallResult {
297 let Some(query) = params.get("query").and_then(|v| v.as_str()) else {
298 return ToolCallResult::error("Missing required parameter: query");
299 };
300 let max = params
301 .get("max_results")
302 .and_then(|v| v.as_u64())
303 .unwrap_or(10) as usize;
304
305 match self.kb.search(query, max) {
306 Ok(results) => {
307 let items: Vec<serde_json::Value> = results
308 .iter()
309 .map(|e| {
310 json!({
311 "id": e.id,
312 "name": e.name,
313 "hypothesis": e.hypothesis,
314 "research_line": e.research_line,
315 "metrics": e.metrics,
316 "tags": e.tags,
317 })
318 })
319 .collect();
320 ToolCallResult::text(serde_json::to_string_pretty(&items).unwrap_or_default())
321 }
322 Err(e) => ToolCallResult::error(format!("Query failed: {e}")),
323 }
324 }
325
326 pub fn get_trajectory(&self, params: &serde_json::Value) -> ToolCallResult {
330 let Some(line) = params.get("research_line").and_then(|v| v.as_str()) else {
331 return ToolCallResult::error("Missing: research_line");
332 };
333 let Some(metric) = params.get("metric").and_then(|v| v.as_str()) else {
334 return ToolCallResult::error("Missing: metric");
335 };
336
337 match self.kb.trajectory(line, metric) {
338 Ok(traj) => {
339 let items: Vec<serde_json::Value> = traj
340 .iter()
341 .map(|(id, val)| json!({"experiment_id": id, "value": val}))
342 .collect();
343 ToolCallResult::text(serde_json::to_string_pretty(&items).unwrap_or_default())
344 }
345 Err(e) => ToolCallResult::error(format!("Failed: {e}")),
346 }
347 }
348
349 pub fn get_change_points(&self, params: &serde_json::Value) -> ToolCallResult {
354 let Some(line) = params.get("research_line").and_then(|v| v.as_str()) else {
355 return ToolCallResult::error("Missing: research_line");
356 };
357 let Some(metric) = params.get("metric").and_then(|v| v.as_str()) else {
358 return ToolCallResult::error("Missing: metric");
359 };
360 let threshold = params
361 .get("threshold")
362 .and_then(|v| v.as_f64())
363 .unwrap_or(0.05);
364
365 match self.kb.change_points(line, metric, threshold) {
366 Ok(points) => {
367 let items: Vec<serde_json::Value> = points
368 .iter()
369 .map(|cp| {
370 json!({
371 "experiment_id": cp.experiment_id,
372 "metric": cp.metric_name,
373 "before": cp.value_before,
374 "after": cp.value_after,
375 "description": cp.description,
376 })
377 })
378 .collect();
379 ToolCallResult::text(serde_json::to_string_pretty(&items).unwrap_or_default())
380 }
381 Err(e) => ToolCallResult::error(format!("Failed: {e}")),
382 }
383 }
384
385 pub fn list_research_lines(&self, _params: &serde_json::Value) -> ToolCallResult {
389 match self.kb.research_lines() {
390 Ok(lines) => {
391 let items: Vec<serde_json::Value> = lines
392 .iter()
393 .map(|l| {
394 json!({
395 "name": l.name,
396 "trend": l.trend.to_string(),
397 "experiments": l.experiments.len(),
398 "best_metric": l.best_metric_value,
399 "best_metric_name": l.best_metric_name,
400 })
401 })
402 .collect();
403 ToolCallResult::text(serde_json::to_string_pretty(&items).unwrap_or_default())
404 }
405 Err(e) => ToolCallResult::error(format!("Failed: {e}")),
406 }
407 }
408
409 pub fn promising_lines(&self, params: &serde_json::Value) -> ToolCallResult {
413 let Some(metric) = params.get("metric").and_then(|v| v.as_str()) else {
414 return ToolCallResult::error("Missing: metric");
415 };
416
417 match self.kb.promising_lines(metric) {
418 Ok(lines) => {
419 let items: Vec<serde_json::Value> = lines
420 .iter()
421 .map(|l| json!({"name": l.name, "trend": l.trend.to_string(), "best": l.best_metric_value}))
422 .collect();
423 ToolCallResult::text(serde_json::to_string_pretty(&items).unwrap_or_default())
424 }
425 Err(e) => ToolCallResult::error(format!("Failed: {e}")),
426 }
427 }
428
429 pub fn create_research_line(&mut self, params: &serde_json::Value) -> ToolCallResult {
439 let Some(name) = params.get("name").and_then(|v| v.as_str()) else {
440 return ToolCallResult::error("Missing: name");
441 };
442 let description = params
443 .get("description")
444 .and_then(|v| v.as_str())
445 .unwrap_or("");
446
447 let record =
449 ExperimentRecord::new(format!("{name}_init"), format!("Research line: {name}"))
450 .with_research_line(name)
451 .with_notes(format!("Research line created. {description}"));
452
453 match self.kb.record(record) {
454 Ok(()) => ToolCallResult::text(format!("Research line '{name}' created")),
455 Err(e) => ToolCallResult::error(format!("Failed: {e}")),
456 }
457 }
458
459 pub fn generate_report(&self, params: &serde_json::Value) -> ToolCallResult {
465 let Some(line) = params.get("research_line").and_then(|v| v.as_str()) else {
466 return ToolCallResult::error("Missing: research_line");
467 };
468
469 let experiments = self.kb.experiments_in_line(line).unwrap_or_default();
470 if experiments.is_empty() {
471 return ToolCallResult::text(format!("# {line}\n\nNo experiments found."));
472 }
473
474 let mut report = format!("# Research Report: {line}\n\n");
475 report.push_str(&format!("**Experiments**: {}\n\n", experiments.len()));
476
477 report.push_str("## Experiments\n\n");
479 report.push_str("| ID | Hypothesis | Metrics | Notes |\n");
480 report.push_str("|---|---|---|---|\n");
481 for exp in &experiments {
482 let metrics_str: String = exp
483 .metrics
484 .iter()
485 .map(|(k, v)| format!("{k}={v:.4}"))
486 .collect::<Vec<_>>()
487 .join(", ");
488 report.push_str(&format!(
489 "| {} | {} | {} | {} |\n",
490 exp.id,
491 exp.hypothesis.as_deref().unwrap_or("-"),
492 metrics_str,
493 exp.notes.as_deref().unwrap_or("-"),
494 ));
495 }
496
497 if let Some(first_metric) = experiments[0].metrics.keys().next() {
499 let traj = self.kb.trajectory(line, first_metric).unwrap_or_default();
500 if !traj.is_empty() {
501 report.push_str(&format!("\n## Trajectory ({first_metric})\n\n"));
502 for (id, val) in &traj {
503 report.push_str(&format!("- {id}: {val:.4}\n"));
504 }
505 }
506
507 let cps = self
509 .kb
510 .change_points(line, first_metric, 0.05)
511 .unwrap_or_default();
512 if !cps.is_empty() {
513 report.push_str("\n## Significant Changes\n\n");
514 for cp in &cps {
515 report.push_str(&format!("- **{}**: {}\n", cp.experiment_id, cp.description));
516 }
517 }
518 }
519
520 if let Ok(lines) = self.kb.research_lines()
522 && let Some(this_line) = lines.iter().find(|l| l.name == line)
523 {
524 report.push_str(&format!(
525 "\n## Status\n\n- **Trend**: {}\n- **Best metric**: {:?}\n",
526 this_line.trend, this_line.best_metric_value
527 ));
528 }
529
530 ToolCallResult::text(report)
531 }
532
533 fn resolve_path(&self, path: &str) -> PathBuf {
538 let p = PathBuf::from(path);
539 if p.is_absolute() {
540 p
541 } else {
542 self.project_dir.join(p)
543 }
544 }
545}
546
547pub(crate) fn find_filter_files(dir: &Path) -> Result<Vec<PathBuf>, std::io::Error> {
549 let mut files = Vec::new();
550 if !dir.exists() {
551 return Ok(files);
552 }
553 for entry in std::fs::read_dir(dir)? {
554 let entry = entry?;
555 let path = entry.path();
556 if path.is_file()
557 && let Some(ext) = path.extension()
558 && (ext == "py" || ext == "rs")
559 {
560 files.push(path);
561 } else if path.is_dir()
562 && !path
563 .file_name()
564 .is_some_and(|n| n.to_string_lossy().starts_with('.'))
565 {
566 files.extend(find_filter_files(&path)?);
567 }
568 }
569 files.sort();
570 Ok(files)
571}