1use crate::bridge::MemoryBridge;
8use crate::tools::*;
9use rmcp::{
10 handler::server::{router::tool::ToolRouter, wrapper::Parameters},
11 tool, tool_handler, tool_router, ErrorData, Json, ServerHandler,
12};
13use schemars::JsonSchema;
14use std::collections::{HashMap, HashSet, VecDeque};
15use std::sync::atomic::{AtomicU64, Ordering};
16use std::sync::{Arc, Mutex};
17use tokio::runtime::Handle;
18
19static WITNESS_REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(1);
20const ROUTING_POLICY_PERSIST_BATCH: usize = 10;
21
22#[derive(Default)]
23struct RoutingPolicyBatchState {
24 policy: Option<semantic_memory::rl_routing::RoutingPolicy>,
25 pending_outcomes: usize,
26}
27
28use crate::tools::{
30 AddGraphEdgeParams, BenchmarkTrustParams, CommunityParams, FactorGraphParams,
31 InvalidateGraphEdgeParams, ListGraphEdgesParams, RecordOutcomeParams, SearchProofDebtParams,
32 SubgraphPruneParams, TopologyParams,
33};
34
35#[cfg(feature = "claim-integration")]
41#[derive(Default)]
42struct ClaimTrustIndex {
43 enabled: bool,
44 fact_to_claim: HashMap<String, String>,
45 claim_support: HashMap<String, claim_ledger::SupportState>,
46 content_to_claim: HashMap<String, String>,
50 trigram_index: HashMap<String, Vec<String>>,
53 last_processed_sequence: u64,
57}
58
59#[cfg(feature = "claim-integration")]
60impl ClaimTrustIndex {
61 fn load_snapshot(&mut self, snapshot: &claim_ledger::LedgerSnapshot) {
62 self.fact_to_claim.clear();
63 self.claim_support.clear();
64 self.content_to_claim.clear();
65 self.trigram_index.clear();
66 for link in &snapshot.content_to_claim_links {
67 self.register_claim(link.claim_id.clone(), link.normalized_claim.clone());
68 }
69 for link in &snapshot.fact_to_claim_links {
70 self.link_fact(link.fact_id.clone(), link.claim_id.clone());
71 }
72 for support in &snapshot.claim_support {
73 self.record_judgment(support.claim_id.clone(), support.support_state);
74 }
75 self.last_processed_sequence = snapshot.last_compacted_sequence;
76 self.enabled = true;
77 }
78
79 fn disable(&mut self) {
80 *self = Self::default();
81 }
82
83 fn apply_entry(&mut self, entry: &claim_ledger::LedgerEntry) {
88 use claim_ledger::{LedgerEvent, SupportState};
89 match &entry.event {
90 LedgerEvent::ClaimAdded {
91 claim_id,
92 source_id,
93 normalized_claim,
94 ..
95 } => {
96 if let Some(fact_id) = source_id.strip_prefix("semantic-memory:fact:") {
97 self.link_fact(fact_id.to_string(), claim_id.clone());
98 }
99 self.register_claim(claim_id.clone(), normalized_claim.clone());
100 }
101 LedgerEvent::SupportJudgment {
102 claim_id,
103 support_state,
104 ..
105 } => {
106 self.record_judgment(claim_id.clone(), *support_state);
107 }
108 LedgerEvent::ContradictionCandidate { claim_refs, .. } => {
109 for claim_ref in claim_refs {
110 self.record_judgment(claim_ref.clone(), SupportState::Contradicted);
111 }
112 }
113 _ => {}
114 }
115 self.last_processed_sequence = entry.sequence;
116 }
117
118 fn rebuild_from_ledger_incremental(&mut self, entries: &[claim_ledger::LedgerEntry]) {
123 for entry in entries {
124 if entry.sequence > self.last_processed_sequence {
125 self.apply_entry(entry);
126 }
127 }
128 }
129
130 fn link_fact(&mut self, bare_fact_id: String, claim_id: String) {
131 self.fact_to_claim.insert(bare_fact_id, claim_id);
132 }
133
134 fn register_claim(&mut self, claim_id: String, normalized_content: String) {
138 if normalized_content.is_empty() {
139 return;
140 }
141 for trigram in Self::trigrams(&normalized_content) {
142 self.trigram_index
143 .entry(trigram)
144 .or_default()
145 .push(claim_id.clone());
146 }
147 self.content_to_claim.insert(normalized_content, claim_id);
148 }
149
150 fn trigrams(text: &str) -> HashSet<String> {
153 let lower = text.to_lowercase();
154 let chars: Vec<char> = lower.chars().collect();
155 if chars.len() < 3 {
156 let mut set = HashSet::new();
157 if !lower.is_empty() {
158 set.insert(lower);
159 }
160 return set;
161 }
162 chars
163 .windows(3)
164 .map(|w| w.iter().collect::<String>())
165 .collect()
166 }
167
168 fn jaccard(a: &HashSet<String>, b: &HashSet<String>) -> f64 {
169 if a.is_empty() && b.is_empty() {
170 return 0.0;
171 }
172 let intersection = a.intersection(b).count();
173 let union = a.union(b).count();
174 if union == 0 {
175 0.0
176 } else {
177 intersection as f64 / union as f64
178 }
179 }
180
181 fn auto_link_content(&mut self, bare_fact_id: &str, content: &str) -> Option<String> {
186 if !self.enabled {
187 return None;
188 }
189 if self.fact_to_claim.contains_key(bare_fact_id) {
190 return None;
191 }
192 let normalized = claim_ledger::ids::normalize_text(content);
193 if normalized.is_empty() {
194 return None;
195 }
196
197 const SIMILARITY_THRESHOLD: f64 = 0.7;
198 let query_trigrams = Self::trigrams(&normalized);
199 let mut candidate_ids: HashSet<&String> = HashSet::new();
200 for trigram in &query_trigrams {
201 if let Some(ids) = self.trigram_index.get(trigram) {
202 candidate_ids.extend(ids.iter());
203 }
204 }
205
206 let mut best: Option<(String, f64)> = None;
207 for claim_id in &candidate_ids {
208 if let Some((existing_content, existing_claim_id)) = self
209 .content_to_claim
210 .iter()
211 .find(|(_, cid)| *cid == *claim_id)
212 {
213 let similarity = Self::jaccard(&query_trigrams, &Self::trigrams(existing_content));
214 if best.as_ref().map(|(_, s)| similarity > *s).unwrap_or(true) {
215 best = Some((existing_claim_id.clone(), similarity));
216 }
217 }
218 }
219
220 let claim_id = match best {
221 Some((claim_id, similarity)) if similarity >= SIMILARITY_THRESHOLD => claim_id,
222 _ => self.content_to_claim.get(&normalized)?.clone(),
223 };
224 self.link_fact(bare_fact_id.to_string(), claim_id.clone());
225 Some(claim_id)
226 }
227
228 fn record_judgment(&mut self, claim_id: String, state: claim_ledger::SupportState) {
229 self.claim_support.insert(claim_id, state);
230 }
231
232 fn trust_for_fact(&self, bare_fact_id: &str) -> String {
233 if !self.enabled {
234 return "trust_enrichment_disabled".to_string();
235 }
236 let Some(claim_id) = self.fact_to_claim.get(bare_fact_id) else {
237 return "persisted_unjudged".to_string();
238 };
239 let Some(state) = self.claim_support.get(claim_id) else {
240 return "persisted_unjudged".to_string();
241 };
242 support_state_label(*state).to_string()
243 }
244
245 fn claim_and_state_for_fact(
250 &self,
251 bare_fact_id: &str,
252 ) -> Option<(String, Option<claim_ledger::SupportState>)> {
253 if !self.enabled {
254 return None;
255 }
256 let claim_id = self.fact_to_claim.get(bare_fact_id)?;
257 Some((claim_id.clone(), self.claim_support.get(claim_id).copied()))
258 }
259}
260
261#[cfg(feature = "claim-integration")]
267fn proof_debts_for_support_state(
268 state: claim_ledger::SupportState,
269) -> Vec<claim_ledger::ProofDebt> {
270 use claim_ledger::{ProofDebt, SupportState};
271 match state {
272 SupportState::Supported | SupportState::PartiallySupported => Vec::new(),
273 SupportState::Unknown | SupportState::HeuristicOnly | SupportState::Unsupported => {
274 vec![ProofDebt::MissingSourceBasis]
275 }
276 SupportState::Contradicted => vec![ProofDebt::MissingSourceBasis, ProofDebt::MissingRepro],
277 }
278}
279
280#[cfg(feature = "claim-integration")]
281#[derive(serde::Serialize, serde::Deserialize)]
282struct ClaimLedgerManifest {
283 format_version: String,
284 generation: String,
285 snapshot_path: String,
286 tail_path: String,
287 receipt_path: String,
288}
289
290#[cfg(feature = "claim-integration")]
291struct ClaimLedgerCompactionConfig {
292 dry_run: bool,
293 max_entries: usize,
294 max_bytes: u64,
295 retain_tail_entries: usize,
296 max_backups: usize,
297}
298
299#[cfg(feature = "claim-integration")]
304struct ClaimLedgerStore {
305 entries: Vec<claim_ledger::LedgerEntry>,
306 path: std::path::PathBuf,
307 legacy_path: std::path::PathBuf,
308 snapshot: Option<claim_ledger::LedgerSnapshot>,
309 receipt: Option<claim_ledger::CompactionReceipt>,
310 trust_enabled: bool,
311}
312
313#[cfg(feature = "claim-integration")]
314impl ClaimLedgerStore {
315 fn open(path: std::path::PathBuf) -> Self {
316 match Self::open_verified(&path) {
317 Ok(store) => store,
318 Err(error) => {
319 tracing::error!(error = %error, "CRITICAL: claim ledger verification failed; trust enrichment disabled");
320 Self {
321 entries: Vec::new(),
322 path: path.clone(),
323 legacy_path: path,
324 snapshot: None,
325 receipt: None,
326 trust_enabled: false,
327 }
328 }
329 }
330 }
331
332 fn open_verified(legacy_path: &std::path::Path) -> Result<Self, String> {
333 let memory_dir = legacy_path
334 .parent()
335 .ok_or("claim ledger has no parent directory")?;
336 let manifest_path = memory_dir.join("claim_ledger.active_compaction.json");
337 if manifest_path.exists() {
338 let manifest: ClaimLedgerManifest = serde_json::from_slice(
339 &std::fs::read(&manifest_path)
340 .map_err(|e| format!("failed to read compaction manifest: {e}"))?,
341 )
342 .map_err(|e| format!("invalid compaction manifest: {e}"))?;
343 if manifest.format_version != "claim-ledger.active-compaction.v1" {
344 return Err(format!(
345 "unsupported compaction manifest format {}",
346 manifest.format_version
347 ));
348 }
349 let snapshot_path = Self::resolve_relative(memory_dir, &manifest.snapshot_path)?;
350 let tail_path = Self::resolve_relative(memory_dir, &manifest.tail_path)?;
351 let receipt_path = Self::resolve_relative(memory_dir, &manifest.receipt_path)?;
352 let snapshot: claim_ledger::LedgerSnapshot = serde_json::from_slice(
353 &std::fs::read(&snapshot_path)
354 .map_err(|e| format!("failed to read ledger snapshot: {e}"))?,
355 )
356 .map_err(|e| format!("invalid ledger snapshot: {e}"))?;
357 let receipt: claim_ledger::CompactionReceipt = serde_json::from_slice(
358 &std::fs::read(&receipt_path)
359 .map_err(|e| format!("failed to read compaction receipt: {e}"))?,
360 )
361 .map_err(|e| format!("invalid compaction receipt: {e}"))?;
362 let tail_contents = std::fs::read_to_string(&tail_path)
363 .map_err(|e| format!("failed to read retained ledger tail: {e}"))?;
364 let entries = claim_ledger::parse_ledger_entries(&tail_contents)
365 .map_err(|e| format!("invalid retained ledger tail: {e}"))?;
366 let verification = claim_ledger::verify_compaction(&snapshot, &entries, &receipt)
367 .map_err(|e| e.to_string())?;
368 tracing::info!(
369 generation = %manifest.generation,
370 entries = verification.last_sequence,
371 "claim ledger snapshot and retained tail verified"
372 );
373 return Ok(Self {
374 entries,
375 path: tail_path,
376 legacy_path: legacy_path.to_path_buf(),
377 snapshot: Some(snapshot),
378 receipt: Some(receipt),
379 trust_enabled: true,
380 });
381 }
382
383 let contents = match std::fs::read_to_string(legacy_path) {
384 Ok(contents) => contents,
385 Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
386 Err(error) => return Err(format!("failed to read claim ledger: {error}")),
387 };
388 let entries = claim_ledger::parse_ledger_entries(&contents)
389 .map_err(|e| format!("invalid claim ledger: {e}"))?;
390 let expected_head = match entries.last() {
391 None => claim_ledger::ExpectedLedgerHead::Empty,
392 Some(last) => claim_ledger::ExpectedLedgerHead::Entry {
393 sequence: last.sequence,
394 entry_digest: last.entry_digest.clone(),
395 },
396 };
397 let verification =
398 claim_ledger::verify_ledger(&entries, &expected_head).map_err(|e| e.to_string())?;
399 tracing::info!(
400 entries = verification.last_sequence,
401 "claim ledger verified"
402 );
403 Ok(Self {
404 entries,
405 path: legacy_path.to_path_buf(),
406 legacy_path: legacy_path.to_path_buf(),
407 snapshot: None,
408 receipt: None,
409 trust_enabled: true,
410 })
411 }
412
413 fn resolve_relative(
414 base: &std::path::Path,
415 relative: &str,
416 ) -> Result<std::path::PathBuf, String> {
417 let path = std::path::Path::new(relative);
418 if path.is_absolute()
419 || path.components().any(|component| {
420 !matches!(
421 component,
422 std::path::Component::Normal(_) | std::path::Component::CurDir
423 )
424 })
425 {
426 return Err(format!("unsafe path in compaction manifest: {relative}"));
427 }
428 Ok(base.join(path))
429 }
430
431 fn next_sequence(&self) -> u64 {
432 self.entries
433 .last()
434 .map(|entry| entry.sequence)
435 .or_else(|| {
436 self.snapshot
437 .as_ref()
438 .map(|snapshot| snapshot.last_compacted_sequence)
439 })
440 .unwrap_or(0)
441 .saturating_add(1)
442 }
443
444 fn last_digest(&self) -> Option<String> {
445 self.entries
446 .last()
447 .map(|entry| entry.entry_digest.clone())
448 .or_else(|| {
449 self.snapshot
450 .as_ref()
451 .and_then(|snapshot| snapshot.last_compacted_entry_digest.clone())
452 })
453 }
454
455 fn append(&mut self, entry: claim_ledger::LedgerEntry) -> Result<String, String> {
458 if !self.trust_enabled {
459 return Err("claim ledger is disabled after verification failure".into());
460 }
461 if entry.sequence != self.next_sequence()
462 || entry.previous_entry_digest != self.last_digest()
463 {
464 return Err("claim ledger append does not continue the verified head".into());
465 }
466 let line = claim_ledger::serialize_entry(&entry).map_err(|e| e.to_string())?;
467 use std::io::Write;
468 let mut file = std::fs::OpenOptions::new()
469 .create(true)
470 .append(true)
471 .open(&self.path)
472 .map_err(|e| format!("failed to open claim ledger file: {e}"))?;
473 writeln!(file, "{line}").map_err(|e| format!("failed to write claim ledger entry: {e}"))?;
474 file.sync_data()
475 .map_err(|e| format!("failed to fsync claim ledger entry: {e}"))?;
476 let digest = entry.entry_digest.clone();
477 self.entries.push(entry);
478 Ok(digest)
479 }
480
481 fn compact(
482 &mut self,
483 config: ClaimLedgerCompactionConfig,
484 ) -> Result<serde_json::Value, String> {
485 if !self.trust_enabled {
486 return Err("claim ledger trust is disabled; refusing compaction".into());
487 }
488 let current_bytes = std::fs::metadata(&self.path)
489 .map(|metadata| metadata.len())
490 .unwrap_or(0);
491 let threshold_exceeded =
492 self.entries.len() > config.max_entries || current_bytes > config.max_bytes;
493 if !threshold_exceeded {
494 return Ok(serde_json::json!({
495 "ok": true,
496 "dry_run": config.dry_run,
497 "compacted": false,
498 "reason": "threshold_not_exceeded",
499 "entries": self.entries.len(),
500 "bytes": current_bytes,
501 "max_entries": config.max_entries,
502 "max_bytes": config.max_bytes,
503 }));
504 }
505
506 let compacted = claim_ledger::compact_ledger_from_snapshot(
507 self.snapshot.as_ref(),
508 &self.entries,
509 &claim_ledger::CompactionPolicy {
510 retain_tail_entries: config.retain_tail_entries,
511 unprojectable_events: claim_ledger::UnprojectableEventPolicy::Retain,
512 },
513 )
514 .map_err(|e| e.to_string())?;
515 let compacted_entries = self
516 .entries
517 .len()
518 .saturating_sub(compacted.retained_tail.len());
519 let result = serde_json::json!({
520 "ok": true,
521 "dry_run": config.dry_run,
522 "compacted": !config.dry_run && compacted_entries > 0,
523 "entries_before": self.entries.len(),
524 "bytes_before": current_bytes,
525 "entries_checkpointed": compacted_entries,
526 "retained_tail_entries": compacted.retained_tail.len(),
527 "snapshot_sequence": compacted.snapshot.last_compacted_sequence,
528 "snapshot_digest": compacted.snapshot.snapshot_digest,
529 "receipt": compacted.receipt,
530 });
531 if config.dry_run || compacted_entries == 0 {
532 return Ok(result);
533 }
534
535 let memory_dir = self
536 .legacy_path
537 .parent()
538 .ok_or("claim ledger has no parent directory")?;
539 let generation = compacted.receipt.receipt_digest[..16].to_string();
540 let generations_dir = memory_dir.join("claim_ledger_generations");
541 std::fs::create_dir_all(&generations_dir)
542 .map_err(|e| format!("failed to create ledger generation directory: {e}"))?;
543 let final_dir = generations_dir.join(&generation);
544 let temp_dir = generations_dir.join(format!(".tmp-{generation}"));
545 if temp_dir.exists() {
546 std::fs::remove_dir_all(&temp_dir)
547 .map_err(|e| format!("failed to clear stale compaction temp directory: {e}"))?;
548 }
549 std::fs::create_dir(&temp_dir)
550 .map_err(|e| format!("failed to create compaction temp directory: {e}"))?;
551
552 let snapshot_bytes = serde_json::to_vec_pretty(&compacted.snapshot)
553 .map_err(|e| format!("failed to serialize ledger snapshot: {e}"))?;
554 let receipt_bytes = serde_json::to_vec_pretty(&compacted.receipt)
555 .map_err(|e| format!("failed to serialize compaction receipt: {e}"))?;
556 let mut tail_bytes = Vec::new();
557 for entry in &compacted.retained_tail {
558 tail_bytes.extend_from_slice(
559 claim_ledger::serialize_entry(entry)
560 .map_err(|e| e.to_string())?
561 .as_bytes(),
562 );
563 tail_bytes.push(b'\n');
564 }
565 Self::write_synced(&temp_dir.join("snapshot.json"), &snapshot_bytes)?;
566 Self::write_synced(&temp_dir.join("tail.jsonl"), &tail_bytes)?;
567 Self::write_synced(&temp_dir.join("receipt.json"), &receipt_bytes)?;
568 Self::sync_directory(&temp_dir)?;
569 std::fs::rename(&temp_dir, &final_dir)
570 .map_err(|e| format!("failed to publish ledger generation: {e}"))?;
571 Self::sync_directory(&generations_dir)?;
572
573 let manifest = ClaimLedgerManifest {
574 format_version: "claim-ledger.active-compaction.v1".into(),
575 generation: generation.clone(),
576 snapshot_path: format!("claim_ledger_generations/{generation}/snapshot.json"),
577 tail_path: format!("claim_ledger_generations/{generation}/tail.jsonl"),
578 receipt_path: format!("claim_ledger_generations/{generation}/receipt.json"),
579 };
580 let manifest_bytes = serde_json::to_vec_pretty(&manifest)
581 .map_err(|e| format!("failed to serialize compaction manifest: {e}"))?;
582 let manifest_path = memory_dir.join("claim_ledger.active_compaction.json");
583 let manifest_temp = memory_dir.join(format!(".claim_ledger.manifest.{generation}.tmp"));
584 Self::write_synced(&manifest_temp, &manifest_bytes)?;
585 std::fs::rename(&manifest_temp, &manifest_path)
586 .map_err(|e| format!("failed to atomically activate compaction: {e}"))?;
587 Self::sync_directory(memory_dir)?;
588
589 let previous_path = self.path.clone();
590 self.path = final_dir.join("tail.jsonl");
591 self.entries = compacted.retained_tail;
592 self.snapshot = Some(compacted.snapshot);
593 self.receipt = Some(compacted.receipt);
594
595 if previous_path == self.legacy_path && previous_path.exists() {
596 let backups_dir = memory_dir.join("claim_ledger_backups");
597 if std::fs::create_dir_all(&backups_dir).is_ok() {
598 let backup_path = backups_dir.join(format!("{generation}-pre-compaction.jsonl"));
599 if let Err(error) = std::fs::rename(&previous_path, &backup_path) {
600 tracing::error!(error = %error, "failed to rotate legacy claim ledger backup");
601 }
602 }
603 }
604 Self::rotate_backups(memory_dir, &generation, config.max_backups);
605 Ok(result)
606 }
607
608 fn write_synced(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> {
609 use std::io::Write;
610 let mut file = std::fs::OpenOptions::new()
611 .write(true)
612 .create_new(true)
613 .open(path)
614 .map_err(|e| format!("failed to create {}: {e}", path.display()))?;
615 file.write_all(bytes)
616 .map_err(|e| format!("failed to write {}: {e}", path.display()))?;
617 file.sync_all()
618 .map_err(|e| format!("failed to fsync {}: {e}", path.display()))
619 }
620
621 fn sync_directory(path: &std::path::Path) -> Result<(), String> {
622 std::fs::File::open(path)
623 .and_then(|directory| directory.sync_all())
624 .map_err(|e| format!("failed to fsync directory {}: {e}", path.display()))
625 }
626
627 fn rotate_backups(memory_dir: &std::path::Path, active: &str, max_backups: usize) {
628 let generations_dir = memory_dir.join("claim_ledger_generations");
629 let mut generations: Vec<_> = std::fs::read_dir(&generations_dir)
630 .into_iter()
631 .flatten()
632 .flatten()
633 .filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
634 .filter(|entry| entry.file_name() != active)
635 .collect();
636 generations.sort_by_key(|entry| {
637 entry
638 .metadata()
639 .and_then(|metadata| metadata.modified())
640 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
641 });
642 let remove_count = generations.len().saturating_sub(max_backups);
643 for entry in generations.into_iter().take(remove_count) {
644 if let Err(error) = std::fs::remove_dir_all(entry.path()) {
645 tracing::error!(error = %error, "failed to remove old claim-ledger generation");
646 }
647 }
648
649 let backups_dir = memory_dir.join("claim_ledger_backups");
650 let mut backups: Vec<_> = std::fs::read_dir(&backups_dir)
651 .into_iter()
652 .flatten()
653 .flatten()
654 .filter(|entry| {
655 entry
656 .file_type()
657 .map(|kind| kind.is_file())
658 .unwrap_or(false)
659 })
660 .collect();
661 backups.sort_by_key(|entry| {
662 entry
663 .metadata()
664 .and_then(|metadata| metadata.modified())
665 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
666 });
667 let remove_count = backups.len().saturating_sub(max_backups);
668 for entry in backups.into_iter().take(remove_count) {
669 if let Err(error) = std::fs::remove_file(entry.path()) {
670 tracing::error!(error = %error, "failed to remove old claim-ledger backup");
671 }
672 }
673 }
674}
675
676#[cfg(feature = "claim-integration")]
677fn support_state_label(state: claim_ledger::SupportState) -> &'static str {
678 use claim_ledger::SupportState;
679 match state {
680 SupportState::Supported => "supported",
681 SupportState::PartiallySupported => "partially_supported",
682 SupportState::Unsupported => "unsupported",
683 SupportState::Contradicted => "contradicted",
684 SupportState::HeuristicOnly => "heuristic_only",
685 SupportState::Unknown => "persisted_unjudged",
686 }
687}
688
689#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, JsonSchema)]
695pub struct StructuredOutput {
696 #[serde(flatten)]
697 pub fields: std::collections::BTreeMap<String, serde_json::Value>,
698}
699
700impl StructuredOutput {
701 fn from_value(value: serde_json::Value) -> Self {
702 let fields = match value {
703 serde_json::Value::Object(map) => map.into_iter().collect(),
704 value => std::collections::BTreeMap::from([("value".to_string(), value)]),
705 };
706 Self { fields }
707 }
708}
709
710fn structured_output(value: serde_json::Value) -> Json<StructuredOutput> {
711 Json(StructuredOutput::from_value(value))
712}
713
714#[derive(Debug, serde::Serialize, serde::Deserialize, JsonSchema)]
716pub struct StatsOutput {
717 pub ok: bool,
718 pub components: serde_json::Value,
719 pub facts: Option<u64>,
720 pub chunks: Option<u64>,
721 pub documents: Option<u64>,
722 pub sessions: Option<u64>,
723 pub messages: Option<u64>,
724 pub graph_edges: Option<usize>,
725 pub db_size_bytes: Option<u64>,
726 pub db_size_mb: Option<f64>,
727 pub embedding_model: Option<String>,
728 pub embedding_dimensions: Option<usize>,
729}
730
731pub struct SemanticMemoryServer {
732 bridge: Arc<MemoryBridge>,
733 tool_router: ToolRouter<Self>,
734 routing_policy_batch: Mutex<RoutingPolicyBatchState>,
735 #[cfg(feature = "claim-integration")]
736 claim_trust: Mutex<ClaimTrustIndex>,
737 #[cfg(feature = "claim-integration")]
738 claim_ledger_store: Mutex<ClaimLedgerStore>,
739}
740
741impl SemanticMemoryServer {
742 pub fn new(bridge: MemoryBridge, tool_profile: &str) -> Self {
743 let mut router = Self::tool_router();
744
745 match tool_profile {
746 "full" => { }
747 "stable" => {
748 let allowed: HashSet<&str> = [
749 "sm_search",
750 "sm_search_witnessed",
751 "sm_stats",
752 "sm_list_namespaces",
753 "sm_get_fact",
754 "sm_get_fact_neighbors",
755 "sm_graph_path",
756 "sm_search_conversations",
757 "sm_add_fact",
758 "sm_supersede_fact",
759 "sm_add_graph_edge",
760 "sm_decide_assertion_authority",
761 "sm_decide_action_authority",
762 ]
763 .into_iter()
764 .collect();
765 let names: Vec<_> = router
766 .list_all()
767 .into_iter()
768 .map(|tool| tool.name.into_owned())
769 .collect();
770 for name in names {
771 if !allowed.contains(name.as_str()) {
772 router.disable_route(name);
773 }
774 }
775 }
776 "lean" => {
777 let allowed: HashSet<&str> = [
778 "sm_search_witnessed",
779 "sm_replay_search",
780 "sm_decide_assertion_authority",
781 "sm_decide_action_authority",
782 ]
783 .into_iter()
784 .collect();
785 let names: Vec<_> = router
786 .list_all()
787 .into_iter()
788 .map(|tool| tool.name.into_owned())
789 .collect();
790 for name in names {
791 if !allowed.contains(name.as_str()) {
792 router.disable_route(name);
793 }
794 }
795 }
796 "standard" => {
797 let allowed: HashSet<&str> = [
798 "sm_search",
799 "sm_search_witnessed",
800 "sm_replay_search",
801 "sm_stats",
802 "sm_list_namespaces",
803 "sm_get_fact",
804 "sm_get_fact_neighbors",
805 "sm_graph_path",
806 "sm_search_conversations",
807 "sm_add_fact",
808 "sm_supersede_fact",
809 "sm_add_graph_edge",
810 "sm_decide_assertion_authority",
811 "sm_decide_action_authority",
812 "sm_update_fact",
813 "sm_set_provenance",
814 "sm_list_facts",
815 ]
816 .into_iter()
817 .collect();
818 let names: Vec<_> = router
819 .list_all()
820 .into_iter()
821 .map(|tool| tool.name.into_owned())
822 .collect();
823 for name in names {
824 if !allowed.contains(name.as_str()) {
825 router.disable_route(name);
826 }
827 }
828 }
829 "agent" => {
830 let allowed: HashSet<&str> = [
831 "sm_add_fact",
832 "sm_add_graph_edge",
833 "sm_decide_action_authority",
834 "sm_decide_assertion_authority",
835 "sm_get_fact",
836 "sm_get_fact_neighbors",
837 "sm_get_search_receipt",
838 "sm_graph_path",
839 "sm_list_namespaces",
840 "sm_replay_search",
841 "sm_search_conversations",
842 "sm_search_witnessed",
843 "sm_set_provenance",
844 "sm_stats",
845 "sm_supersede_fact",
846 "sm_update_fact",
847 ]
848 .into_iter()
849 .collect();
850 let names: Vec<_> = router
851 .list_all()
852 .into_iter()
853 .map(|tool| tool.name.into_owned())
854 .collect();
855 for name in names {
856 if !allowed.contains(name.as_str()) {
857 router.disable_route(name);
858 }
859 }
860 }
861 _ => panic!(
862 "Unknown tool profile '{}'. Must be one of: stable, lean, standard, agent, full",
863 tool_profile
864 ),
865 }
866
867 eprintln!(
868 "Tool profile: {} ({} tools visible)",
869 tool_profile,
870 router.list_all().len()
871 );
872
873 #[cfg(feature = "claim-integration")]
874 let claim_ledger_store =
875 ClaimLedgerStore::open(bridge.memory_dir.join("claim_ledger.jsonl"));
876 #[cfg(feature = "claim-integration")]
877 let mut claim_trust = ClaimTrustIndex::default();
878 #[cfg(feature = "claim-integration")]
879 if claim_ledger_store.trust_enabled {
880 claim_trust.enabled = true;
881 if let Some(snapshot) = &claim_ledger_store.snapshot {
882 claim_trust.load_snapshot(snapshot);
883 }
884 claim_trust.rebuild_from_ledger_incremental(&claim_ledger_store.entries);
885 } else {
886 claim_trust.disable();
887 }
888
889 Self {
890 bridge: Arc::new(bridge),
891 tool_router: router,
892 routing_policy_batch: Mutex::new(RoutingPolicyBatchState::default()),
893 #[cfg(feature = "claim-integration")]
894 claim_trust: Mutex::new(claim_trust),
895 #[cfg(feature = "claim-integration")]
896 claim_ledger_store: Mutex::new(claim_ledger_store),
897 }
898 }
899
900 pub fn exposes_tool(&self, name: &str) -> bool {
901 self.tool_router
902 .list_all()
903 .iter()
904 .any(|tool| tool.name == name)
905 }
906
907 pub fn exposed_tool_names(&self) -> Vec<String> {
908 let mut names: Vec<_> = self
909 .tool_router
910 .list_all()
911 .into_iter()
912 .map(|tool| tool.name.into_owned())
913 .collect();
914 names.sort();
915 names
916 }
917
918 pub fn tool_annotations(&self, name: &str) -> Option<rmcp::model::ToolAnnotations> {
919 self.tool_router
920 .list_all()
921 .into_iter()
922 .find(|tool| tool.name == name)
923 .and_then(|tool| tool.annotations)
924 }
925
926 fn decide_governed_authority(
927 &self,
928 params: GovernedDecisionParams,
929 purpose: semantic_memory::GovernedAccessPurposeV1,
930 ) -> Result<Json<StructuredOutput>, ErrorData> {
931 use semantic_memory::{
932 AudienceV1, CallerPrincipalV1, DelegationElevationLeaseV1, GovernedAccessPurposeV1,
933 GovernedAccessRequestV1, NamespaceScopeV1, SubjectPrincipalV1,
934 };
935
936 let GovernedDecisionParams {
937 fact_id,
938 caller,
939 subject,
940 audiences,
941 scope,
942 delegation_or_elevation,
943 } = params;
944 let caller = CallerPrincipalV1::new(caller)
945 .map_err(|error| ErrorData::invalid_params(error, None))?;
946 let subject = SubjectPrincipalV1::new(subject)
947 .map_err(|error| ErrorData::invalid_params(error, None))?;
948 let scope = NamespaceScopeV1 {
949 namespace: scope.namespace,
950 domain: scope.domain,
951 workspace_id: scope.workspace_id,
952 repo_id: scope.repo_id,
953 };
954 let mut request =
955 GovernedAccessRequestV1::for_principals(caller, subject, audiences, purpose, scope);
956 if let Some(lease) = delegation_or_elevation {
957 let lease_scope = NamespaceScopeV1 {
958 namespace: lease.scope.namespace,
959 domain: lease.scope.domain,
960 workspace_id: lease.scope.workspace_id,
961 repo_id: lease.scope.repo_id,
962 };
963 let purposes = lease
964 .purposes
965 .into_iter()
966 .map(|purpose| match purpose {
967 GovernedAccessPurposeParam::Recall => GovernedAccessPurposeV1::Recall,
968 GovernedAccessPurposeParam::Assertion => GovernedAccessPurposeV1::Assertion,
969 GovernedAccessPurposeParam::Action => GovernedAccessPurposeV1::Action,
970 GovernedAccessPurposeParam::Export => GovernedAccessPurposeV1::Export,
971 GovernedAccessPurposeParam::Replay => GovernedAccessPurposeV1::Replay,
972 GovernedAccessPurposeParam::Admin => GovernedAccessPurposeV1::Admin,
973 })
974 .collect();
975 request = request.with_delegation_or_elevation(DelegationElevationLeaseV1 {
976 lease_id: lease.lease_id,
977 delegator: SubjectPrincipalV1::new(lease.delegator)
978 .map_err(|error| ErrorData::invalid_params(error, None))?,
979 delegatee: CallerPrincipalV1::new(lease.delegatee)
980 .map_err(|error| ErrorData::invalid_params(error, None))?,
981 purposes,
982 scope: lease_scope,
983 audience: AudienceV1::new(lease.audiences),
984 expires_at: lease.expires_at,
985 revoked: lease.revoked,
986 elevation: lease.elevation,
987 });
988 }
989
990 let fact_id = fact_id
991 .strip_prefix("fact:")
992 .unwrap_or(&fact_id)
993 .to_string();
994 let access = tokio::task::block_in_place(|| {
995 Handle::current().block_on(
996 self.bridge
997 .store
998 .authority()
999 .get_fact_governed(&fact_id, request),
1000 )
1001 })
1002 .map_err(|error| {
1003 ErrorData::internal_error(format!("governed authority decision error: {error}"), None)
1004 })?;
1005
1006 let value = serde_json::to_value(&access.decision).map_err(|error| {
1009 ErrorData::internal_error(
1010 format!("decision receipt serialization error: {error}"),
1011 None,
1012 )
1013 })?;
1014 Ok(structured_output(value))
1015 }
1016}
1017
1018fn load_stored_edge_refs(
1021 store: &semantic_memory::MemoryStore,
1022) -> Result<Vec<semantic_memory::discord::GraphEdgeRef>, ErrorData> {
1023 let edges =
1024 tokio::task::block_in_place(|| Handle::current().block_on(store.list_all_graph_edges()))
1025 .map_err(|e| {
1026 ErrorData::internal_error(format!("Failed to load graph edges: {e}"), None)
1027 })?;
1028 let refs = edges
1029 .iter()
1030 .map(|edge| {
1031 let parsed_type = edge
1032 .edge_type_parsed
1033 .clone()
1034 .or_else(|| serde_json::from_str(&edge.edge_type).ok())
1035 .unwrap_or(semantic_memory::GraphEdgeType::Entity {
1036 relation: "unknown".to_string(),
1037 });
1038 let type_str = match parsed_type {
1039 semantic_memory::GraphEdgeType::Semantic { .. } => "semantic",
1040 semantic_memory::GraphEdgeType::Temporal { .. } => "temporal",
1041 semantic_memory::GraphEdgeType::Causal { .. } => "causal",
1042 semantic_memory::GraphEdgeType::Entity { .. } => "entity",
1043 };
1044 semantic_memory::discord::GraphEdgeRef {
1045 source: edge.source.clone(),
1046 target: edge.target.clone(),
1047 edge_type: type_str.to_string(),
1048 weight: edge.weight,
1049 }
1050 })
1051 .collect();
1052 Ok(refs)
1053}
1054
1055fn load_stored_factor_edges(
1058 store: &semantic_memory::MemoryStore,
1059) -> Result<Vec<FactorEdgeTuple>, ErrorData> {
1060 let edges =
1061 tokio::task::block_in_place(|| Handle::current().block_on(store.list_all_graph_edges()))
1062 .map_err(|e| {
1063 ErrorData::internal_error(format!("Failed to load graph edges: {e}"), None)
1064 })?;
1065 let raw = edges
1066 .iter()
1067 .map(|edge| {
1068 let parsed_type = edge
1069 .edge_type_parsed
1070 .clone()
1071 .or_else(|| serde_json::from_str(&edge.edge_type).ok())
1072 .unwrap_or(semantic_memory::GraphEdgeType::Entity {
1073 relation: "unknown".to_string(),
1074 });
1075 (
1076 edge.source.clone(),
1077 edge.target.clone(),
1078 parsed_type,
1079 edge.weight,
1080 edge.metadata.clone(),
1081 )
1082 })
1083 .collect();
1084 Ok(raw)
1085}
1086
1087fn load_stored_edge_pairs(
1089 store: &semantic_memory::MemoryStore,
1090) -> Result<Vec<(String, String)>, ErrorData> {
1091 let edges =
1092 tokio::task::block_in_place(|| Handle::current().block_on(store.list_all_graph_edges()))
1093 .map_err(|e| {
1094 ErrorData::internal_error(format!("Failed to load graph edges: {e}"), None)
1095 })?;
1096 let pairs = edges
1097 .iter()
1098 .map(|edge| (edge.source.clone(), edge.target.clone()))
1099 .collect();
1100 Ok(pairs)
1101}
1102
1103fn load_neighborhood_edge_pairs(
1107 store: &semantic_memory::MemoryStore,
1108 seed_ids: &[String],
1109) -> Result<Vec<(String, String)>, ErrorData> {
1110 if seed_ids.is_empty() {
1111 return load_stored_edge_pairs(store);
1112 }
1113 let edges = tokio::task::block_in_place(|| {
1114 Handle::current().block_on(store.list_graph_edges_for_neighborhood(
1115 seed_ids.to_vec(),
1116 2,
1117 200,
1118 ))
1119 })
1120 .map_err(|e| {
1121 ErrorData::internal_error(format!("Failed to load neighborhood edges: {e}"), None)
1122 })?;
1123 let pairs = edges
1124 .iter()
1125 .map(|edge| (edge.source.clone(), edge.target.clone()))
1126 .collect();
1127 Ok(pairs)
1128}
1129
1130fn load_neighborhood_edge_refs(
1132 store: &semantic_memory::MemoryStore,
1133 seed_ids: &[String],
1134) -> Result<Vec<semantic_memory::discord::GraphEdgeRef>, ErrorData> {
1135 if seed_ids.is_empty() {
1136 return load_stored_edge_refs(store);
1137 }
1138 let edges = tokio::task::block_in_place(|| {
1139 Handle::current().block_on(store.list_graph_edges_for_neighborhood(
1140 seed_ids.to_vec(),
1141 2,
1142 200,
1143 ))
1144 })
1145 .map_err(|e| {
1146 ErrorData::internal_error(format!("Failed to load neighborhood edges: {e}"), None)
1147 })?;
1148 let refs = edges
1149 .iter()
1150 .map(|edge| {
1151 let parsed_type = edge
1152 .edge_type_parsed
1153 .clone()
1154 .or_else(|| serde_json::from_str(&edge.edge_type).ok())
1155 .unwrap_or(semantic_memory::GraphEdgeType::Entity {
1156 relation: "unknown".to_string(),
1157 });
1158 let type_str = match parsed_type {
1159 semantic_memory::GraphEdgeType::Semantic { .. } => "semantic",
1160 semantic_memory::GraphEdgeType::Temporal { .. } => "temporal",
1161 semantic_memory::GraphEdgeType::Causal { .. } => "causal",
1162 semantic_memory::GraphEdgeType::Entity { .. } => "entity",
1163 };
1164 semantic_memory::discord::GraphEdgeRef {
1165 source: edge.source.clone(),
1166 target: edge.target.clone(),
1167 edge_type: type_str.to_string(),
1168 weight: edge.weight,
1169 }
1170 })
1171 .collect();
1172 Ok(refs)
1173}
1174
1175type FactorEdgeTuple = (
1176 String,
1177 String,
1178 semantic_memory::GraphEdgeType,
1179 f64,
1180 Option<String>,
1181);
1182
1183fn load_neighborhood_factor_edges(
1185 store: &semantic_memory::MemoryStore,
1186 seed_ids: &[String],
1187) -> Result<Vec<FactorEdgeTuple>, ErrorData> {
1188 if seed_ids.is_empty() {
1189 return load_stored_factor_edges(store);
1190 }
1191 let edges = tokio::task::block_in_place(|| {
1192 Handle::current().block_on(store.list_graph_edges_for_neighborhood(
1193 seed_ids.to_vec(),
1194 2,
1195 200,
1196 ))
1197 })
1198 .map_err(|e| {
1199 ErrorData::internal_error(format!("Failed to load neighborhood edges: {e}"), None)
1200 })?;
1201 let raw = edges
1202 .iter()
1203 .map(|edge| {
1204 let parsed_type = edge
1205 .edge_type_parsed
1206 .clone()
1207 .or_else(|| serde_json::from_str(&edge.edge_type).ok())
1208 .unwrap_or(semantic_memory::GraphEdgeType::Entity {
1209 relation: "unknown".to_string(),
1210 });
1211 (
1212 edge.source.clone(),
1213 edge.target.clone(),
1214 parsed_type,
1215 edge.weight,
1216 edge.metadata.clone(),
1217 )
1218 })
1219 .collect();
1220 Ok(raw)
1221}
1222
1223fn load_superseded_targets(
1225 store: &semantic_memory::MemoryStore,
1226) -> Result<HashSet<String>, ErrorData> {
1227 let edges =
1228 tokio::task::block_in_place(|| Handle::current().block_on(store.list_all_graph_edges()))
1229 .map_err(|e| {
1230 ErrorData::internal_error(format!("Failed to load graph edges: {e}"), None)
1231 })?;
1232 let mut targets = HashSet::new();
1233 for edge in edges {
1234 let parsed_type = edge
1235 .edge_type_parsed
1236 .clone()
1237 .or_else(|| serde_json::from_str(&edge.edge_type).ok());
1238 if let Some(semantic_memory::GraphEdgeType::Entity { relation }) = parsed_type {
1239 if relation == "supersedes" {
1240 targets.insert(edge.target);
1241 }
1242 }
1243 }
1244 Ok(targets)
1245}
1246
1247fn query_allows_superseded(query: &str) -> bool {
1248 let q = query.to_lowercase();
1249 q.contains("supersed")
1250 || q.contains("stale")
1251 || q.contains("obsolete")
1252 || q.contains("histor")
1253 || q.contains("old fact")
1254 || q.contains("previous fact")
1255}
1256
1257fn build_projection_query(params: ProjectionQueryParams) -> semantic_memory::ProjectionQuery {
1264 use stack_ids::{ClaimId, ClaimVersionId, EntityId, ScopeKey};
1265
1266 let scope = ScopeKey {
1267 namespace: params.namespace,
1268 domain: params.domain,
1269 workspace_id: params.workspace_id,
1270 repo_id: params.repo_id,
1271 };
1272
1273 let limit = params.limit.unwrap_or(10) as usize;
1274
1275 semantic_memory::ProjectionQuery {
1276 scope,
1277 text_query: params.text_query,
1278 valid_at: params.valid_at,
1279 recorded_at_or_before: params.recorded_at_or_before,
1280 subject_entity_id: params.subject_entity_id.map(EntityId::new),
1281 canonical_entity_id: params.canonical_entity_id.map(EntityId::new),
1282 claim_state: params.claim_state,
1283 claim_id: params.claim_id.map(ClaimId::new),
1284 claim_version_id: params.claim_version_id.map(ClaimVersionId::new),
1285 limit,
1286 }
1287}
1288
1289fn json_to_output(value: &serde_json::Value) -> Result<Json<StructuredOutput>, ErrorData> {
1290 Ok(structured_output(value.clone()))
1291}
1292
1293fn mcp_receipt_id(tool_name: &str) -> String {
1296 format!("mcp-receipt:{tool_name}:{}", uuid::Uuid::new_v4())
1297}
1298
1299fn mcp_now_iso() -> String {
1301 chrono::Utc::now().to_rfc3339()
1302}
1303
1304fn mcp_receipt(tool_name: &str) -> serde_json::Value {
1307 serde_json::json!({
1308 "receipt_id": mcp_receipt_id(tool_name),
1309 "recorded_at": mcp_now_iso(),
1310 "tool": tool_name,
1311 })
1312}
1313
1314fn witnessed_injectible_fact(
1321 store: &semantic_memory::MemoryStore,
1322 result: semantic_memory::SearchResult,
1323 receipt_ref: &str,
1324) -> Result<Option<serde_json::Value>, ErrorData> {
1325 let semantic_memory::SearchSource::Fact { fact_id, .. } = &result.source else {
1326 return Ok(None);
1327 };
1328 let fact = tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(fact_id)))
1329 .map_err(|e| {
1330 ErrorData::internal_error(
1331 format!("witnessed fact provenance hydration failed: {e}"),
1332 None,
1333 )
1334 })?;
1335 let Some(fact) = fact else {
1336 return Ok(None);
1337 };
1338 let Some(source) = fact.source.filter(|source| !source.trim().is_empty()) else {
1339 return Ok(None);
1340 };
1341 let memory_id = format!("fact:{}", fact.id);
1342 Ok(Some(serde_json::json!({
1343 "memory_id": memory_id,
1344 "result_id": result.source.result_id(),
1345 "content": fact.content,
1346 "namespace": fact.namespace,
1347 "source": source,
1348 "trust": "persisted_unjudged",
1349 "state": "current",
1350 "retrieval_receipt_ref": receipt_ref,
1351 "score": result.score,
1352 "bm25_rank": result.bm25_rank,
1353 "vector_rank": result.vector_rank,
1354 "cosine_similarity": result.cosine_similarity,
1355 })))
1356}
1357
1358#[tool_router]
1359impl SemanticMemoryServer {
1360 #[tool(
1363 description = "Semantic hybrid search (BM25 + vector + RRF). Returns ranked results with content, scores, and stable result IDs.",
1364 annotations(read_only_hint = true)
1365 )]
1366 fn sm_search(
1367 &self,
1368 Parameters(SearchParams {
1369 query,
1370 top_k,
1371 namespaces,
1372 }): Parameters<SearchParams>,
1373 ) -> Result<Json<StructuredOutput>, ErrorData> {
1374 let requested_k = top_k.map(|v| v as usize).unwrap_or(5);
1375 let allow_superseded = false;
1376 let search_k = if allow_superseded {
1377 requested_k
1378 } else {
1379 (requested_k * 4).max(20)
1380 };
1381 let ns: Option<Vec<&str>> = namespaces
1382 .as_ref()
1383 .map(|v| v.iter().map(|s| s.as_str()).collect());
1384
1385 let store = &self.bridge.store;
1386 let result = tokio::task::block_in_place(|| {
1387 Handle::current().block_on(store.search(&query, Some(search_k), ns.as_deref(), None))
1388 });
1389
1390 match result {
1391 Ok(results) => {
1392 let superseded_targets = if allow_superseded {
1393 HashSet::new()
1394 } else {
1395 load_superseded_targets(store)?
1396 };
1397 let fresh_results: Vec<_> = results
1398 .iter()
1399 .filter(|r| !superseded_targets.contains(&r.source.result_id()))
1400 .collect();
1401 let result_refs: Vec<_> = if superseded_targets.is_empty() {
1402 results.iter().collect()
1403 } else {
1404 fresh_results
1405 };
1406 let superseded_filtered_count = results.len().saturating_sub(result_refs.len());
1407 let json_results: Vec<serde_json::Value> = result_refs
1408 .iter()
1409 .take(requested_k)
1410 .map(|r| {
1411 serde_json::json!({
1412 "result_id": r.source.result_id(),
1413 "content": r.content,
1414 "source": format!("{:?}", r.source),
1415 "score": r.score,
1416 "bm25_rank": r.bm25_rank,
1417 "vector_rank": r.vector_rank,
1418 "cosine_similarity": r.cosine_similarity,
1419 })
1420 })
1421 .collect();
1422 json_to_output(&serde_json::json!({
1423 "ok": true,
1424 "results": json_results,
1425 "count": json_results.len(),
1426 "superseded_filtered_count": superseded_filtered_count,
1427 }))
1428 }
1429 Err(e) => Err(ErrorData::internal_error(
1430 format!("Search error: {e}"),
1431 None,
1432 )),
1433 }
1434 }
1435
1436 #[cfg(feature = "claim-integration")]
1440 fn trust_for_fact(&self, bare_fact_id: &str) -> String {
1441 self.claim_trust
1442 .lock()
1443 .unwrap()
1444 .trust_for_fact(bare_fact_id)
1445 }
1446
1447 #[cfg(not(feature = "claim-integration"))]
1448 fn trust_for_fact(&self, _bare_fact_id: &str) -> String {
1449 "persisted_unjudged".to_string()
1450 }
1451
1452 #[cfg(feature = "claim-integration")]
1457 fn auto_link_fact_to_claims(&self, bare_fact_id: &str, content: &str) {
1458 self.claim_trust
1459 .lock()
1460 .unwrap()
1461 .auto_link_content(bare_fact_id, content);
1462 }
1463
1464 #[cfg(not(feature = "claim-integration"))]
1465 fn auto_link_fact_to_claims(&self, _bare_fact_id: &str, _content: &str) {}
1466
1467 fn enrich_results_with_trust(&self, results: &mut [serde_json::Value]) {
1473 for result in results.iter_mut() {
1474 let bare_fact_id = result
1475 .get("memory_id")
1476 .and_then(|v| v.as_str())
1477 .map(|s| s.strip_prefix("fact:").unwrap_or(s).to_string());
1478 let Some(bare_fact_id) = bare_fact_id else {
1479 continue;
1480 };
1481 if let Some(content) = result.get("content").and_then(|v| v.as_str()) {
1482 self.auto_link_fact_to_claims(&bare_fact_id, content);
1483 }
1484 if let Some(obj) = result.as_object_mut() {
1485 obj.insert(
1486 "trust".to_string(),
1487 serde_json::Value::String(self.trust_for_fact(&bare_fact_id)),
1488 );
1489 }
1490 }
1491 }
1492
1493 #[tool(
1494 description = "Mandatory witnessed retrieval. Bypasses cache, verifies durable receipt persistence, defaults to Current state, and supports privacy-preserving opt-in storage for complete replay.",
1495 annotations(read_only_hint = true)
1496 )]
1497 fn sm_search_witnessed(
1498 &self,
1499 Parameters(SearchWitnessedParams {
1500 query,
1501 top_k,
1502 namespaces,
1503 request_id,
1504 retrieval_mode,
1505 replay_mode,
1506 }): Parameters<SearchWitnessedParams>,
1507 ) -> Result<Json<StructuredOutput>, ErrorData> {
1508 use semantic_memory::{ExactnessProfile, ReceiptMode, ReplayMode, SearchContext};
1509 let k = top_k.map(|v| v as usize).unwrap_or(5);
1510 let request_id = request_id.unwrap_or_else(|| {
1511 format!(
1512 "mcp-witness-{}-{}",
1513 chrono::Utc::now().timestamp_micros(),
1514 WITNESS_REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed)
1515 )
1516 });
1517 let digest = |s: &str| format!("blake3:{}", blake3::hash(s.as_bytes()).to_hex());
1518 let filters = serde_json::json!({"namespaces": namespaces});
1519 let query_digest = digest(&query);
1520 let input_digest = digest(
1521 &serde_json::json!({"query": query, "top_k": k, "filters": filters}).to_string(),
1522 );
1523 let filter_digest = digest(&filters.to_string());
1524 let retrieval_mode = retrieval_mode.unwrap_or(RetrievalModeParam::Hybrid);
1525 let retrieval_mode_name = match retrieval_mode {
1526 RetrievalModeParam::Hybrid => "hybrid",
1527 RetrievalModeParam::FtsOnly => "fts_only",
1528 RetrievalModeParam::VectorOnly => "vector_only",
1529 };
1530 let config_digest = digest(&format!(
1531 "retrieval_mode={retrieval_mode_name};top_k={k};state=current;cache=bypass;exactness=prefer_exact"
1532 ));
1533 let ns: Option<Vec<&str>> = namespaces
1534 .as_ref()
1535 .map(|v| v.iter().map(String::as_str).collect());
1536 let mut context = SearchContext::default_now();
1537 context.receipt_mode = ReceiptMode::ReturnReceipt;
1538 context.replay_mode = match replay_mode.unwrap_or(ReplayModeParam::NoReplay) {
1539 ReplayModeParam::NoReplay => ReplayMode::NoReplay,
1540 ReplayModeParam::StoreInputs => ReplayMode::StoreInputs,
1541 };
1542 context.exactness_profile = ExactnessProfile::PreferExact;
1543 context.request_id = Some(request_id.clone());
1544 context.query_text_digest = Some(query_digest.clone());
1545 context.query_input_digest = Some(input_digest.clone());
1546 context.filter_digest = Some(filter_digest.clone());
1547 let response = tokio::task::block_in_place(|| {
1549 Handle::current().block_on(async {
1550 match retrieval_mode {
1551 RetrievalModeParam::Hybrid => {
1552 self.bridge
1553 .store
1554 .search_with_context(&query, Some(k), ns.as_deref(), None, context)
1555 .await
1556 }
1557 RetrievalModeParam::FtsOnly => {
1558 self.bridge
1559 .store
1560 .search_fts_only_with_context(
1561 &query,
1562 Some(k),
1563 ns.as_deref(),
1564 None,
1565 context,
1566 )
1567 .await
1568 }
1569 RetrievalModeParam::VectorOnly => {
1570 self.bridge
1571 .store
1572 .search_vector_only_with_context(
1573 &query,
1574 Some(k),
1575 ns.as_deref(),
1576 None,
1577 context,
1578 )
1579 .await
1580 }
1581 }
1582 })
1583 })
1584 .map_err(|e| {
1585 ErrorData::internal_error(
1586 format!("witnessed search/receipt persistence failed: {e}"),
1587 None,
1588 )
1589 })?;
1590 let receipt = response.receipt.ok_or_else(|| {
1591 ErrorData::internal_error("witness missing; operation contained".to_string(), None)
1592 })?;
1593 let authority_state = tokio::task::block_in_place(|| {
1594 Handle::current().block_on(self.bridge.store.authority().current_state())
1595 })
1596 .map_err(|error| {
1597 ErrorData::internal_error(format!("authority state lookup failed: {error}"), None)
1598 })?;
1599 let durable = tokio::task::block_in_place(|| {
1600 Handle::current().block_on(self.bridge.store.get_search_receipt(&receipt.receipt_id))
1601 })
1602 .map_err(|e| {
1603 ErrorData::internal_error(format!("receipt verification failed: {e}"), None)
1604 })?;
1605 if durable.is_none() {
1606 return Err(ErrorData::internal_error(
1607 "receipt not durable; operation contained".to_string(),
1608 None,
1609 ));
1610 }
1611 let complete_replay_available = tokio::task::block_in_place(|| {
1612 Handle::current().block_on(
1613 self.bridge
1614 .store
1615 .search_replay_inputs_available(&receipt.receipt_id),
1616 )
1617 })
1618 .map_err(|e| {
1619 ErrorData::internal_error(format!("replay input verification failed: {e}"), None)
1620 })?;
1621 let stats =
1622 tokio::task::block_in_place(|| Handle::current().block_on(self.bridge.store.stats()))
1623 .map_err(|e| {
1624 ErrorData::internal_error(format!("model identity unavailable: {e}"), None)
1625 })?;
1626 let model_digest = digest(&serde_json::json!({"model": stats.embedding_model, "dimensions": stats.embedding_dimensions}).to_string());
1627 let receipt_ref = format!("receipt:{}", receipt.receipt_id);
1628 let mut results = Vec::new();
1629 for result in response.results {
1630 if let Some(hit) = witnessed_injectible_fact(&self.bridge.store, result, &receipt_ref)?
1631 {
1632 results.push(hit);
1633 }
1634 }
1635 self.enrich_results_with_trust(&mut results);
1638
1639 #[cfg(feature = "integration")]
1645 {
1646 use semantic_memory::factor_graph::{
1647 factors_from_edges, FactorGraph, FactorGraphConfig,
1648 };
1649 let result_nodes: Vec<(String, f64)> = results
1650 .iter()
1651 .filter_map(|result| {
1652 let id = result
1653 .get("memory_id")
1654 .and_then(|value| value.as_str())?
1655 .to_string();
1656 let score = result
1657 .get("score")
1658 .and_then(|value| value.as_f64())
1659 .unwrap_or(0.5);
1660 Some((id, score))
1661 })
1662 .collect();
1663 if !result_nodes.is_empty() {
1664 let seed_ids: Vec<String> = result_nodes
1665 .iter()
1666 .map(|(item_id, _)| item_id.clone())
1667 .collect();
1668 let edge_tuples = load_neighborhood_factor_edges(&self.bridge.store, &seed_ids)?;
1669 if !edge_tuples.is_empty() {
1670 let factors = factors_from_edges(&edge_tuples);
1671 let factor_graph =
1672 FactorGraph::new(&result_nodes, factors, FactorGraphConfig::default());
1673 let result_beliefs = factor_graph.propagate();
1674 let reranked = result_beliefs.top_k(result_nodes.len());
1675 results.sort_by(|a, b| {
1677 let a_id = a.get("memory_id").and_then(|v| v.as_str()).unwrap_or("");
1678 let b_id = b.get("memory_id").and_then(|v| v.as_str()).unwrap_or("");
1679 let a_belief = reranked
1680 .iter()
1681 .find(|(id, _)| id == a_id)
1682 .map(|(_, b)| *b)
1683 .unwrap_or(0.0);
1684 let b_belief = reranked
1685 .iter()
1686 .find(|(id, _)| id == b_id)
1687 .map(|(_, b)| *b)
1688 .unwrap_or(0.0);
1689 b_belief
1690 .partial_cmp(&a_belief)
1691 .unwrap_or(std::cmp::Ordering::Equal)
1692 });
1693 }
1694 }
1695 }
1696
1697 let ordered_results: Vec<_> = results.iter().map(|r| serde_json::json!({"result_id": r["result_id"], "result_digest": digest(&r.to_string())})).collect();
1698 let exactness = if receipt.approximate {
1699 "approximate_candidates"
1700 } else if receipt.exact_rerank {
1701 "exact_f32_rerank"
1702 } else {
1703 "backend_reported_non_approximate"
1704 };
1705 json_to_output(&serde_json::json!({
1706 "schema_version": "retrieval_response_v1", "ok": true, "request_id": request_id, "receipt_id": receipt.receipt_id, "retrieval_mode": retrieval_mode_name,
1707 "state_view": {"kind": "Current"}, "current_snapshot_id": authority_state.snapshot_id.0,
1708 "retrieval_epoch": authority_state.retrieval_epoch.0,
1709 "evaluation_time": receipt.evaluation_time,
1710 "authority": {
1711 "snapshot_id": authority_state.snapshot_id.0,
1712 "retrieval_epoch": authority_state.retrieval_epoch.0,
1713 "status": "Applied",
1714 "degradation": null
1715 },
1716 "digests": {"query_text": query_digest, "input": input_digest, "filter": filter_digest, "config": config_digest, "model": model_digest},
1717 "execution": {"cache": "bypassed", "candidate_backend": receipt.candidate_backend, "exactness": exactness, "artifact_generation_id": receipt.artifact_generation_id},
1718 "ordered_results": ordered_results, "results": results,
1719 "stage_outcomes": {
1720 "authority_snapshot": {"outcome": "Applied", "degradation": null},
1721 "hybrid_retrieval": {"outcome": if matches!(retrieval_mode, RetrievalModeParam::Hybrid) { "Applied" } else { "Skipped" }, "degradation": null},
1722 "selected_retrieval": {"outcome": "Applied", "degradation": null, "mode": retrieval_mode_name},
1723 "receipt_persistence": {"outcome": "Applied", "degradation": null},
1724 "cache": {"outcome": "Skipped", "degradation": "witnessed retrieval bypasses cache"},
1725 "replay": if complete_replay_available {
1726 serde_json::json!({"outcome": "Applied", "degradation": null})
1727 } else {
1728 serde_json::json!({"outcome": "AnalysisOnly", "degradation": "complete replay inputs are not available"})
1729 }
1730 },
1731 "degradations": receipt.degradations,
1732 "complete_replay_available": complete_replay_available
1733 }))
1734 }
1735
1736 #[tool(
1737 description = "Search with proof-debt analysis. Runs a standard search, then for each result checks the claim-ledger trust index for support state. Returns results plus a proof_debt summary with total debt weight, unsupported count, and gate decision.",
1738 annotations(read_only_hint = true)
1739 )]
1740 fn sm_search_proof_debt(
1741 &self,
1742 Parameters(SearchProofDebtParams {
1743 query,
1744 top_k,
1745 namespaces,
1746 budget_micros,
1747 }): Parameters<SearchProofDebtParams>,
1748 ) -> Result<Json<StructuredOutput>, ErrorData> {
1749 let k = top_k.map(|v| v as usize).unwrap_or(5);
1750 let store = &self.bridge.store;
1751 let ns: Option<Vec<&str>> = namespaces
1752 .as_ref()
1753 .map(|v| v.iter().map(String::as_str).collect());
1754
1755 let results = tokio::task::block_in_place(|| {
1756 Handle::current().block_on(store.search(&query, Some(k), ns.as_deref(), None))
1757 })
1758 .map_err(|e| ErrorData::internal_error(format!("search failed: {e}"), None))?;
1759
1760 #[cfg(feature = "claim-integration")]
1767 {
1768 use claim_ledger::{
1769 budget_for_claim, evaluate_proof_debt_gate_with_config, total_proof_debt_weight,
1770 ProofDebtBudgetConfig,
1771 };
1772 let budget = budget_micros.unwrap_or(500_000);
1773 let idx = self.claim_trust.lock().unwrap();
1774 let mut all_debts = Vec::new();
1775 let mut unsupported_count = 0usize;
1776 let mut per_result = Vec::new();
1777
1778 for r in &results {
1779 let fact_id = r.source.result_id();
1780 let bare_id = fact_id.strip_prefix("fact:").unwrap_or(&fact_id);
1781 let trust = idx.trust_for_fact(bare_id);
1782 let (claim_id, debts) = match idx.claim_and_state_for_fact(bare_id) {
1783 Some((claim_id, state)) => {
1784 let debts = proof_debts_for_support_state(
1785 state.unwrap_or(claim_ledger::SupportState::Unknown),
1786 );
1787 (Some(claim_id), debts)
1788 }
1789 None => (None, Vec::new()),
1790 };
1791 if !debts.is_empty() {
1792 unsupported_count += 1;
1793 }
1794 let debt_micros = total_proof_debt_weight(&debts);
1795 all_debts.extend(debts);
1796 per_result.push(serde_json::json!({
1797 "fact_id": fact_id,
1798 "claim_id": claim_id,
1799 "trust": trust,
1800 "debt_micros": debt_micros,
1801 }));
1802 }
1803
1804 let config = ProofDebtBudgetConfig::default();
1805 let total_debt = total_proof_debt_weight(&all_debts);
1806 let (proof_budget, debits) =
1807 budget_for_claim("sm_search_proof_debt", &all_debts, budget);
1808 let gate = evaluate_proof_debt_gate_with_config(&proof_budget, &config);
1809
1810 json_to_output(&serde_json::json!({
1811 "ok": true,
1812 "query": query,
1813 "results": per_result,
1814 "count": results.len(),
1815 "proof_debt": {
1816 "total_debt_micros": total_debt,
1817 "unsupported_count": unsupported_count,
1818 "budget_micros": budget,
1819 "consumed_pct": gate.consumed_pct,
1820 "exhausted": gate.exhausted,
1821 "gate_decision": gate.decision,
1822 "gate_summary": gate.summary,
1823 "debit_count": debits.len(),
1824 },
1825 "receipt": mcp_receipt("sm_search_proof_debt"),
1826 }))
1827 }
1828 #[cfg(not(feature = "claim-integration"))]
1829 {
1830 json_to_output(&serde_json::json!({
1831 "ok": true,
1832 "query": query,
1833 "results": results.iter().map(|r| serde_json::json!({
1834 "fact_id": r.source.result_id(),
1835 "content": r.content,
1836 "trust": "persisted_unjudged",
1837 })).collect::<Vec<_>>(),
1838 "count": results.len(),
1839 "proof_debt": {
1840 "total_debt_micros": 0,
1841 "unsupported_count": 0,
1842 "budget_micros": budget_micros.unwrap_or(500_000),
1843 "gate_decision": "Pass",
1844 "note": "claim-integration feature not enabled",
1845 },
1846 "receipt": mcp_receipt("sm_search_proof_debt"),
1847 }))
1848 }
1849 }
1850
1851 #[tool(
1852 description = "Benchmark trust quality across search results. Runs multiple queries and measures the trust distribution (supported/partially_supported/unsupported/contradicted/heuristic_only/persisted_unjudged) of returned results. Shows what fraction of retrieved facts have claim-ledger backing vs unjudged.",
1853 annotations(read_only_hint = true)
1854 )]
1855 fn sm_benchmark_trust(
1856 &self,
1857 Parameters(BenchmarkTrustParams {
1858 query_count,
1859 top_k,
1860 namespaces,
1861 }): Parameters<BenchmarkTrustParams>,
1862 ) -> Result<Json<StructuredOutput>, ErrorData> {
1863 let n = query_count.map(|v| v as usize).unwrap_or(10);
1864 let k = top_k.map(|v| v as usize).unwrap_or(5);
1865 let store = &self.bridge.store;
1866 let ns: Option<Vec<&str>> = namespaces
1867 .as_ref()
1868 .map(|v| v.iter().map(String::as_str).collect());
1869
1870 let facts =
1872 tokio::task::block_in_place(|| Handle::current().block_on(store.list_facts("", n, 0)))
1873 .map_err(|e| ErrorData::internal_error(format!("list_facts failed: {e}"), None))?;
1874
1875 #[cfg(feature = "claim-integration")]
1876 {
1877 let idx = self.claim_trust.lock().unwrap();
1878 let mut trust_counts: HashMap<String, usize> = HashMap::new();
1879 for label in &[
1880 "supported",
1881 "partially_supported",
1882 "unsupported",
1883 "contradicted",
1884 "heuristic_only",
1885 "persisted_unjudged",
1886 ] {
1887 trust_counts.insert(label.to_string(), 0);
1888 }
1889
1890 let mut total_results = 0usize;
1891 let mut per_query = Vec::new();
1892
1893 for fact in &facts {
1894 let query = &fact.content;
1895 let results = tokio::task::block_in_place(|| {
1896 Handle::current().block_on(store.search(query, Some(k), ns.as_deref(), None))
1897 })
1898 .unwrap_or_default();
1899
1900 let mut query_trust: HashMap<String, usize> = HashMap::new();
1901 for r in &results {
1902 let bare_id = r.source.result_id();
1903 let bare = bare_id.strip_prefix("fact:").unwrap_or(&bare_id);
1904 let trust = idx.trust_for_fact(bare);
1905 *trust_counts.entry(trust.clone()).or_insert(0) += 1;
1906 *query_trust.entry(trust.clone()).or_insert(0) += 1;
1907 total_results += 1;
1908 }
1909 per_query.push(serde_json::json!({
1910 "query": query,
1911 "result_count": results.len(),
1912 "trust_distribution": query_trust,
1913 }));
1914 }
1915
1916 json_to_output(&serde_json::json!({
1917 "ok": true,
1918 "queries_run": facts.len(),
1919 "top_k": k,
1920 "total_results": total_results,
1921 "trust_distribution": trust_counts,
1922 "judged_pct": if total_results > 0 {
1923 let judged = trust_counts.get("supported").copied().unwrap_or(0)
1924 + trust_counts.get("partially_supported").copied().unwrap_or(0)
1925 + trust_counts.get("contradicted").copied().unwrap_or(0)
1926 + trust_counts.get("unsupported").copied().unwrap_or(0);
1927 (judged as f64 / total_results as f64) * 100.0
1928 } else { 0.0 },
1929 "per_query": per_query,
1930 "receipt": mcp_receipt("sm_benchmark_trust"),
1931 }))
1932 }
1933 #[cfg(not(feature = "claim-integration"))]
1934 {
1935 json_to_output(&serde_json::json!({
1936 "ok": true,
1937 "queries_run": facts.len(),
1938 "top_k": k,
1939 "trust_distribution": {"persisted_unjudged": facts.len()},
1940 "judged_pct": 0.0,
1941 "note": "claim-integration feature not enabled",
1942 "receipt": mcp_receipt("sm_benchmark_trust"),
1943 }))
1944 }
1945 }
1946
1947 #[tool(
1948 description = "Return the canonical typed origin-authority decision receipt for asserting a fact. The purpose is fixed to assertion; recall authority is not reused. This read-only decision surface never returns memory content.",
1949 annotations(read_only_hint = true)
1950 )]
1951 fn sm_decide_assertion_authority(
1952 &self,
1953 Parameters(params): Parameters<GovernedDecisionParams>,
1954 ) -> Result<Json<StructuredOutput>, ErrorData> {
1955 self.decide_governed_authority(params, semantic_memory::GovernedAccessPurposeV1::Assertion)
1956 }
1957
1958 #[tool(
1959 description = "Return the canonical typed origin-authority decision receipt for acting on a fact. The purpose is fixed to action; recall or assertion authority is not reused. This read-only decision surface never returns memory content or performs the action.",
1960 annotations(read_only_hint = true)
1961 )]
1962 fn sm_decide_action_authority(
1963 &self,
1964 Parameters(params): Parameters<GovernedDecisionParams>,
1965 ) -> Result<Json<StructuredOutput>, ErrorData> {
1966 self.decide_governed_authority(params, semantic_memory::GovernedAccessPurposeV1::Action)
1967 }
1968
1969 #[allow(dead_code)]
1974 fn sm_search_explained(
1975 &self,
1976 Parameters(SearchExplainedParams { query, top_k }): Parameters<SearchExplainedParams>,
1977 ) -> Result<Json<StructuredOutput>, ErrorData> {
1978 let requested_k = top_k.map(|v| v as usize).unwrap_or(5);
1979 let allow_superseded = query_allows_superseded(&query);
1980 let search_k = if allow_superseded {
1981 requested_k
1982 } else {
1983 (requested_k * 4).max(20)
1984 };
1985 let store = &self.bridge.store;
1986 let result = tokio::task::block_in_place(|| {
1987 Handle::current().block_on(store.search_explained(&query, Some(search_k), None, None))
1988 });
1989
1990 match result {
1991 Ok(results) => {
1992 let superseded_targets = if allow_superseded {
1993 HashSet::new()
1994 } else {
1995 load_superseded_targets(store)?
1996 };
1997 let fresh_results: Vec<_> = results
1998 .iter()
1999 .filter(|r| !superseded_targets.contains(&r.result.source.result_id()))
2000 .collect();
2001 let result_refs: Vec<_> = if superseded_targets.is_empty() {
2002 results.iter().collect()
2003 } else {
2004 fresh_results
2005 };
2006 let superseded_filtered_count = results.len().saturating_sub(result_refs.len());
2007 let json_results: Vec<serde_json::Value> = result_refs
2008 .iter()
2009 .take(requested_k)
2010 .map(|r| {
2011 serde_json::json!({
2012 "result_id": r.result.source.result_id(),
2013 "content": r.result.content,
2014 "source": format!("{:?}", r.result.source),
2015 "score": r.result.score,
2016 "bm25_rank": r.result.bm25_rank,
2017 "vector_rank": r.result.vector_rank,
2018 "cosine_similarity": r.result.cosine_similarity,
2019 "breakdown": {
2020 "rrf_score": r.breakdown.rrf_score,
2021 "bm25_score": r.breakdown.bm25_score,
2022 "vector_score": r.breakdown.vector_score,
2023 "recency_score": r.breakdown.recency_score,
2024 "bm25_rank": r.breakdown.bm25_rank,
2025 "vector_rank": r.breakdown.vector_rank,
2026 "vector_source_rank": r.breakdown.vector_source_rank,
2027 "vector_source_score": r.breakdown.vector_source_score,
2028 "bm25_contribution": r.breakdown.bm25_contribution,
2029 "vector_contribution": r.breakdown.vector_contribution,
2030 "vector_reranked_from_f32": r.breakdown.vector_reranked_from_f32,
2031 "bm25_weight": r.breakdown.bm25_weight,
2032 "vector_weight": r.breakdown.vector_weight,
2033 "recency_weight": r.breakdown.recency_weight,
2034 "rrf_k": r.breakdown.rrf_k,
2035 },
2036 })
2037 })
2038 .collect();
2039 json_to_output(&serde_json::json!({
2040 "ok": true,
2041 "results": json_results,
2042 "count": json_results.len(),
2043 "superseded_filtered_count": superseded_filtered_count,
2044 }))
2045 }
2046 Err(e) => Err(ErrorData::internal_error(
2047 format!("Search error: {e}"),
2048 None,
2049 )),
2050 }
2051 }
2052
2053 #[tool(
2054 description = "Add a fact to the knowledge base. Embedded and indexed for semantic search. Returns fact ID and content digest.",
2055 annotations(idempotent_hint = true)
2056 )]
2057 fn sm_add_fact(
2058 &self,
2059 Parameters(AddFactParams {
2060 content,
2061 namespace,
2062 source,
2063 extract_entities,
2064 memory_kind,
2065 sensitivity,
2066 evidence_refs,
2067 idempotency_key,
2068 }): Parameters<AddFactParams>,
2069 ) -> Result<Json<StructuredOutput>, ErrorData> {
2070 let store = &self.bridge.store;
2071
2072 let sens = sensitivity.unwrap_or_else(|| "internal".to_string());
2074 let kind = memory_kind.unwrap_or_else(|| "durable_fact".to_string());
2075
2076 if sens == "confidential" || sens == "restricted" {
2078 return Err(ErrorData::invalid_params(
2079 format!("Admission gate BLOCKED: sensitivity='{sens}' content cannot be stored without explicit user request"),
2080 None,
2081 ));
2082 }
2083
2084 let explicit_evidence: Vec<String> = evidence_refs
2086 .as_deref()
2087 .unwrap_or_default()
2088 .iter()
2089 .filter(|reference| !reference.trim().is_empty())
2090 .cloned()
2091 .collect();
2092 if kind == "ephemeral_inference" && explicit_evidence.is_empty() {
2093 return Err(ErrorData::invalid_params(
2094 "Admission gate BLOCKED: ephemeral_inference requires evidence_refs to promote to durable".to_string(),
2095 None,
2096 ));
2097 }
2098
2099 let mut authority_evidence = explicit_evidence;
2100 if let Some(source_ref) = source.as_ref().filter(|value| !value.trim().is_empty()) {
2101 if !authority_evidence.contains(source_ref) {
2102 authority_evidence.push(source_ref.clone());
2103 }
2104 }
2105
2106 let mut meta = serde_json::Map::new();
2108 meta.insert("memory_kind".to_string(), serde_json::json!(kind));
2109 meta.insert("sensitivity".to_string(), serde_json::json!(sens));
2110 if let Some(refs) = evidence_refs {
2111 meta.insert("evidence_refs".to_string(), serde_json::json!(refs));
2112 }
2113 let _metadata_str = serde_json::to_string(&serde_json::Value::Object(meta)).ok();
2114
2115 let caller_idempotency_key = match idempotency_key {
2116 Some(key) if !key.trim().is_empty() => key,
2117 Some(_) => {
2118 return Err(ErrorData::invalid_params(
2119 "idempotency_key must not be blank".to_string(),
2120 None,
2121 ))
2122 }
2123 None => format!("mcp-sm-add-fact:{}", uuid::Uuid::new_v4()),
2124 };
2125 let origin = if authority_evidence.is_empty() {
2126 semantic_memory::OriginAuthorityLabelV1::operator_system(
2127 "principal:semantic-memory-mcp",
2128 "caller:sm_add_fact",
2129 )
2130 } else {
2131 semantic_memory::OriginAuthorityLabelV1::new(
2132 semantic_memory::OriginClassV1::ExternalEvidence,
2133 "principal:semantic-memory-mcp",
2134 "caller:sm_add_fact",
2135 format!(
2136 "blake3:{}",
2137 blake3::hash(authority_evidence.join("\n").as_bytes()).to_hex()
2138 ),
2139 semantic_memory::OriginRiskV1::Medium,
2140 semantic_memory::AuthorityScopesV1 {
2141 recall: semantic_memory::AuthorityScopeV1::Universal,
2142 assertion: semantic_memory::AuthorityScopeV1::Denied,
2143 action: semantic_memory::AuthorityScopeV1::Denied,
2144 },
2145 semantic_memory::ElevationRequirementV1::ExplicitOperatorApproval,
2146 None,
2147 semantic_memory::RevocationStatusV1::Active,
2148 vec!["principal:semantic-memory-mcp".into()],
2149 )
2150 .map_err(|error| {
2151 ErrorData::internal_error(format!("invalid origin label: {error}"), None)
2152 })?
2153 };
2154 let permit = if authority_evidence.is_empty() {
2155 semantic_memory::AuthorityPermit::operator_system(
2156 "principal:semantic-memory-mcp",
2157 "caller:sm_add_fact",
2158 semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
2159 )
2160 } else {
2161 semantic_memory::AuthorityPermit::with_evidence(
2162 "principal:semantic-memory-mcp",
2163 "caller:sm_add_fact",
2164 semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
2165 authority_evidence,
2166 )
2167 }
2168 .with_origin(origin);
2169
2170 let result = tokio::task::block_in_place(|| {
2171 Handle::current().block_on(store.authority().append(
2172 permit,
2173 caller_idempotency_key,
2174 namespace.clone(),
2175 content.clone(),
2176 source.clone(),
2177 ))
2178 });
2179
2180 match result {
2181 Ok(receipt) => {
2182 let id = receipt.affected_ids.first().cloned().ok_or_else(|| {
2183 ErrorData::internal_error(
2184 "authority append returned no affected fact id".to_string(),
2185 None,
2186 )
2187 })?;
2188 self.auto_link_fact_to_claims(&id, &content);
2191 if extract_entities == Some(true) {
2193 let prompt = format!(
2194 "Extract entities from this text as JSON. Format: {{\"entities\": [{{\"name\": \"...\", \"type\": \"person|project|concept|tool|version|path\"}}]}}\nText: {content}\nJSON:"
2195 );
2196 let body = serde_json::json!({
2197 "model": "granite4.1:3b",
2198 "prompt": prompt,
2199 "stream": false,
2200 "options": {"temperature": 0, "num_predict": 200}
2201 });
2202 if let Ok(resp) = reqwest::blocking::Client::new()
2203 .post("http://127.0.0.1:11434/api/generate")
2204 .json(&body)
2205 .send()
2206 {
2207 if let Ok(v) = resp.json::<serde_json::Value>() {
2208 if let Some(response_str) = v.get("response").and_then(|r| r.as_str()) {
2209 let parsed_result =
2211 boundary_compiler::parse_with_dup_check(response_str.trim());
2212 if let Ok(parsed) = parsed_result {
2213 if let Some(entities) =
2214 parsed.get("entities").and_then(|e| e.as_array())
2215 {
2216 let fact_node = format!("fact:{id}");
2217 for entity in entities {
2218 if let Some(name) =
2219 entity.get("name").and_then(|n| n.as_str())
2220 {
2221 let entity_node = format!("entity:{name}");
2222 let _ = tokio::task::block_in_place(|| {
2223 Handle::current()
2224 .block_on(store.add_graph_edge(
2225 &fact_node,
2226 &entity_node,
2227 semantic_memory::GraphEdgeType::Entity {
2228 relation: "mentions".to_string(),
2229 },
2230 1.0,
2231 None,
2232 ))
2233 });
2234 }
2235 }
2236 }
2237 }
2238 }
2239 }
2240 }
2241 }
2242
2243 json_to_output(&serde_json::json!({
2244 "ok": true,
2245 "fact_id": id,
2246 "namespace": namespace,
2247 "receipt": mcp_receipt("sm_add_fact"),
2248 "message": "Fact added successfully",
2249 }))
2250 }
2251 Err(e) => Err(ErrorData::internal_error(
2252 format!("Error adding fact: {e}"),
2253 None,
2254 )),
2255 }
2256 }
2257
2258 #[tool(
2259 description = "Ingest a document with automatic chunking. Splits into chunks, each embedded and indexed. Returns document ID and chunk count.",
2260 annotations(idempotent_hint = true)
2261 )]
2262 fn sm_ingest_document(
2263 &self,
2264 Parameters(IngestDocumentParams {
2265 content,
2266 title,
2267 namespace,
2268 }): Parameters<IngestDocumentParams>,
2269 ) -> Result<Json<StructuredOutput>, ErrorData> {
2270 let store = &self.bridge.store;
2271 let result = tokio::task::block_in_place(|| {
2272 Handle::current()
2273 .block_on(store.ingest_document(&title, &content, &namespace, None, None))
2274 });
2275
2276 match result {
2277 Ok(doc_id) => {
2278 let chunk_count = tokio::task::block_in_place(|| {
2279 Handle::current().block_on(store.count_chunks_for_document(&doc_id))
2280 })
2281 .unwrap_or(0);
2282 json_to_output(&serde_json::json!({
2283 "ok": true,
2284 "receipt": mcp_receipt("sm_ingest_document"),
2285 "document_id": doc_id,
2286 "title": title,
2287 "chunk_count": chunk_count,
2288 "message": "Document ingested successfully",
2289 }))
2290 }
2291 Err(e) => Err(ErrorData::internal_error(
2292 format!("Error ingesting document: {e}"),
2293 None,
2294 )),
2295 }
2296 }
2297
2298 #[tool(
2299 description = "Get knowledge base statistics: fact/chunk/document/session counts, DB size, embedding model, and graph edge count.",
2300 annotations(read_only_hint = true)
2301 )]
2302 fn sm_stats(&self) -> Result<Json<StatsOutput>, ErrorData> {
2303 let store = &self.bridge.store;
2304 let core = tokio::task::block_in_place(|| Handle::current().block_on(store.stats()));
2305 let graph = tokio::task::block_in_place(|| {
2306 Handle::current().block_on(store.list_all_graph_edges())
2307 });
2308 let core_health = match &core {
2309 Ok(_) => serde_json::json!({"health": "healthy", "error": null}),
2310 Err(e) => serde_json::json!({"health": "error", "error": e.to_string()}),
2311 };
2312 let graph_health = match &graph {
2313 Ok(_) => serde_json::json!({"health": "healthy", "error": null}),
2314 Err(e) => serde_json::json!({"health": "error", "error": e.to_string()}),
2315 };
2316 let core_value = core.ok();
2317 let graph_count = graph.ok().map(|edges| edges.len());
2318 Ok(Json(StatsOutput {
2319 ok: core_value.is_some() && graph_count.is_some(),
2320 components: serde_json::json!({"core": core_health, "graph": graph_health}),
2321 facts: core_value.as_ref().map(|s| s.total_facts),
2322 chunks: core_value.as_ref().map(|s| s.total_chunks),
2323 documents: core_value.as_ref().map(|s| s.total_documents),
2324 sessions: core_value.as_ref().map(|s| s.total_sessions),
2325 messages: core_value.as_ref().map(|s| s.total_messages),
2326 graph_edges: graph_count,
2327 db_size_bytes: core_value.as_ref().map(|s| s.database_size_bytes),
2328 db_size_mb: core_value
2329 .as_ref()
2330 .map(|s| (s.database_size_bytes as f64 / 1_048_576.0 * 100.0).round() / 100.0),
2331 embedding_model: core_value.as_ref().and_then(|s| s.embedding_model.clone()),
2332 embedding_dimensions: core_value.as_ref().and_then(|s| s.embedding_dimensions),
2333 }))
2334 }
2335
2336 #[tool(
2337 description = "Find shortest path between two items in the knowledge graph. Traverses all edge types. Returns node IDs with edge evidence per hop.",
2338 annotations(read_only_hint = true)
2339 )]
2340 fn sm_graph_path(
2341 &self,
2342 Parameters(GraphPathParams {
2343 from_id,
2344 to_id,
2345 max_depth,
2346 }): Parameters<GraphPathParams>,
2347 ) -> Result<Json<StructuredOutput>, ErrorData> {
2348 let depth = max_depth.map(|v| v as usize).unwrap_or(5);
2349 let store = &self.bridge.store;
2350 let g = store.graph_view();
2351
2352 match typed_graph_path(g.as_ref(), &from_id, &to_id, depth) {
2353 Ok(GraphPathOutcome::Found(path)) => {
2354 let path_segments = build_path_segments(store, &path);
2356 json_to_output(&serde_json::json!({
2357 "ok": true,
2358 "outcome": "Found",
2359 "from": from_id,
2360 "to": to_id,
2361 "path": path,
2362 "path_length": path.len(),
2363 "segments": path_segments,
2364 }))
2365 }
2366 Ok(GraphPathOutcome::NoPathWithinCompleteSearch) => {
2367 json_to_output(&serde_json::json!({
2368 "ok": true,
2369 "outcome": "NoPathWithinCompleteSearch",
2370 "from": from_id,
2371 "to": to_id,
2372 "path": null,
2373 "message": format!("No path found from {from_id} to {to_id} within depth {depth}"),
2374 }))
2375 }
2376 Ok(GraphPathOutcome::BudgetExceeded) => json_to_output(&serde_json::json!({
2377 "ok": false, "outcome": "BudgetExceeded", "from": from_id, "to": to_id,
2378 "path": null, "budget": {"max_depth": depth}
2379 })),
2380 Ok(GraphPathOutcome::InvalidEndpoint(endpoint)) => json_to_output(&serde_json::json!({
2381 "ok": false, "outcome": "InvalidEndpoint", "invalid_endpoint": endpoint,
2382 "from": from_id, "to": to_id, "path": null
2383 })),
2384 Err(e) => Err(ErrorData::internal_error(
2385 format!("Graph view error: {e}"),
2386 None,
2387 )),
2388 }
2389 }
2390
2391 #[tool(
2394 description = "Fetch one fact by id (bare UUID or prefixed 'fact:<uuid>'). Returns full content, namespace, source, timestamps, and metadata.",
2395 annotations(read_only_hint = true)
2396 )]
2397 fn sm_get_fact(
2398 &self,
2399 Parameters(GetFactParams { fact_id }): Parameters<GetFactParams>,
2400 ) -> Result<Json<StructuredOutput>, ErrorData> {
2401 let bare = fact_id
2402 .strip_prefix("fact:")
2403 .unwrap_or(&fact_id)
2404 .to_string();
2405 let store = &self.bridge.store;
2406 let result =
2407 tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&bare)));
2408 match result {
2409 Ok(Some(f)) => json_to_output(&serde_json::json!({
2410 "ok": true,
2411 "found": true,
2412 "fact": {
2413 "result_id": format!("fact:{}", f.id),
2414 "id": f.id,
2415 "namespace": f.namespace,
2416 "content": f.content,
2417 "source": f.source,
2418 "created_at": f.created_at,
2419 "updated_at": f.updated_at,
2420 "metadata": f.metadata,
2421 },
2422 })),
2423 Ok(None) => json_to_output(&serde_json::json!({
2424 "ok": true,
2425 "found": false,
2426 "message": format!("No fact with id '{fact_id}'"),
2427 })),
2428 Err(e) => Err(ErrorData::internal_error(
2429 format!("get_fact error: {e}"),
2430 None,
2431 )),
2432 }
2433 }
2434
2435 #[tool(
2436 description = "Enumerate facts in a namespace (newest first) with pagination. Exhaustive, not similarity-ranked — for browsing, auditing, or deduping.",
2437 annotations(read_only_hint = true)
2438 )]
2439 fn sm_list_facts(
2440 &self,
2441 Parameters(ListFactsParams {
2442 namespace,
2443 limit,
2444 offset,
2445 }): Parameters<ListFactsParams>,
2446 ) -> Result<Json<StructuredOutput>, ErrorData> {
2447 let lim = limit.map(|v| v as usize).unwrap_or(50);
2448 let off = offset.map(|v| v as usize).unwrap_or(0);
2449 let store = &self.bridge.store;
2450 let result = tokio::task::block_in_place(|| {
2451 Handle::current().block_on(store.list_facts(&namespace, lim, off))
2452 });
2453 match result {
2454 Ok(facts) => {
2455 let arr: Vec<serde_json::Value> = facts
2456 .iter()
2457 .map(|f| {
2458 serde_json::json!({
2459 "result_id": format!("fact:{}", f.id),
2460 "id": f.id,
2461 "namespace": f.namespace,
2462 "content": f.content,
2463 "source": f.source,
2464 "updated_at": f.updated_at,
2465 })
2466 })
2467 .collect();
2468 json_to_output(&serde_json::json!({
2469 "ok": true,
2470 "namespace": namespace,
2471 "count": arr.len(),
2472 "limit": lim,
2473 "offset": off,
2474 "facts": arr,
2475 }))
2476 }
2477 Err(e) => Err(ErrorData::internal_error(
2478 format!("list_facts error: {e}"),
2479 None,
2480 )),
2481 }
2482 }
2483
2484 #[tool(
2485 description = "List namespaces that currently contain facts. Use before sm_list_facts to discover what is stored.",
2486 annotations(read_only_hint = true)
2487 )]
2488 fn sm_list_namespaces(&self) -> Result<Json<StructuredOutput>, ErrorData> {
2489 let store = &self.bridge.store;
2490 let result = tokio::task::block_in_place(|| {
2491 Handle::current().block_on(store.list_fact_namespaces())
2492 });
2493 match result {
2494 Ok(ns) => json_to_output(&serde_json::json!({
2495 "ok": true,
2496 "count": ns.len(),
2497 "namespaces": ns,
2498 })),
2499 Err(e) => Err(ErrorData::internal_error(
2500 format!("list_namespaces error: {e}"),
2501 None,
2502 )),
2503 }
2504 }
2505
2506 #[tool(
2507 description = "Fetch a fact plus its graph neighbors WITH their content in one call. Hydrates neighbor facts for ids returned by graph tools.",
2508 annotations(read_only_hint = true)
2509 )]
2510 fn sm_get_fact_neighbors(
2511 &self,
2512 Parameters(GetFactNeighborsParams { item_id }): Parameters<GetFactNeighborsParams>,
2513 ) -> Result<Json<StructuredOutput>, ErrorData> {
2514 let node_id = if item_id.contains(':') {
2515 item_id.clone()
2516 } else {
2517 format!("fact:{item_id}")
2518 };
2519 let bare = node_id
2520 .strip_prefix("fact:")
2521 .unwrap_or(&node_id)
2522 .to_string();
2523 let store = &self.bridge.store;
2524
2525 let center =
2526 tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&bare)))
2527 .map_err(|e| ErrorData::internal_error(format!("get_fact error: {e}"), None))?;
2528 let edges = tokio::task::block_in_place(|| {
2529 Handle::current().block_on(store.list_graph_edges_for_node(&node_id))
2530 })
2531 .map_err(|e| ErrorData::internal_error(format!("list edges error: {e}"), None))?;
2532
2533 let mut neighbors: Vec<serde_json::Value> = Vec::new();
2534 for e in &edges {
2535 let outgoing = e.source == node_id;
2536 let other = if outgoing { &e.target } else { &e.source };
2537 let other_bare = other.strip_prefix("fact:").unwrap_or(other).to_string();
2538 let content = tokio::task::block_in_place(|| {
2539 Handle::current().block_on(store.get_fact(&other_bare))
2540 })
2541 .ok()
2542 .flatten()
2543 .map(|f| f.content);
2544 neighbors.push(serde_json::json!({
2545 "neighbor_id": other,
2546 "direction": if outgoing { "out" } else { "in" },
2547 "edge_type": e.edge_type,
2548 "weight": e.weight,
2549 "content": content,
2550 }));
2551 }
2552 json_to_output(&serde_json::json!({
2553 "ok": true,
2554 "item_id": node_id,
2555 "center_content": center.map(|f| f.content),
2556 "neighbor_count": neighbors.len(),
2557 "neighbors": neighbors,
2558 }))
2559 }
2560
2561 #[tool(
2562 description = "Create a replacement fact and link it to a stale fact via 'supersedes' edge. Use instead of deleting outdated facts. Returns new fact id and edge id.",
2563 annotations(idempotent_hint = true)
2564 )]
2565 fn sm_supersede_fact(
2566 &self,
2567 Parameters(SupersedeFactParams {
2568 old_fact_id,
2569 content,
2570 namespace,
2571 source,
2572 reason,
2573 }): Parameters<SupersedeFactParams>,
2574 ) -> Result<Json<StructuredOutput>, ErrorData> {
2575 use semantic_memory::GraphEdgeType;
2576
2577 let old_bare = old_fact_id
2578 .strip_prefix("fact:")
2579 .unwrap_or(&old_fact_id)
2580 .to_string();
2581 let old_node = format!("fact:{old_bare}");
2582 let store = &self.bridge.store;
2583 let old =
2584 tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&old_bare)))
2585 .map_err(|e| ErrorData::internal_error(format!("get old fact error: {e}"), None))?;
2586 let Some(old_fact) = old else {
2587 return Err(ErrorData::invalid_params(
2588 format!("No fact with id '{old_fact_id}'"),
2589 None,
2590 ));
2591 };
2592
2593 let ns = namespace.unwrap_or_else(|| old_fact.namespace.clone());
2594 let new_id = tokio::task::block_in_place(|| {
2595 Handle::current().block_on(store.add_fact(&ns, &content, source.as_deref(), None))
2596 })
2597 .map_err(|e| ErrorData::internal_error(format!("add replacement fact error: {e}"), None))?;
2598 let new_node = format!("fact:{new_id}");
2599 let metadata = serde_json::json!({
2600 "reason": reason.unwrap_or_else(|| "replacement fact supersedes stale fact".to_string()),
2601 "old_fact_id": old_bare,
2602 });
2603 let edge = tokio::task::block_in_place(|| {
2604 Handle::current().block_on(store.add_graph_edge(
2605 &new_node,
2606 &old_node,
2607 GraphEdgeType::Entity {
2608 relation: "supersedes".to_string(),
2609 },
2610 1.0,
2611 Some(metadata),
2612 ))
2613 })
2614 .map_err(|e| ErrorData::internal_error(format!("add supersedes edge error: {e}"), None))?;
2615
2616 json_to_output(&serde_json::json!({
2617 "ok": true,
2618 "receipt": mcp_receipt("sm_supersede_fact"),
2619 "new_fact_id": new_id,
2620 "new_result_id": new_node,
2621 "old_fact_id": old_bare,
2622 "old_result_id": old_node,
2623 "namespace": ns,
2624 "edge_id": edge.id,
2625 "relation": "supersedes",
2626 }))
2627 }
2628
2629 #[allow(dead_code)]
2636 fn sm_create_session(
2637 &self,
2638 Parameters(CreateSessionParams { channel, metadata }): Parameters<CreateSessionParams>,
2639 ) -> Result<Json<StructuredOutput>, ErrorData> {
2640 let meta: Option<serde_json::Value> = metadata
2641 .as_deref()
2642 .and_then(|s| serde_json::from_str(s).ok());
2643 let store = &self.bridge.store;
2644 let result = tokio::task::block_in_place(|| {
2645 Handle::current().block_on(store.create_session_with_metadata(&channel, meta))
2646 });
2647 match result {
2648 Ok(id) => json_to_output(
2649 &serde_json::json!({"ok": true, "session_id": id, "channel": channel, "receipt": mcp_receipt("sm_create_session")}),
2650 ),
2651 Err(e) => Err(ErrorData::internal_error(
2652 format!("create_session error: {e}"),
2653 None,
2654 )),
2655 }
2656 }
2657
2658 #[allow(dead_code)]
2662 fn sm_add_message(
2663 &self,
2664 Parameters(AddMessageParams {
2665 session_id,
2666 role,
2667 content,
2668 }): Parameters<AddMessageParams>,
2669 ) -> Result<Json<StructuredOutput>, ErrorData> {
2670 let parsed_role = match role.to_lowercase().as_str() {
2671 "user" => semantic_memory::types::Role::User,
2672 "assistant" => semantic_memory::types::Role::Assistant,
2673 "system" => semantic_memory::types::Role::System,
2674 "tool" => semantic_memory::types::Role::Tool,
2675 other => {
2676 return Err(ErrorData::invalid_params(
2677 format!("invalid role '{other}' (use user|assistant|system|tool)"),
2678 None,
2679 ))
2680 }
2681 };
2682 let store = &self.bridge.store;
2683 let result = tokio::task::block_in_place(|| {
2684 Handle::current().block_on(store.add_message_embedded(
2685 &session_id,
2686 parsed_role,
2687 &content,
2688 None,
2689 None,
2690 ))
2691 });
2692 match result {
2693 Ok(id) => json_to_output(
2694 &serde_json::json!({"ok": true, "message_id": id, "session_id": session_id, "receipt": mcp_receipt("sm_add_message")}),
2695 ),
2696 Err(e) => Err(ErrorData::internal_error(
2697 format!("add_message error: {e}"),
2698 None,
2699 )),
2700 }
2701 }
2702
2703 #[tool(
2704 description = "List recent conversation sessions (newest first) with message counts.",
2705 annotations(read_only_hint = true)
2706 )]
2707 fn sm_list_sessions(
2708 &self,
2709 Parameters(ListSessionsParams { limit, offset }): Parameters<ListSessionsParams>,
2710 ) -> Result<Json<StructuredOutput>, ErrorData> {
2711 let lim = limit.map(|v| v as usize).unwrap_or(20);
2712 let off = offset.map(|v| v as usize).unwrap_or(0);
2713 let store = &self.bridge.store;
2714 let result = tokio::task::block_in_place(|| {
2715 Handle::current().block_on(store.list_sessions(lim, off))
2716 });
2717 match result {
2718 Ok(sessions) => json_to_output(&serde_json::json!({
2719 "ok": true,
2720 "count": sessions.len(),
2721 "sessions": sessions.iter().map(|s| serde_json::json!({
2722 "session_id": s.id,
2723 "channel": s.channel,
2724 "message_count": s.message_count,
2725 "created_at": s.created_at,
2726 "updated_at": s.updated_at,
2727 })).collect::<Vec<_>>(),
2728 })),
2729 Err(e) => Err(ErrorData::internal_error(
2730 format!("list_sessions error: {e}"),
2731 None,
2732 )),
2733 }
2734 }
2735
2736 #[tool(
2737 description = "Get most recent messages from a session within a token budget (default 4000), chronological order. Returns role, content, timestamps.",
2738 annotations(read_only_hint = true)
2739 )]
2740 fn sm_get_messages(
2741 &self,
2742 Parameters(GetMessagesParams {
2743 session_id,
2744 max_tokens,
2745 }): Parameters<GetMessagesParams>,
2746 ) -> Result<Json<StructuredOutput>, ErrorData> {
2747 let budget = max_tokens.unwrap_or(4000);
2748 let store = &self.bridge.store;
2749 let result = tokio::task::block_in_place(|| {
2750 Handle::current().block_on(store.get_messages_within_budget(&session_id, budget))
2751 });
2752 match result {
2753 Ok(msgs) => json_to_output(&serde_json::json!({
2754 "ok": true,
2755 "session_id": session_id,
2756 "count": msgs.len(),
2757 "messages": msgs.iter().map(|m| serde_json::json!({
2758 "id": m.id,
2759 "role": m.role,
2760 "content": m.content,
2761 "token_count": m.token_count,
2762 "created_at": m.created_at,
2763 })).collect::<Vec<_>>(),
2764 })),
2765 Err(e) => Err(ErrorData::internal_error(
2766 format!("get_messages error: {e}"),
2767 None,
2768 )),
2769 }
2770 }
2771
2772 #[tool(
2773 description = "Hybrid semantic search over stored conversation MESSAGES (not facts). Recall what was discussed in past sessions. Returns ranked messages.",
2774 annotations(read_only_hint = true)
2775 )]
2776 fn sm_search_conversations(
2777 &self,
2778 Parameters(SearchConversationsParams { query, top_k }): Parameters<
2779 SearchConversationsParams,
2780 >,
2781 ) -> Result<Json<StructuredOutput>, ErrorData> {
2782 let k = top_k.map(|v| v as usize);
2783 let store = &self.bridge.store;
2784 let result = tokio::task::block_in_place(|| {
2785 Handle::current().block_on(store.search_conversations(&query, k, None))
2786 });
2787 match result {
2788 Ok(results) => json_to_output(&serde_json::json!({
2789 "ok": true,
2790 "count": results.len(),
2791 "results": results.iter().map(|r| serde_json::json!({
2792 "result_id": r.source.result_id(),
2793 "content": r.content,
2794 "score": r.score,
2795 "cosine_similarity": r.cosine_similarity,
2796 })).collect::<Vec<_>>(),
2797 })),
2798 Err(e) => Err(ErrorData::internal_error(
2799 format!("search_conversations error: {e}"),
2800 None,
2801 )),
2802 }
2803 }
2804
2805 #[tool(
2812 description = "Profile a query and get an adaptive routing decision. Determines which retrieval stages (BM25, vector, rerank, graph, decoder, discord) to activate.",
2813 annotations(read_only_hint = true)
2814 )]
2815 fn sm_route_query(
2816 &self,
2817 Parameters(RouteQueryParams { query }): Parameters<RouteQueryParams>,
2818 ) -> Result<Json<StructuredOutput>, ErrorData> {
2819 use semantic_memory::rl_routing::{is_trained, route_with_policy};
2820 use semantic_memory::routing::{QueryProfile, RetrievalRouter};
2821
2822 let router = RetrievalRouter {
2823 decoder_enabled: true,
2824 discord_enabled: true,
2825 corpus_density: 0.5,
2826 ..Default::default()
2827 };
2828
2829 let profile = QueryProfile::from_query(&query);
2830 let policy = tokio::task::block_in_place(|| {
2831 Handle::current().block_on(self.bridge.store.load_routing_policy())
2832 })
2833 .map_err(|e| ErrorData::internal_error(format!("load routing policy error: {e}"), None))?;
2834 let (decision, routing_source) = match policy.as_ref().filter(|p| is_trained(p)) {
2835 Some(policy) => (route_with_policy(policy, &profile), "trained_policy"),
2836 None => (router.route(&profile), "heuristic"),
2837 };
2838 json_to_output(&serde_json::json!({
2839 "ok": true,
2840 "routing_source": routing_source,
2841 "bm25_coarse": decision.bm25_coarse,
2842 "vector_medium": decision.vector_medium,
2843 "rerank_fine": decision.rerank_fine,
2844 "graph_expansion": decision.graph_expansion,
2845 "decoder": decision.decoder,
2846 "discord": decision.discord,
2847 "no_retrieval": decision.no_retrieval,
2848 "reasoning": decision.reasoning,
2849 }))
2850 }
2851
2852 #[tool(
2853 description = "Adaptive search: profiles query, routes to appropriate stages, applies factor graph belief propagation if decoder is activated. Returns results with stable IDs.",
2854 annotations(read_only_hint = true)
2855 )]
2856 fn sm_search_with_routing(
2857 &self,
2858 Parameters(SearchWithRoutingParams {
2859 query,
2860 top_k,
2861 contradictions,
2862 group_by_community,
2863 }): Parameters<SearchWithRoutingParams>,
2864 ) -> Result<Json<StructuredOutput>, ErrorData> {
2865 use semantic_memory::integration::plan_execution;
2866 use semantic_memory::rl_routing::{is_trained, route_with_policy};
2867 use semantic_memory::routing::{QueryProfile, RetrievalRouter};
2868
2869 let k = top_k.map(|v| v as usize).unwrap_or(5);
2870 let allow_superseded = query_allows_superseded(&query);
2871 let search_k = if allow_superseded { k } else { (k * 4).max(20) };
2872
2873 let router = RetrievalRouter {
2874 decoder_enabled: true,
2875 discord_enabled: true,
2876 corpus_density: 0.5,
2877 ..Default::default()
2878 };
2879
2880 let store = &self.bridge.store;
2883 let policy =
2884 tokio::task::block_in_place(|| Handle::current().block_on(store.load_routing_policy()))
2885 .map_err(|e| {
2886 ErrorData::internal_error(format!("load routing policy error: {e}"), None)
2887 })?;
2888 let profile = QueryProfile::from_query(&query);
2889 let (decision, routing_source) = match policy.as_ref().filter(|p| is_trained(p)) {
2890 Some(policy) => (route_with_policy(policy, &profile), "trained_policy"),
2891 None => (router.route(&profile), "heuristic"),
2892 };
2893 let contras = contradictions.unwrap_or_default();
2894 let plan = plan_execution(&decision, contras.clone());
2895
2896 let store = &self.bridge.store;
2897 let search_result = tokio::task::block_in_place(|| {
2898 Handle::current().block_on(store.search(&query, Some(search_k), None, None))
2899 });
2900
2901 match search_result {
2902 Ok(results) => {
2903 let superseded_targets = if allow_superseded {
2904 HashSet::new()
2905 } else {
2906 load_superseded_targets(store)?
2907 };
2908 let fresh_results: Vec<_> = results
2909 .iter()
2910 .filter(|r| !superseded_targets.contains(&r.source.result_id()))
2911 .collect();
2912 let result_refs: Vec<_> = if superseded_targets.is_empty() {
2913 results.iter().collect()
2914 } else {
2915 fresh_results
2916 };
2917 let superseded_filtered_count = results.len().saturating_sub(result_refs.len());
2918 let json_results: Vec<serde_json::Value> = result_refs
2919 .iter()
2920 .take(k)
2921 .map(|r| {
2922 serde_json::json!({
2923 "result_id": r.source.result_id(),
2924 "content": r.content,
2925 "score": r.score,
2926 })
2927 })
2928 .collect();
2929
2930 let mut factor_graph_payload = serde_json::json!({
2931 "enabled": false,
2932 });
2933
2934 let mut decoder_executed = false;
2935 let mut discord_executed = false;
2936 let mut discord_results_payload: Vec<serde_json::Value> = Vec::new();
2937
2938 if decision.decoder {
2939 #[cfg(feature = "full")]
2940 {
2941 use semantic_memory::factor_graph::{
2942 factors_from_edges, FactorGraph, FactorGraphConfig,
2943 };
2944
2945 let graph_edges = tokio::task::block_in_place(|| {
2946 Handle::current().block_on(store.list_all_graph_edges())
2947 });
2948
2949 match graph_edges {
2950 Ok(edges) => {
2951 let raw_edges: Vec<(
2952 String,
2953 String,
2954 semantic_memory::GraphEdgeType,
2955 f64,
2956 Option<String>,
2957 )> = edges
2958 .iter()
2959 .map(|edge| {
2960 let parsed_type = edge
2961 .edge_type_parsed
2962 .clone()
2963 .or_else(|| serde_json::from_str(&edge.edge_type).ok())
2964 .unwrap_or(semantic_memory::GraphEdgeType::Entity {
2965 relation: "unknown".to_string(),
2966 });
2967 (
2968 edge.source.clone(),
2969 edge.target.clone(),
2970 parsed_type,
2971 edge.weight,
2972 edge.metadata.clone(),
2973 )
2974 })
2975 .collect();
2976
2977 let nodes: Vec<(String, f64)> = result_refs
2978 .iter()
2979 .map(|r| (r.source.result_id(), r.score))
2980 .collect();
2981 let factors = factors_from_edges(&raw_edges);
2982 let graph =
2983 FactorGraph::new(&nodes, factors, FactorGraphConfig::default());
2984 let propagated = graph.propagate();
2985 let top_beliefs = propagated.top_k(k);
2986
2987 factor_graph_payload = serde_json::json!({
2988 "enabled": true,
2989 "top_k_beliefs": top_beliefs
2990 .into_iter()
2991 .map(|(item_id, belief)| serde_json::json!({
2992 "item_id": item_id,
2993 "belief": belief,
2994 }))
2995 .collect::<Vec<_>>(),
2996 "iterations": propagated.iterations,
2997 "converged": propagated.converged,
2998 "elapsed_ms": propagated.elapsed_ms,
2999 "factor_counts": {
3000 "semantic": propagated.factor_counts.semantic,
3001 "temporal": propagated.factor_counts.temporal,
3002 "causal": propagated.factor_counts.causal,
3003 "entity": propagated.factor_counts.entity,
3004 "total": propagated.factor_counts.total(),
3005 },
3006 });
3007 decoder_executed = true;
3008 }
3009 Err(e) => {
3010 factor_graph_payload = serde_json::json!({
3011 "enabled": false,
3012 "error": format!("factor graph analysis failed: {e}"),
3013 });
3014 }
3015 }
3016 }
3017
3018 #[cfg(not(feature = "full"))]
3019 {
3020 factor_graph_payload = serde_json::json!({
3021 "enabled": false,
3022 "reason": "factor graph analysis requires the `full` feature",
3023 });
3024 }
3025
3026 if !plan.contradictions.is_empty() {
3027 use semantic_memory::decoder::{compute_correction, detect_syndromes};
3028 let result_scores: Vec<(String, f64)> = result_refs
3029 .iter()
3030 .map(|r| (r.source.result_id(), r.score))
3031 .collect();
3032 let syndromes = detect_syndromes(&result_scores, &plan.contradictions);
3033 let _ = compute_correction(&syndromes, 10.0);
3034 decoder_executed = true;
3035 }
3036 }
3037
3038 if plan.use_discord {
3039 use semantic_memory::discord::DiscordScorer;
3040 let direct_ids: Vec<String> =
3041 result_refs.iter().map(|r| r.source.result_id()).collect();
3042 let existing_ids: std::collections::HashSet<String> =
3043 direct_ids.iter().cloned().collect();
3044 if let Ok(edges) = load_neighborhood_edge_refs(&self.bridge.store, &direct_ids)
3045 {
3046 let scorer = DiscordScorer::with_defaults();
3047 let discord_hits = scorer.score(&direct_ids, &edges);
3048 for hit in &discord_hits {
3049 if !existing_ids.contains(&hit.item_id) {
3050 discord_results_payload.push(serde_json::json!({
3051 "result_id": hit.item_id,
3052 "discord_score": hit.discord_score,
3053 "anchor_ids": hit.anchor_ids,
3054 "relationship_types": hit.relationship_types,
3055 }));
3056 }
3057 }
3058 discord_executed = true;
3059 }
3060 }
3061
3062 let mut matryoshka_payload = serde_json::json!({
3063 "enabled": false,
3064 });
3065 if decision.vector_medium {
3066 #[cfg(feature = "full")]
3067 {
3068 use semantic_memory::integration::multi_resolution_route;
3069 use semantic_memory::matryoshka::MatryoshkaConfig;
3070 use semantic_memory::routing::QueryProfile;
3071
3072 let route_profile = QueryProfile::from_query(&query);
3073 let route_decision =
3074 multi_resolution_route(&route_profile, &MatryoshkaConfig::default());
3075 matryoshka_payload = serde_json::json!({
3076 "enabled": true,
3077 "candidate_dim": route_decision.candidate_dim,
3078 "heuristic_recall_estimate": route_decision.estimated_recall,
3079 "recall_basis": "heuristic_dimensional_model_not_corpus_measured",
3080 "embedding_dim": route_decision.embedding_dim,
3081 "reasoning": route_decision.reasoning,
3082 });
3083 }
3084
3085 #[cfg(not(feature = "full"))]
3086 {
3087 matryoshka_payload = serde_json::json!({
3088 "enabled": false,
3089 "reason": "matryoshka routing requires the `full` feature",
3090 });
3091 }
3092 }
3093
3094 let grouped_results_payload: serde_json::Value = if group_by_community == Some(true)
3096 {
3097 let seed_ids: Vec<String> = result_refs
3098 .iter()
3099 .take(k)
3100 .map(|r| r.source.result_id())
3101 .collect();
3102 let edges = load_neighborhood_edge_pairs(store, &seed_ids).unwrap_or_default();
3103 if !edges.is_empty() {
3104 use semantic_memory::community::detect_communities;
3105 let communities = detect_communities(&edges, 1.0, 42);
3106 let mut member_to_comm: std::collections::HashMap<String, String> =
3107 std::collections::HashMap::new();
3108 for c in &communities {
3109 for m in &c.members {
3110 member_to_comm.insert(m.clone(), c.id.clone());
3111 }
3112 }
3113 let mut groups: std::collections::HashMap<String, Vec<serde_json::Value>> =
3114 std::collections::HashMap::new();
3115 let mut ungrouped: Vec<serde_json::Value> = Vec::new();
3116 for r in &json_results {
3117 if let Some(rid) = r.get("result_id").and_then(|v| v.as_str()) {
3118 match member_to_comm.get(rid).cloned() {
3119 Some(cid) => groups.entry(cid).or_default().push(r.clone()),
3120 None => ungrouped.push(r.clone()),
3121 }
3122 }
3123 }
3124 let mut map = serde_json::Map::new();
3125 for (cid, items) in groups {
3126 map.insert(format!("community_{cid}"), serde_json::json!(items));
3127 }
3128 if !ungrouped.is_empty() {
3129 map.insert("ungrouped".to_string(), serde_json::json!(ungrouped));
3130 }
3131 serde_json::Value::Object(map)
3132 } else {
3133 serde_json::Value::Null
3134 }
3135 } else {
3136 serde_json::Value::Null
3137 };
3138
3139 let mut topology_payload = serde_json::json!({ "auto_called": false });
3141 {
3142 use semantic_memory::routing::{QueryComplexityClass, QueryProfile};
3143 let route_profile = QueryProfile::from_query(&query);
3144 if route_profile.complexity_class == QueryComplexityClass::Synthesis
3145 && result_refs.len() > 10
3146 {
3147 #[cfg(feature = "full")]
3148 {
3149 use semantic_memory::topology::{compute_betti_numbers, find_voids};
3150 let edges = load_stored_edge_pairs(store).unwrap_or_default();
3151 if !edges.is_empty() {
3152 let mut adjacency: std::collections::HashMap<String, Vec<String>> =
3153 std::collections::HashMap::new();
3154 for (src, tgt) in &edges {
3155 adjacency.entry(src.clone()).or_default().push(tgt.clone());
3156 adjacency.entry(tgt.clone()).or_default().push(src.clone());
3157 }
3158 let betti = compute_betti_numbers(&adjacency);
3159 let voids = find_voids(&edges);
3160 topology_payload = serde_json::json!({
3161 "auto_called": true,
3162 "trigger": "synthesis_class_with_10_plus_results",
3163 "betti_numbers": {
3164 "betti_0": betti.betti_0,
3165 "betti_1": betti.betti_1,
3166 },
3167 "void_count": voids.len(),
3168 "voids": voids.iter().map(|v| serde_json::json!({
3169 "description": v.description,
3170 "void_type": format!("{:?}", v.void_type),
3171 "nearby_items": v.nearby_items,
3172 "suggested_connections": v.suggested_connections,
3173 })).collect::<Vec<_>>(),
3174 });
3175 } else {
3176 topology_payload = serde_json::json!({
3177 "auto_called": true,
3178 "trigger": "synthesis_class_with_10_plus_results",
3179 "note": "no graph edges in store",
3180 });
3181 }
3182 }
3183 #[cfg(not(feature = "full"))]
3184 {
3185 topology_payload = serde_json::json!({
3186 "auto_called": true,
3187 "trigger": "synthesis_class_with_10_plus_results",
3188 "error": "topology requires the full feature",
3189 });
3190 }
3191 }
3192 }
3193
3194 json_to_output(&serde_json::json!({
3195 "ok": true,
3196 "routing_decision": {
3197 "source": routing_source,
3198 "bm25_coarse": decision.bm25_coarse,
3199 "vector_medium": decision.vector_medium,
3200 "rerank_fine": decision.rerank_fine,
3201 "graph_expansion": decision.graph_expansion,
3202 "decoder": decision.decoder,
3203 "discord": decision.discord,
3204 "no_retrieval": decision.no_retrieval,
3205 "reasoning": decision.reasoning,
3206 },
3207 "results": json_results,
3208 "count": json_results.len(),
3209 "superseded_filtered_count": superseded_filtered_count,
3210 "decoder_planned": plan.use_decoder,
3211 "decoder_executed": decoder_executed,
3212 "discord_planned": plan.use_discord,
3213 "discord_executed": discord_executed,
3214 "discord_results": discord_results_payload,
3215 "factor_graph": factor_graph_payload,
3216 "matryoshka": matryoshka_payload,
3217 "grouped_results": grouped_results_payload,
3218 "topology": topology_payload,
3219 }))
3220 }
3221 Err(e) => Err(ErrorData::internal_error(
3222 format!("Search error: {e}"),
3223 None,
3224 )),
3225 }
3226 }
3227
3228 #[tool(
3229 description = "Detect contradictions in search results. Runs syndrome detection, computes corrections, and applies belief propagation to refine confidence scores.",
3230 annotations(read_only_hint = true)
3231 )]
3232 fn sm_decoder_analyze(
3233 &self,
3234 Parameters(DecoderAnalyzeParams {
3235 results,
3236 contradictions,
3237 }): Parameters<DecoderAnalyzeParams>,
3238 ) -> Result<Json<StructuredOutput>, ErrorData> {
3239 use semantic_memory::decoder::{
3240 compute_correction, detect_syndromes, pass_messages, ConflictGraph,
3241 };
3242
3243 let contras = contradictions.unwrap_or_default();
3244 let syndromes = detect_syndromes(&results, &contras);
3245 let corrections = compute_correction(&syndromes, 10.0);
3246 let graph = ConflictGraph::from_syndromes(&results, &syndromes);
3247 let mp = pass_messages(&graph, 50, 0.001);
3248
3249 json_to_output(&serde_json::json!({
3250 "ok": true,
3251 "syndromes": syndromes.iter().map(|s| serde_json::json!({
3252 "id": s.id,
3253 "severity": format!("{:?}", s.severity),
3254 "items": s.items,
3255 "description": s.description,
3256 "type": format!("{:?}", s.syndrome_type),
3257 })).collect::<Vec<_>>(),
3258 "syndrome_count": syndromes.len(),
3259 "corrections": corrections.iter().map(|c| serde_json::json!({
3260 "id": c.id,
3261 "confidence": c.confidence,
3262 "cost": c.cost,
3263 "operations": c.operations.len(),
3264 })).collect::<Vec<_>>(),
3265 "correction_count": corrections.len(),
3266 "message_passing": {
3267 "iterations": mp.iterations,
3268 "converged": mp.converged,
3269 "elapsed_ms": mp.elapsed_ms,
3270 },
3271 }))
3272 }
3273
3274 #[tool(
3275 description = "Detect contradictions among the top results for a query from their CONTENT (numeric, value, negation, or antonym disagreement) — no pre-asserted edges required. Returns candidate conflicting pairs, each with the signals that fired and a human-readable reason. Persist a confirmed pair with sm_add_graph_edge(edge_type=\"contradicts\") so the decoder/community/factor-graph tools pick it up.",
3276 annotations(read_only_hint = true)
3277 )]
3278 fn sm_detect_contradictions(
3279 &self,
3280 Parameters(DetectContradictionsParams {
3281 query,
3282 top_k,
3283 record_to_ledger,
3284 }): Parameters<DetectContradictionsParams>,
3285 ) -> Result<Json<StructuredOutput>, ErrorData> {
3286 use semantic_memory::contradiction_detect::{detect_contradictions, DetectorConfig};
3287
3288 let k = top_k.map(|v| v as usize).unwrap_or(10);
3289 let store = &self.bridge.store;
3290 let results = tokio::task::block_in_place(|| {
3291 Handle::current().block_on(store.search(&query, Some(k), None, None))
3292 })
3293 .map_err(|e| ErrorData::internal_error(format!("search failed: {e}"), None))?;
3294
3295 let items: Vec<(String, String)> = results
3296 .iter()
3297 .map(|r| (r.source.result_id(), r.content.clone()))
3298 .collect();
3299
3300 let pairs = detect_contradictions(&items, &DetectorConfig::default());
3301
3302 let (ledger_recorded, ledger_entries) = if record_to_ledger.unwrap_or(false) {
3308 #[cfg(feature = "claim-integration")]
3309 {
3310 use claim_ledger::{LedgerEntryBuilder, SupportState};
3311 let mut count = 0usize;
3312 let mut entries = Vec::new();
3313 for p in &pairs {
3314 let fact_a = p.a.strip_prefix("fact:").unwrap_or(&p.a).to_string();
3315 let fact_b = p.b.strip_prefix("fact:").unwrap_or(&p.b).to_string();
3316 {
3317 let mut idx = self.claim_trust.lock().unwrap();
3318 idx.record_judgment(fact_a.clone(), SupportState::Contradicted);
3319 idx.record_judgment(fact_b.clone(), SupportState::Contradicted);
3320 }
3321
3322 let pattern = p
3323 .signals
3324 .iter()
3325 .map(|s| format!("{s:?}"))
3326 .collect::<Vec<_>>()
3327 .join(",");
3328 let contradiction_id =
3329 claim_ledger::ids::contradiction_id(&fact_a, &fact_b, &pattern);
3330
3331 let mut ledger = self.claim_ledger_store.lock().unwrap();
3332 let sequence = ledger.next_sequence();
3333 let previous_digest = ledger.last_digest();
3334 let append_result = LedgerEntryBuilder::new(sequence, previous_digest)
3335 .add_contradiction_candidate(
3336 &contradiction_id,
3337 vec![fact_a.clone(), fact_b.clone()],
3338 &pattern,
3339 &p.reason,
3340 )
3341 .map_err(|e| e.to_string())
3342 .and_then(|entry| ledger.append(entry));
3343 drop(ledger);
3344
3345 match append_result {
3346 Ok(entry_hash) => {
3347 count += 1;
3348 entries.push(serde_json::json!({
3349 "contradiction_id": contradiction_id,
3350 "claim_refs": [fact_a, fact_b],
3351 "sequence": sequence,
3352 "entry_hash": entry_hash,
3353 }));
3354 }
3355 Err(e) => {
3356 return Err(ErrorData::internal_error(
3357 format!("failed to record contradiction to ledger: {e}"),
3358 None,
3359 ));
3360 }
3361 }
3362 }
3363 (count, entries)
3364 }
3365 #[cfg(not(feature = "claim-integration"))]
3366 {
3367 (0, Vec::new())
3368 }
3369 } else {
3370 (0, Vec::new())
3371 };
3372
3373 json_to_output(&serde_json::json!({
3374 "ok": true,
3375 "query": query,
3376 "items_scanned": items.len(),
3377 "contradictions": pairs.iter().map(|p| serde_json::json!({
3378 "a": p.a,
3379 "b": p.b,
3380 "score": p.score,
3381 "signals": p.signals.iter().map(|s| format!("{s:?}")).collect::<Vec<_>>(),
3382 "reason": p.reason,
3383 })).collect::<Vec<_>>(),
3384 "count": pairs.len(),
3385 "ledger_recorded": ledger_recorded,
3386 "ledger_entries": ledger_entries,
3387 "receipt": mcp_receipt("sm_detect_contradictions"),
3388 }))
3389 }
3390
3391 #[tool(
3392 description = "Second-order retrieval: find items related to your search results through the graph, but NOT themselves direct hits. Loads edges from store automatically.",
3393 annotations(read_only_hint = true)
3394 )]
3395 fn sm_discord_search(
3396 &self,
3397 Parameters(DiscordSearchParams { direct_result_ids }): Parameters<DiscordSearchParams>,
3398 ) -> Result<Json<StructuredOutput>, ErrorData> {
3399 use semantic_memory::discord::DiscordScorer;
3400
3401 let edges = load_neighborhood_edge_refs(&self.bridge.store, &direct_result_ids)?;
3404 let scorer = DiscordScorer::with_defaults();
3405 let results = scorer.score(&direct_result_ids, &edges);
3406
3407 json_to_output(&serde_json::json!({
3408 "ok": true,
3409 "discord_results": results.iter().map(|r| serde_json::json!({
3410 "item_id": r.item_id,
3411 "discord_score": r.discord_score,
3412 "anchor_ids": r.anchor_ids,
3413 "relationship_types": r.relationship_types,
3414 })).collect::<Vec<_>>(),
3415 "count": results.len(),
3416 "edges_loaded": edges.len(),
3417 "edges_scope": "neighborhood",
3418 }))
3419 }
3420
3421 #[tool(
3422 description = "Set provenance (evidence confidence) for an item. Confidence in [0.0, 1.0] with support count. Returns a provenance receipt.",
3423 annotations(idempotent_hint = true)
3424 )]
3425 fn sm_set_provenance(
3426 &self,
3427 Parameters(SetProvenanceParams {
3428 item_id,
3429 confidence,
3430 support_count,
3431 }): Parameters<SetProvenanceParams>,
3432 ) -> Result<Json<StructuredOutput>, ErrorData> {
3433 use semantic_memory::provenance::{
3434 ConfidenceSemiring, ConfidenceValue, ProvenanceItemType,
3435 };
3436
3437 if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
3439 return Err(ErrorData::invalid_params(
3440 format!("confidence must be a finite value in [0.0, 1.0], got {confidence}"),
3441 None,
3442 ));
3443 }
3444
3445 let value = ConfidenceValue::new(confidence, support_count);
3446 let store = &self.bridge.store;
3447
3448 let result = tokio::task::block_in_place(|| {
3449 Handle::current().block_on(store.set_provenance::<ConfidenceSemiring>(
3450 &ProvenanceItemType::Fact,
3451 &item_id,
3452 &value,
3453 &[],
3454 None,
3455 ))
3456 });
3457
3458 match result {
3459 Ok(receipt) => json_to_output(&serde_json::json!({
3460 "ok": true,
3461 "provenance_id": receipt.provenance_id,
3462 "item_id": receipt.item_id,
3463 "semiring_type": receipt.semiring_type,
3464 "recorded_at": receipt.recorded_at,
3465 "message": "Provenance set successfully",
3466 })),
3467 Err(e) => Err(ErrorData::internal_error(
3468 format!("Provenance error: {e}"),
3469 None,
3470 )),
3471 }
3472 }
3473
3474 #[tool(
3475 description = "Run a memory lifecycle pass: analyze items for syndromes, compute corrections, identify subtraction candidates, and check compression needs.",
3476 annotations(read_only_hint = true)
3477 )]
3478 fn sm_run_lifecycle(
3479 &self,
3480 Parameters(RunLifecycleParams { item_ids }): Parameters<RunLifecycleParams>,
3481 ) -> Result<Json<StructuredOutput>, ErrorData> {
3482 use semantic_memory::decoder::{compute_correction, detect_syndromes};
3483 use semantic_memory::integration::{
3484 corrections_to_subtraction_candidates, should_trigger_recompression,
3485 };
3486
3487 let results: Vec<(String, f64)> = item_ids.iter().map(|id| (id.clone(), 0.5)).collect();
3488 let syndromes = detect_syndromes(&results, &[]);
3489 let corrections = compute_correction(&syndromes, 10.0);
3490
3491 let sub_candidates = corrections_to_subtraction_candidates(&corrections);
3492
3493 let subtracted_count = sub_candidates.len();
3494 let remaining_count = item_ids.len().saturating_sub(subtracted_count);
3495 let recompression = should_trigger_recompression(subtracted_count, remaining_count, false);
3496
3497 let store = &self.bridge.store;
3498 let graph_edges = tokio::task::block_in_place(|| {
3499 Handle::current().block_on(store.list_all_graph_edges())
3500 });
3501 let stored_edges: Vec<(String, String)> = graph_edges
3502 .as_ref()
3503 .map(|edges| {
3504 edges
3505 .iter()
3506 .map(|edge| (edge.source.clone(), edge.target.clone()))
3507 .collect()
3508 })
3509 .unwrap_or_default();
3510
3511 let mut topology_voids: Vec<serde_json::Value> = Vec::new();
3512 let mut betti = serde_json::json!({
3513 "betti_0": 0usize,
3514 "betti_1": 0usize,
3515 });
3516 #[allow(unused_mut)]
3517 let mut topology_error: Option<String> = None;
3518
3519 let mut communities: Vec<serde_json::Value> = Vec::new();
3520 let mut community_contradictions: Vec<serde_json::Value> = Vec::new();
3521 #[allow(unused_mut)]
3522 let mut community_error: Option<String> = None;
3523
3524 let mut subgraph_assessment = serde_json::json!({
3525 "subgraphs_identified": 0usize,
3526 "subgraphs_pruned": 0usize,
3527 });
3528 #[allow(unused_mut)]
3529 let mut subgraph_error: Option<String> = None;
3530
3531 #[cfg(feature = "full")]
3532 {
3533 use std::collections::HashMap;
3534
3535 if !stored_edges.is_empty() {
3536 let analysis_edges = stored_edges.clone();
3537
3538 use semantic_memory::topology::{compute_betti_numbers, find_voids};
3539
3540 let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
3541 for (left, right) in &analysis_edges {
3542 adjacency
3543 .entry(left.clone())
3544 .or_default()
3545 .push(right.clone());
3546 adjacency
3547 .entry(right.clone())
3548 .or_default()
3549 .push(left.clone());
3550 }
3551
3552 let betti_numbers = compute_betti_numbers(&adjacency);
3553 betti = serde_json::json!({
3554 "betti_0": betti_numbers.betti_0,
3555 "betti_1": betti_numbers.betti_1,
3556 });
3557
3558 topology_voids = find_voids(&analysis_edges)
3559 .into_iter()
3560 .map(|v| {
3561 serde_json::json!({
3562 "description": v.description,
3563 "void_type": format!("{:?}", v.void_type),
3564 "nearby_items": v.nearby_items,
3565 "suggested_connections": v.suggested_connections,
3566 })
3567 })
3568 .collect();
3569
3570 use semantic_memory::community::{
3571 community_contradiction_scan, detect_communities,
3572 };
3573
3574 let detected = detect_communities(&analysis_edges, 1.0, 42);
3575 communities = detected
3576 .iter()
3577 .map(|c| {
3578 serde_json::json!({
3579 "id": c.id,
3580 "members": c.members,
3581 "level": c.level,
3582 "parent": c.parent,
3583 "member_count": c.members.len(),
3584 })
3585 })
3586 .collect();
3587
3588 community_contradictions = community_contradiction_scan(&detected, &[])
3589 .into_iter()
3590 .map(|cc| {
3591 serde_json::json!({
3592 "community_id": cc.community_id,
3593 "item_a": cc.item_a,
3594 "item_b": cc.item_b,
3595 "description": cc.description,
3596 })
3597 })
3598 .collect();
3599
3600 use semantic_memory::integration::autonomous_subgraph_maintenance;
3601 use semantic_memory::subgraph_pruning::AccessLog;
3602 use std::collections::HashSet;
3603
3604 let mut access_items: HashSet<String> = HashSet::new();
3605 for (left, right) in &analysis_edges {
3606 access_items.insert(left.clone());
3607 access_items.insert(right.clone());
3608 }
3609
3610 let access_logs = access_items
3611 .into_iter()
3612 .map(|item| AccessLog {
3613 item_id: item,
3614 access_count: 1,
3615 last_accessed: "1970-01-01T00:00:00Z".to_string(),
3616 })
3617 .collect::<Vec<_>>();
3618
3619 let report = autonomous_subgraph_maintenance(&analysis_edges, &access_logs, &[], 0);
3620 subgraph_assessment = serde_json::json!({
3621 "subgraphs_identified": report.subgraphs_identified,
3622 "subgraphs_pruned": report.subgraphs_pruned,
3623 "summary": report.summary,
3624 });
3625 }
3626 }
3627
3628 #[cfg(not(feature = "full"))]
3629 {
3630 if !stored_edges.is_empty() {
3631 topology_error = Some(
3632 "topology/community/subgraph phases require the `full` feature".to_string(),
3633 );
3634 community_error = Some(
3635 "topology/community/subgraph phases require the `full` feature".to_string(),
3636 );
3637 subgraph_error = Some(
3638 "topology/community/subgraph phases require the `full` feature".to_string(),
3639 );
3640 }
3641 }
3642
3643 #[cfg(feature = "full")]
3644 let (f32_count, compressed_count) =
3645 item_ids
3646 .iter()
3647 .fold((0usize, 0usize), |(f32_count, compressed_count), _| {
3648 use semantic_memory::compression_governor::{
3649 decide_quantization, QuantizationLevel,
3650 };
3651
3652 match decide_quantization(0.5) {
3653 QuantizationLevel::F32 => (f32_count + 1, compressed_count),
3654 _ => (f32_count, compressed_count + 1),
3655 }
3656 });
3657 #[cfg(not(feature = "full"))]
3658 let (f32_count, compressed_count) = (0usize, 0usize);
3659
3660 json_to_output(&serde_json::json!({
3661 "ok": true,
3662 "items_analyzed": item_ids.len(),
3663 "syndromes_detected": syndromes.len(),
3664 "corrections_computed": corrections.len(),
3665 "subtraction_candidates": sub_candidates.iter().map(|c| serde_json::json!({
3666 "item_id": c.item_id,
3667 "structuring_score": c.structuring_score,
3668 "operation_type": c.operation_type,
3669 "reason": c.reason,
3670 })).collect::<Vec<_>>(),
3671 "recompression_triggered": recompression.triggered,
3672 "recompression_reason": recompression.reason,
3673 "topology": {
3674 "enabled": !stored_edges.is_empty(),
3675 "voids": topology_voids,
3676 "void_count": topology_voids.len(),
3677 "betti_numbers": betti,
3678 "error": topology_error,
3679 },
3680 "community_detection": {
3681 "enabled": !stored_edges.is_empty(),
3682 "communities": communities,
3683 "community_count": communities.len(),
3684 "contradictions": community_contradictions,
3685 "contradiction_count": community_contradictions.len(),
3686 "error": community_error,
3687 },
3688 "subgraph_pruning_assessment": {
3689 "enabled": !stored_edges.is_empty(),
3690 "subgraph_count": subgraph_assessment["subgraphs_identified"].as_u64().unwrap_or(0),
3691 "pruned_count": subgraph_assessment["subgraphs_pruned"].as_u64().unwrap_or(0),
3692 "summary": subgraph_assessment["summary"].as_str().unwrap_or(""),
3693 "error": subgraph_error,
3694 },
3695 "turbo_quantization_assessment": {
3696 "items_assessed": item_ids.len(),
3697 "would_retain_f32": f32_count,
3698 "would_compress": compressed_count,
3699 },
3700 "summary": format!(
3701 "Analyzed {} items: {} syndromes, {} corrections, {} subtraction candidates, recompression: {}",
3702 item_ids.len(), syndromes.len(), corrections.len(), sub_candidates.len(),
3703 if recompression.triggered { "needed" } else { "not needed" }
3704 ),
3705 }))
3706 }
3707
3708 #[tool(
3711 description = "Add a durable, typed graph edge between two nodes. Edge types: semantic, temporal, causal, entity. Idempotent — same edge returns existing ID.",
3712 annotations(idempotent_hint = true)
3713 )]
3714 fn sm_add_graph_edge(
3715 &self,
3716 Parameters(params): Parameters<AddGraphEdgeParams>,
3717 ) -> Result<Json<StructuredOutput>, ErrorData> {
3718 use semantic_memory::GraphEdgeType;
3719
3720 if let Some(cs) = params.cosine_similarity {
3722 if !cs.is_finite() || !(0.0..=1.0).contains(&cs) {
3723 return Err(ErrorData::invalid_params(
3724 format!("cosine_similarity must be finite and in [0.0, 1.0], got {cs}"),
3725 None,
3726 ));
3727 }
3728 }
3729 if let Some(conf) = params.confidence {
3730 if !conf.is_finite() || !(0.0..=1.0).contains(&conf) {
3731 return Err(ErrorData::invalid_params(
3732 format!("confidence must be finite and in [0.0, 1.0], got {conf}"),
3733 None,
3734 ));
3735 }
3736 }
3737
3738 let edge_type = match params.edge_type {
3739 EdgeType::Semantic => GraphEdgeType::Semantic {
3740 cosine_similarity: params.cosine_similarity.unwrap_or(0.5),
3741 },
3742 EdgeType::Temporal => GraphEdgeType::Temporal {
3743 delta_secs: params.delta_secs.unwrap_or(0),
3744 },
3745 EdgeType::Causal => GraphEdgeType::Causal {
3746 confidence: params.confidence.unwrap_or(0.5),
3747 evidence_ids: params.evidence_ids.unwrap_or_default(),
3748 },
3749 EdgeType::Entity => GraphEdgeType::Entity {
3750 relation: params.relation.unwrap_or_else(|| "related".to_string()),
3751 },
3752 };
3753
3754 let metadata = match params.metadata.as_deref() {
3756 None => None,
3757 Some(s) => match serde_json::from_str::<serde_json::Value>(s) {
3758 Ok(v) => Some(v),
3759 Err(e) => {
3760 return Err(ErrorData::invalid_params(
3761 format!("metadata is not valid JSON: {e}"),
3762 None,
3763 ))
3764 }
3765 },
3766 };
3767
3768 let store = &self.bridge.store;
3769 let result = tokio::task::block_in_place(|| {
3770 Handle::current().block_on(store.add_graph_edge(
3771 ¶ms.source,
3772 ¶ms.target,
3773 edge_type,
3774 params.weight,
3775 metadata,
3776 ))
3777 });
3778
3779 match result {
3780 Ok(edge) => json_to_output(&serde_json::json!({
3781 "ok": true,
3782 "receipt": mcp_receipt("sm_add_graph_edge"),
3783 "id": edge.id,
3784 "source": edge.source,
3785 "target": edge.target,
3786 "edge_type": edge.edge_type,
3787 "weight": edge.weight,
3788 "content_digest": edge.content_digest,
3789 "recorded_at": edge.recorded_at,
3790 "message": "Graph edge added successfully",
3791 })),
3792 Err(e) => Err(ErrorData::internal_error(
3793 format!("Error adding graph edge: {e}"),
3794 None,
3795 )),
3796 }
3797 }
3798
3799 #[tool(
3800 description = "List graph edges for a specific node (as source or target), or all edges if no node_id. Returns non-invalidated edges only.",
3801 annotations(read_only_hint = true)
3802 )]
3803 fn sm_list_graph_edges(
3804 &self,
3805 Parameters(ListGraphEdgesParams { node_id }): Parameters<ListGraphEdgesParams>,
3806 ) -> Result<Json<StructuredOutput>, ErrorData> {
3807 let store = &self.bridge.store;
3808 let result = match node_id {
3809 Some(id) => tokio::task::block_in_place(|| {
3810 Handle::current().block_on(store.list_graph_edges_for_node(&id))
3811 }),
3812 None => tokio::task::block_in_place(|| {
3813 Handle::current().block_on(store.list_all_graph_edges())
3814 }),
3815 };
3816
3817 match result {
3818 Ok(edges) => json_to_output(&serde_json::json!({
3819 "ok": true,
3820 "edges": edges.iter().map(|e| serde_json::json!({
3821 "id": e.id,
3822 "source": e.source,
3823 "target": e.target,
3824 "edge_type": e.edge_type,
3825 "weight": e.weight,
3826 "metadata": e.metadata,
3827 "recorded_at": e.recorded_at,
3828 })).collect::<Vec<_>>(),
3829 "count": edges.len(),
3830 })),
3831 Err(e) => Err(ErrorData::internal_error(
3832 format!("Error listing graph edges: {e}"),
3833 None,
3834 )),
3835 }
3836 }
3837
3838 #[tool(
3839 description = "Invalidate a stored graph edge by ID. Append-only — edge is never deleted, only marked invalidated with a reason.",
3840 annotations(idempotent_hint = true)
3841 )]
3842 fn sm_invalidate_graph_edge(
3843 &self,
3844 Parameters(InvalidateGraphEdgeParams { edge_id, reason }): Parameters<
3845 InvalidateGraphEdgeParams,
3846 >,
3847 ) -> Result<Json<StructuredOutput>, ErrorData> {
3848 let store = &self.bridge.store;
3849 let result = tokio::task::block_in_place(|| {
3850 Handle::current().block_on(store.invalidate_graph_edge(&edge_id, &reason))
3851 });
3852
3853 match result {
3854 Ok(()) => json_to_output(&serde_json::json!({
3855 "ok": true,
3856 "receipt": mcp_receipt("sm_invalidate_graph_edge"),
3857 "edge_id": edge_id,
3858 "message": "Edge invalidated successfully",
3859 })),
3860 Err(e) => Err(ErrorData::internal_error(
3861 format!("Error invalidating edge: {e}"),
3862 None,
3863 )),
3864 }
3865 }
3866
3867 #[tool(
3870 description = "Run factor graph belief propagation on stored graph edges. Models all 4 edge types as factors. Returns unified confidence scores after convergence.",
3871 annotations(read_only_hint = true)
3872 )]
3873 fn sm_factor_graph(
3874 &self,
3875 Parameters(params): Parameters<FactorGraphParams>,
3876 ) -> Result<Json<StructuredOutput>, ErrorData> {
3877 use semantic_memory::factor_graph::{factors_from_edges, FactorGraph, FactorGraphConfig};
3878
3879 let defaults = FactorGraphConfig::default();
3880 let config = FactorGraphConfig {
3881 semantic_weight: params.semantic_weight.unwrap_or(defaults.semantic_weight),
3882 temporal_weight: params.temporal_weight.unwrap_or(defaults.temporal_weight),
3883 causal_weight: params.causal_weight.unwrap_or(defaults.causal_weight),
3884 entity_weight: params.entity_weight.unwrap_or(defaults.entity_weight),
3885 self_influence: params.self_influence.unwrap_or(defaults.self_influence),
3886 max_iterations: params
3887 .max_iterations
3888 .map(|v| v as usize)
3889 .unwrap_or(defaults.max_iterations),
3890 convergence_threshold: params
3891 .convergence_threshold
3892 .unwrap_or(defaults.convergence_threshold),
3893 };
3894
3895 let seed_ids: Vec<String> = params.nodes.iter().map(|n| n.item_id.clone()).collect();
3898 let raw_edges = load_neighborhood_factor_edges(&self.bridge.store, &seed_ids)?;
3899 let factors = factors_from_edges(&raw_edges);
3900
3901 let nodes: Vec<(String, f64)> = params
3902 .nodes
3903 .iter()
3904 .map(|n| (n.item_id.clone(), n.initial_belief))
3905 .collect();
3906
3907 let graph = FactorGraph::new(&nodes, factors, config);
3908 let result = graph.propagate();
3909
3910 json_to_output(&serde_json::json!({
3911 "ok": true,
3912 "node_beliefs": result.node_beliefs,
3913 "iterations": result.iterations,
3914 "converged": result.converged,
3915 "elapsed_ms": result.elapsed_ms,
3916 "edges_loaded": raw_edges.len(),
3917 "edges_scope": "neighborhood",
3918 "factor_counts": {
3919 "semantic": result.factor_counts.semantic,
3920 "temporal": result.factor_counts.temporal,
3921 "causal": result.factor_counts.causal,
3922 "entity": result.factor_counts.entity,
3923 "total": result.factor_counts.total(),
3924 },
3925 "config": {
3926 "semantic_weight": result.config.semantic_weight,
3927 "temporal_weight": result.config.temporal_weight,
3928 "causal_weight": result.config.causal_weight,
3929 "entity_weight": result.config.entity_weight,
3930 "self_influence": result.config.self_influence,
3931 "max_iterations": result.config.max_iterations,
3932 "convergence_threshold": result.config.convergence_threshold,
3933 },
3934 }))
3935 }
3936
3937 #[tool(
3938 description = "Find topological voids in the knowledge graph. Computes Betti numbers (components and cycles) and detects structural gaps. Loads edges from store.",
3939 annotations(read_only_hint = true)
3940 )]
3941 fn sm_topology(
3942 &self,
3943 Parameters(_params): Parameters<TopologyParams>,
3944 ) -> Result<Json<StructuredOutput>, ErrorData> {
3945 use semantic_memory::topology::{compute_betti_numbers, find_voids, gap_report};
3946
3947 let edges = load_stored_edge_pairs(&self.bridge.store)?;
3949
3950 let mut adjacency: std::collections::HashMap<String, Vec<String>> =
3951 std::collections::HashMap::new();
3952 for (src, tgt) in &edges {
3953 adjacency.entry(src.clone()).or_default().push(tgt.clone());
3954 adjacency.entry(tgt.clone()).or_default().push(src.clone());
3955 }
3956
3957 let betti = compute_betti_numbers(&adjacency);
3958 let voids = find_voids(&edges);
3959 let report = gap_report(&voids);
3960
3961 json_to_output(&serde_json::json!({
3962 "ok": true,
3963 "betti_numbers": {
3964 "betti_0": betti.betti_0,
3965 "betti_1": betti.betti_1,
3966 },
3967 "voids": voids.iter().map(|v| serde_json::json!({
3968 "description": v.description,
3969 "nearby_items": v.nearby_items,
3970 "suggested_connections": v.suggested_connections,
3971 "void_type": format!("{:?}", v.void_type),
3972 })).collect::<Vec<_>>(),
3973 "void_count": voids.len(),
3974 "edges_loaded_from_store": edges.len(),
3975 "report": report,
3976 }))
3977 }
3978
3979 #[tool(
3980 description = "Detect communities in the knowledge graph (Leiden-inspired). Returns community assignments, optional contradiction scans, and compression recommendations.",
3981 annotations(read_only_hint = true)
3982 )]
3983 fn sm_community(
3984 &self,
3985 Parameters(params): Parameters<CommunityParams>,
3986 ) -> Result<Json<StructuredOutput>, ErrorData> {
3987 use semantic_memory::community::{
3988 community_aware_compression, community_contradiction_scan, detect_communities,
3989 };
3990
3991 let edges = load_stored_edge_pairs(&self.bridge.store)?;
3993
3994 let resolution = params.resolution.unwrap_or(1.0);
3995 let seed = params.seed.unwrap_or(42);
3996
3997 let communities = detect_communities(&edges, resolution, seed);
3998
3999 let contradictions = params.contradictions.unwrap_or_default();
4000 let community_contras = community_contradiction_scan(&communities, &contradictions);
4001
4002 let importance_scores = params.importance_scores.unwrap_or_default();
4003 let compression = community_aware_compression(&communities, &importance_scores);
4004
4005 let summarize = params.summarize.unwrap_or(false);
4006 let store = &self.bridge.store;
4007 let communities_json: Vec<serde_json::Value> = communities
4008 .iter()
4009 .map(|c| {
4010 let summary: Option<String> = if summarize && !c.members.is_empty() {
4011 let member_texts: Vec<String> = c
4012 .members
4013 .iter()
4014 .filter_map(|mid| {
4015 let bare = mid.strip_prefix("fact:").unwrap_or(mid);
4016 tokio::task::block_in_place(|| {
4017 Handle::current().block_on(store.get_fact(bare))
4018 })
4019 .ok()
4020 .flatten()
4021 .map(|f| f.content)
4022 })
4023 .collect();
4024 if !member_texts.is_empty() {
4025 let combined = member_texts.join("\n---\n");
4026 let prompt = format!(
4027 "Summarize these related facts in 1-2 sentences:\n{combined}\nSummary:"
4028 );
4029 let body = serde_json::json!({
4030 "model": "granite4.1:3b",
4031 "prompt": prompt,
4032 "stream": false,
4033 "options": {"temperature": 0, "num_predict": 100}
4034 });
4035 reqwest::blocking::Client::new()
4036 .post("http://127.0.0.1:11434/api/generate")
4037 .json(&body)
4038 .send()
4039 .ok()
4040 .and_then(|resp| resp.json::<serde_json::Value>().ok())
4041 .and_then(|v| {
4042 v.get("response")
4043 .and_then(|r| r.as_str())
4044 .map(|s| s.trim().to_string())
4045 })
4046 } else {
4047 None
4048 }
4049 } else {
4050 None
4051 };
4052 serde_json::json!({
4053 "id": c.id,
4054 "members": c.members,
4055 "level": c.level,
4056 "parent": c.parent,
4057 "member_count": c.members.len(),
4058 "summary": summary,
4059 })
4060 })
4061 .collect();
4062
4063 json_to_output(&serde_json::json!({
4064 "ok": true,
4065 "communities": communities_json,
4066 "community_count": communities.len(),
4067 "contradictions": community_contras.iter().map(|cc| serde_json::json!({
4068 "community_id": cc.community_id,
4069 "item_a": cc.item_a,
4070 "item_b": cc.item_b,
4071 "description": cc.description,
4072 })).collect::<Vec<_>>(),
4073 "contradiction_count": community_contras.len(),
4074 "compression_recommendations": compression.iter().map(|cr| serde_json::json!({
4075 "community_id": cr.community_id,
4076 "quantization_level": cr.quantization_level,
4077 "reason": cr.reason,
4078 })).collect::<Vec<_>>(),
4079 "compression_count": compression.len(),
4080 "edges_loaded_from_store": edges.len(),
4081 }))
4082 }
4083
4084 #[tool(
4090 description = "Identify and optionally prune reasoning subgraphs in the knowledge graph. Runs autonomous subgraph maintenance: identifies connected subgraphs, ranks by access frequency (least-accessed first), and optionally prunes. Dry-run by default. Returns identified subgraphs, pruning priority, and pruning receipts.",
4091 annotations(read_only_hint = true)
4092 )]
4093 fn sm_subgraph_prune(
4094 &self,
4095 Parameters(SubgraphPruneParams { dry_run, max_prune }): Parameters<SubgraphPruneParams>,
4096 ) -> Result<Json<StructuredOutput>, ErrorData> {
4097 #[cfg(feature = "subgraph-pruning")]
4098 {
4099 use semantic_memory::integration::autonomous_subgraph_maintenance;
4100 use semantic_memory::subgraph_pruning::AccessLog;
4101
4102 let dry = dry_run.unwrap_or(true);
4103 let max = max_prune.map(|v| v as usize).unwrap_or(5);
4104
4105 let edges = load_stored_edge_pairs(&self.bridge.store)?;
4107
4108 let access_logs: Vec<AccessLog> = Vec::new();
4110
4111 let raw_edges = tokio::task::block_in_place(|| {
4113 Handle::current().block_on(self.bridge.store.list_all_graph_edges())
4114 })
4115 .map_err(|e| ErrorData::internal_error(format!("load edges failed: {e}"), None))?;
4116 let contradictions: Vec<(String, String)> = raw_edges
4117 .iter()
4118 .filter_map(|e| {
4119 let parsed = e
4120 .edge_type_parsed
4121 .clone()
4122 .or_else(|| serde_json::from_str(&e.edge_type).ok());
4123 match parsed {
4124 Some(semantic_memory::GraphEdgeType::Entity { relation })
4125 if relation == "contradicts" =>
4126 {
4127 Some((e.source.clone(), e.target.clone()))
4128 }
4129 _ => None,
4130 }
4131 })
4132 .collect();
4133
4134 let prune_count = if dry { 0 } else { max };
4135 let report =
4136 autonomous_subgraph_maintenance(&edges, &access_logs, &contradictions, prune_count);
4137
4138 json_to_output(&serde_json::json!({
4139 "ok": true,
4140 "dry_run": dry,
4141 "subgraphs_identified": report.subgraphs_identified,
4142 "subgraphs_pruned": report.subgraphs_pruned,
4143 "receipts": report.receipts.iter().map(|r| serde_json::json!({
4144 "subgraph_root": r.subgraph_root,
4145 "pruned_nodes": r.pruned_nodes,
4146 })).collect::<Vec<_>>(),
4147 "summary": report.summary,
4148 "receipt_field": mcp_receipt("sm_subgraph_prune"),
4149 }))
4150 }
4151 #[cfg(not(feature = "subgraph-pruning"))]
4152 {
4153 let _ = (dry_run, max_prune);
4154 json_to_output(&serde_json::json!({
4155 "ok": true,
4156 "note": "subgraph-pruning feature not enabled",
4157 "receipt": mcp_receipt("sm_subgraph_prune"),
4158 }))
4159 }
4160 }
4161
4162 #[tool(
4163 description = "Forget a single fact by id through the governed dependency-closure path. Scrubs canonical content and derived FTS/vector/graph/cache/export/replay surfaces while retaining a content-free tombstone receipt. Prefer sm_supersede_fact for corrections.",
4164 annotations(destructive_hint = true)
4165 )]
4166 fn sm_delete_fact(
4167 &self,
4168 Parameters(DeleteFactParams { fact_id }): Parameters<DeleteFactParams>,
4169 ) -> Result<Json<StructuredOutput>, ErrorData> {
4170 let bare = fact_id
4171 .strip_prefix("fact:")
4172 .unwrap_or(&fact_id)
4173 .to_string();
4174 let store = &self.bridge.store;
4175 let fact = tokio::task::block_in_place(|| {
4176 Handle::current().block_on(store.get_fact_raw_compat(&bare))
4177 })
4178 .map_err(|e| ErrorData::internal_error(format!("delete_fact lookup error: {e}"), None))?
4179 .ok_or_else(|| ErrorData::invalid_params(format!("fact not found: fact:{bare}"), None))?;
4180 let origin_record = tokio::task::block_in_place(|| {
4181 Handle::current().block_on(store.authority().get_origin_authority(&bare))
4182 })
4183 .map_err(|e| {
4184 ErrorData::internal_error(format!("delete_fact origin lookup error: {e}"), None)
4185 })?
4186 .ok_or_else(|| {
4187 ErrorData::invalid_params(
4188 format!("fact has no governed origin authority: fact:{bare}"),
4189 None,
4190 )
4191 })?;
4192 let resource_principal = origin_record.label.origin_principal;
4193 let permit = semantic_memory::AuthorityPermit::operator_system(
4194 resource_principal.clone(),
4195 "caller:sm_delete_fact",
4196 semantic_memory::AuthorityPermit::FORGET_CAPABILITY,
4197 )
4198 .with_origin(semantic_memory::OriginAuthorityLabelV1::operator_system(
4199 &resource_principal,
4200 "caller:sm_delete_fact",
4201 ));
4202 let request = semantic_memory::ForgettingClosureRequestV1::new(
4203 vec![bare.clone()],
4204 fact.namespace,
4205 "explicit MCP fact-forgetting request",
4206 4096,
4207 );
4208 let result = tokio::task::block_in_place(|| {
4209 Handle::current().block_on(store.authority().forget(
4210 permit,
4211 format!("mcp-sm-delete-fact:{}", uuid::Uuid::new_v4()),
4212 request,
4213 ))
4214 });
4215 match result {
4216 Ok(receipt) => json_to_output(&serde_json::json!({
4217 "ok": true,
4218 "deleted": false,
4219 "forgotten": true,
4220 "fact_id": format!("fact:{bare}"),
4221 "forgetting_receipt": receipt,
4222 "message": "Fact forgotten through governed dependency closure",
4223 })),
4224 Err(e) => Err(ErrorData::internal_error(
4225 format!("delete_fact forgetting error: {e}"),
4226 None,
4227 )),
4228 }
4229 }
4230
4231 #[tool(
4232 description = "Permanently delete ALL memory in a namespace — facts, documents, chunks, sessions/messages. HARD delete, irreversible. Returns per-surface deletion count.",
4233 annotations(destructive_hint = true)
4234 )]
4235 fn sm_delete_namespace(
4236 &self,
4237 Parameters(DeleteNamespaceParams { namespace }): Parameters<DeleteNamespaceParams>,
4238 ) -> Result<Json<StructuredOutput>, ErrorData> {
4239 let store = &self.bridge.store;
4240 let result = tokio::task::block_in_place(|| {
4241 Handle::current().block_on(store.delete_namespace(&namespace))
4242 });
4243 match result {
4244 Ok(r) => json_to_output(&serde_json::json!({
4245 "ok": true,
4246 "receipt": mcp_receipt("sm_delete_namespace"),
4247 "namespace": namespace,
4248 "deleted": {
4249 "facts": r.facts,
4250 "documents": r.documents,
4251 "chunks": r.chunks,
4252 "messages": r.messages,
4253 "sessions": r.sessions,
4254 "episodes": r.episodes,
4255 "projection_rows": r.projection_rows,
4256 },
4257 "message": "Namespace permanently deleted",
4258 })),
4259 Err(e) => Err(ErrorData::internal_error(
4260 format!("delete_namespace error: {e}"),
4261 None,
4262 )),
4263 }
4264 }
4265
4266 #[tool(
4267 description = "Update a fact's content in-place. Re-embeds the fact and updates FTS index. Use this to correct outdated facts without deleting and re-adding.",
4268 annotations(idempotent_hint = true)
4269 )]
4270 fn sm_update_fact(
4271 &self,
4272 Parameters(UpdateFactParams { fact_id, content }): Parameters<UpdateFactParams>,
4273 ) -> Result<Json<StructuredOutput>, ErrorData> {
4274 let bare = fact_id
4275 .strip_prefix("fact:")
4276 .unwrap_or(&fact_id)
4277 .to_string();
4278 let store = &self.bridge.store;
4279 let result = tokio::task::block_in_place(|| {
4280 Handle::current().block_on(store.update_fact(&bare, &content))
4281 });
4282 match result {
4283 Ok(()) => json_to_output(&serde_json::json!({
4284 "ok": true,
4285 "receipt": mcp_receipt("sm_update_fact"),
4286 "fact_id": format!("fact:{bare}"),
4287 "message": "Fact content updated and re-embedded",
4288 })),
4289 Err(e) => Err(ErrorData::internal_error(
4290 format!("update_fact error: {e}"),
4291 None,
4292 )),
4293 }
4294 }
4295
4296 #[tool(
4297 description = "Consolidate two near-duplicate facts into one. Merges their content, updates the kept fact, and supersedes the other with a 'consolidated with' edge. Use this to clean up duplicate knowledge."
4298 )]
4299 fn sm_consolidate_facts(
4300 &self,
4301 Parameters(ConsolidateFactsParams {
4302 keep_id,
4303 supersede_id,
4304 merged_content,
4305 }): Parameters<ConsolidateFactsParams>,
4306 ) -> Result<Json<StructuredOutput>, ErrorData> {
4307 let keep_bare = keep_id
4308 .strip_prefix("fact:")
4309 .unwrap_or(&keep_id)
4310 .to_string();
4311 let sup_bare = supersede_id
4312 .strip_prefix("fact:")
4313 .unwrap_or(&supersede_id)
4314 .to_string();
4315 let store = &self.bridge.store;
4316
4317 let keep_fact =
4319 tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&keep_bare)));
4320 let sup_fact =
4321 tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&sup_bare)));
4322
4323 let (_namespace, final_content) = match (keep_fact, sup_fact) {
4324 (Ok(Some(k)), Ok(Some(s))) => {
4325 let ns = k.namespace.clone();
4326 let content = merged_content.unwrap_or_else(|| {
4327 if k.content.len() >= s.content.len() {
4328 if !k.content.contains(&s.content) {
4329 format!("{}\n\nAdditional: {}", k.content, s.content)
4330 } else {
4331 k.content.clone()
4332 }
4333 } else if !s.content.contains(&k.content) {
4334 format!("{}\n\nAdditional: {}", s.content, k.content)
4335 } else {
4336 s.content.clone()
4337 }
4338 });
4339 (ns, content)
4340 }
4341 (Ok(Some(k)), _) => (
4342 k.namespace.clone(),
4343 merged_content.unwrap_or(k.content.clone()),
4344 ),
4345 (Err(_), _) | (Ok(None), _) => {
4346 return Err(ErrorData::internal_error(
4347 "keep fact not found".to_string(),
4348 None,
4349 ));
4350 }
4351 };
4352
4353 let result = tokio::task::block_in_place(|| {
4355 Handle::current().block_on(store.consolidate_facts(
4356 &keep_bare,
4357 &sup_bare,
4358 &final_content,
4359 ))
4360 });
4361
4362 match result {
4363 Ok(()) => json_to_output(&serde_json::json!({
4364 "ok": true,
4365 "receipt": mcp_receipt("sm_consolidate_facts"),
4366 "kept_fact_id": format!("fact:{}", keep_bare),
4367 "superseded_fact_id": format!("fact:{}", sup_bare),
4368 "message": format!("Facts consolidated atomically: kept fact:{} supersedes fact:{}", keep_bare, sup_bare),
4369 })),
4370 Err(e) => Err(ErrorData::internal_error(
4371 format!("consolidation failed: {e}"),
4372 None,
4373 )),
4374 }
4375 }
4376
4377 #[tool(
4380 description = "Return the current persisted RL routing policy, including weights, training example count, and last update time.",
4381 annotations(read_only_hint = true)
4382 )]
4383 fn sm_get_routing_policy(&self) -> Result<Json<StructuredOutput>, ErrorData> {
4384 use semantic_memory::rl_routing::is_trained;
4385
4386 let policy = tokio::task::block_in_place(|| {
4387 Handle::current().block_on(self.bridge.store.load_routing_policy())
4388 })
4389 .map_err(|e| ErrorData::internal_error(format!("load routing policy error: {e}"), None))?;
4390
4391 match policy {
4392 Some(policy) => json_to_output(&serde_json::json!({
4393 "ok": true,
4394 "policy": {
4395 "weights": policy.weights,
4396 "training_examples_count": policy.trained_examples,
4397 "trained": is_trained(&policy),
4398 "last_updated": policy.last_updated,
4399 }
4400 })),
4401 None => json_to_output(&serde_json::json!({
4402 "ok": true,
4403 "policy": null,
4404 })),
4405 }
4406 }
4407
4408 #[tool(
4409 description = "MUTATING: record a caller-supplied proxy feedback label for retrieval routing. This is not a verified outcome. Persists the updated tabular routing policy every 10 outcomes.",
4410 annotations(
4411 read_only_hint = false,
4412 destructive_hint = false,
4413 idempotent_hint = false
4414 )
4415 )]
4416 fn sm_record_outcome(
4417 &self,
4418 Parameters(RecordOutcomeParams { query, outcome }): Parameters<RecordOutcomeParams>,
4419 ) -> Result<Json<StructuredOutput>, ErrorData> {
4420 use semantic_memory::rl_routing::{
4421 is_trained, record_routing_outcome, route_with_policy, RoutingOutcome,
4422 };
4423 use semantic_memory::routing::{QueryProfile, RetrievalRouter};
4424
4425 let outcome_enum = match outcome.to_lowercase().as_str() {
4426 "good" => RoutingOutcome::Good,
4427 "bad" => RoutingOutcome::Bad,
4428 "neutral" => RoutingOutcome::Neutral,
4429 _ => {
4430 return Err(ErrorData::invalid_params(
4431 format!("outcome must be 'good', 'bad', or 'neutral', got '{outcome}'"),
4432 None,
4433 ));
4434 }
4435 };
4436
4437 let profile = QueryProfile::from_query(&query);
4438 let router = RetrievalRouter::default();
4439 let store = &self.bridge.store;
4440 let mut batch = self
4441 .routing_policy_batch
4442 .lock()
4443 .map_err(|_| ErrorData::internal_error("routing policy batch lock poisoned", None))?;
4444 let mut policy = match batch.policy.take() {
4445 Some(policy) => policy,
4446 None => tokio::task::block_in_place(|| {
4447 Handle::current().block_on(store.load_routing_policy())
4448 })
4449 .map_err(|e| {
4450 ErrorData::internal_error(format!("load routing policy error: {e}"), None)
4451 })?
4452 .unwrap_or_default(),
4453 };
4454 let decision = if is_trained(&policy) {
4455 route_with_policy(&policy, &profile)
4456 } else {
4457 router.route(&profile)
4458 };
4459 record_routing_outcome(&mut policy, &profile, &decision, outcome_enum);
4460 batch.pending_outcomes += 1;
4461 let persisted = batch.pending_outcomes >= ROUTING_POLICY_PERSIST_BATCH;
4462 if persisted {
4463 if let Err(e) = tokio::task::block_in_place(|| {
4464 Handle::current().block_on(store.save_routing_policy(&policy))
4465 }) {
4466 batch.policy = Some(policy);
4467 return Err(ErrorData::internal_error(
4468 format!("persist routing policy error: {e}"),
4469 None,
4470 ));
4471 }
4472 batch.pending_outcomes = 0;
4473 }
4474 let pending_outcomes = batch.pending_outcomes;
4475 batch.policy = Some(policy.clone());
4476
4477 json_to_output(&serde_json::json!({
4478 "ok": true,
4479 "receipt": mcp_receipt("sm_record_outcome"),
4480 "mutating": true,
4481 "query": query,
4482 "feedback": {"kind": "ProxyLabel", "label": outcome},
4483 "routing_decision": {
4484 "bm25_coarse": decision.bm25_coarse,
4485 "vector_medium": decision.vector_medium,
4486 "rerank_fine": decision.rerank_fine,
4487 "graph_expansion": decision.graph_expansion,
4488 "decoder": decision.decoder,
4489 "discord": decision.discord,
4490 "no_retrieval": decision.no_retrieval,
4491 "reasoning": decision.reasoning,
4492 },
4493 "policy_state": {
4494 "trained_examples": policy.trained_examples,
4495 "baseline": policy.baseline,
4496 "weights": policy.weights,
4497 "last_updated": policy.last_updated,
4498 "persisted": persisted,
4499 "pending_outcomes": pending_outcomes,
4500 },
4501 "message": if persisted {
4502 "Routing outcome recorded and policy batch persisted to DB"
4503 } else {
4504 "Routing outcome recorded; policy persistence batch pending"
4505 },
4506 }))
4507 }
4508
4509 #[cfg(feature = "claim-integration")]
4512 #[tool(
4513 description = "Verify and checkpoint the claim ledger when configured entry/byte thresholds are exceeded. Defaults to dry_run=true. A successful rotation atomically activates a digest-verified snapshot and retained tail, emits a compaction receipt bound to the prior ledger head, and keeps bounded backups.",
4514 annotations(read_only_hint = false, destructive_hint = true)
4515 )]
4516 fn sm_compact_claim_ledger(
4517 &self,
4518 Parameters(CompactClaimLedgerParams {
4519 dry_run,
4520 max_entries,
4521 max_bytes,
4522 retain_tail_entries,
4523 max_backups,
4524 }): Parameters<CompactClaimLedgerParams>,
4525 ) -> Result<Json<StructuredOutput>, ErrorData> {
4526 let mut ledger = self.claim_ledger_store.lock().unwrap();
4527 let result = ledger
4528 .compact(ClaimLedgerCompactionConfig {
4529 dry_run: dry_run.unwrap_or(true),
4530 max_entries: max_entries.unwrap_or(10_000),
4531 max_bytes: max_bytes.unwrap_or(16 * 1024 * 1024),
4532 retain_tail_entries: retain_tail_entries.unwrap_or(256),
4533 max_backups: max_backups.unwrap_or(3),
4534 })
4535 .map_err(|error| ErrorData::internal_error(error, None))?;
4536 if result["compacted"].as_bool().unwrap_or(false) {
4537 let mut index = self.claim_trust.lock().unwrap();
4538 index.disable();
4539 if ledger.trust_enabled {
4540 index.enabled = true;
4541 if let Some(snapshot) = &ledger.snapshot {
4542 index.load_snapshot(snapshot);
4543 }
4544 index.rebuild_from_ledger_incremental(&ledger.entries);
4545 }
4546 }
4547 json_to_output(&result)
4548 }
4549
4550 #[cfg(feature = "claim-integration")]
4551 #[tool(
4552 description = "Create a typed Claim from a semantic-memory fact. The claim gets a source-spanned provenance record from the fact's metadata. Returns the claim ID.",
4553 annotations(read_only_hint = false, idempotent_hint = true)
4554 )]
4555 fn sm_create_claim(
4556 &self,
4557 Parameters(CreateClaimParams {
4558 fact_id,
4559 source_span,
4560 }): Parameters<CreateClaimParams>,
4561 ) -> Result<Json<StructuredOutput>, ErrorData> {
4562 use claim_ledger::Claim;
4563 let bare = fact_id
4564 .strip_prefix("fact:")
4565 .unwrap_or(&fact_id)
4566 .to_string();
4567 let store = &self.bridge.store;
4568
4569 let fact =
4571 tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&bare)));
4572 let fact = match fact {
4573 Ok(Some(f)) => f,
4574 _ => {
4575 return Err(ErrorData::internal_error(
4576 format!("fact not found: {fact_id}"),
4577 None,
4578 ))
4579 }
4580 };
4581
4582 let source_id = format!("semantic-memory:fact:{bare}");
4584 let span_id = source_span.unwrap_or_else(|| "full".to_string());
4585 let claim = Claim::new(&source_id, &span_id, &fact.content, "fact");
4586
4587 let claim_id = claim.claim_id.clone();
4588 let normalized = &claim.normalized_claim;
4589
4590 {
4591 let mut ledger = self.claim_ledger_store.lock().unwrap();
4592 let sequence = ledger.next_sequence();
4593 let previous_digest = ledger.last_digest();
4594 let entry = claim_ledger::LedgerEntryBuilder::new(sequence, previous_digest)
4595 .add_claim(&claim_id, &source_id, &span_id, normalized)
4596 .map_err(|e| {
4597 ErrorData::internal_error(
4598 format!("failed to build claim ledger entry: {e}"),
4599 None,
4600 )
4601 })?;
4602 ledger.append(entry).map_err(|e| {
4603 ErrorData::internal_error(format!("failed to record claim to ledger: {e}"), None)
4604 })?;
4605 }
4606
4607 {
4608 let mut idx = self.claim_trust.lock().unwrap();
4609 idx.link_fact(bare.clone(), claim_id.clone());
4610 idx.register_claim(claim_id.clone(), normalized.clone());
4611 }
4612
4613 json_to_output(&serde_json::json!({
4614 "ok": true,
4615 "receipt": mcp_receipt("sm_create_claim"),
4616 "claim_id": claim_id,
4617 "source_id": source_id,
4618 "span_id": span_id,
4619 "claim_text": fact.content,
4620 "normalized_claim": normalized,
4621 "claim_type": "fact",
4622 "message": "Claim created from semantic-memory fact with source-spanned provenance",
4623 }))
4624 }
4625
4626 #[cfg(feature = "claim-integration")]
4627 #[tool(
4628 description = "Add evidence to a claim. Creates an EvidenceBundle linking the evidence text to the claim. Returns the evidence bundle ID.",
4629 annotations(read_only_hint = false)
4630 )]
4631 fn sm_add_evidence(
4632 &self,
4633 Parameters(AddEvidenceParams {
4634 claim_id,
4635 evidence_text,
4636 source_type,
4637 }): Parameters<AddEvidenceParams>,
4638 ) -> Result<Json<StructuredOutput>, ErrorData> {
4639 use claim_ledger::{EvidenceBundle, EvidenceLink, EvidenceRelation};
4640 let mut bundle = EvidenceBundle::new(&claim_id);
4641 let link = EvidenceLink {
4642 relation: EvidenceRelation::Supports,
4643 source_id: source_type.unwrap_or_else(|| "semantic-memory".to_string()),
4644 span_id: "full".to_string(),
4645 quote: evidence_text.clone(),
4646 digest: claim_ledger::ids::sha256_text(&evidence_text),
4647 support_role: "supporting".to_string(),
4648 };
4649 bundle.evidence_links.push(link);
4650
4651 let evidence_path = self.bridge.memory_dir.join("evidence_bundles.jsonl");
4653 let line = serde_json::to_string(&bundle).map_err(|e| {
4654 ErrorData::internal_error(format!("failed to serialize evidence bundle: {e}"), None)
4655 })?;
4656 use std::io::Write;
4657 let mut file = std::fs::OpenOptions::new()
4658 .create(true)
4659 .append(true)
4660 .open(&evidence_path)
4661 .map_err(|e| {
4662 ErrorData::internal_error(
4663 format!("failed to open evidence bundles file: {e}"),
4664 None,
4665 )
4666 })?;
4667 writeln!(file, "{line}").map_err(|e| {
4668 ErrorData::internal_error(format!("failed to write evidence bundle: {e}"), None)
4669 })?;
4670 file.sync_data().map_err(|e| {
4671 ErrorData::internal_error(format!("failed to fsync evidence bundle: {e}"), None)
4672 })?;
4673
4674 json_to_output(&serde_json::json!({
4675 "ok": true,
4676 "receipt": mcp_receipt("sm_add_evidence"),
4677 "evidence_bundle_id": bundle.evidence_bundle_id,
4678 "claim_id": claim_id,
4679 "evidence_count": bundle.evidence_links.len(),
4680 "message": "Evidence added to claim and persisted",
4681 }))
4682 }
4683
4684 #[cfg(feature = "claim-integration")]
4685 #[tool(
4686 description = "Judge the support state of a claim. Creates a SupportJudgment (supported, unsupported, contested, or heuristic_only) with optional rationale.",
4687 annotations(read_only_hint = false)
4688 )]
4689 fn sm_judge_support(
4690 &self,
4691 Parameters(JudgeSupportParams {
4692 claim_id,
4693 judgment,
4694 rationale,
4695 }): Parameters<JudgeSupportParams>,
4696 ) -> Result<Json<StructuredOutput>, ErrorData> {
4697 use claim_ledger::{SupportJudgment, SupportState};
4698 let state = match judgment.to_lowercase().as_str() {
4699 "supported" => SupportState::Supported,
4700 "partially_supported" | "partial" => SupportState::PartiallySupported,
4701 "unsupported" => SupportState::Unsupported,
4702 "contradicted" | "contested" => SupportState::Contradicted,
4703 "heuristic_only" | "heuristic" => SupportState::HeuristicOnly,
4704 _ => return Err(ErrorData::invalid_params(
4705 format!("Invalid judgment '{judgment}'. Must be: supported, partially_supported, unsupported, contradicted, or heuristic_only"),
4706 None,
4707 )),
4708 };
4709 let j = SupportJudgment {
4710 support_judgment_id: claim_ledger::ids::ulid(),
4711 claim_id: claim_id.clone(),
4712 evidence_bundle_ref: claim_ledger::ids::evidence_bundle_id(&claim_id),
4713 support_state: state,
4714 method: "agent_judgment".to_string(),
4715 rationale: rationale.unwrap_or_default(),
4716 contradiction_refs: Vec::new(),
4717 proof_debt: Vec::new(),
4718 created_recorded_time: chrono::Utc::now(),
4719 };
4720
4721 {
4722 let mut ledger = self.claim_ledger_store.lock().unwrap();
4723 let sequence = ledger.next_sequence();
4724 let previous_digest = ledger.last_digest();
4725 let entry = claim_ledger::LedgerEntryBuilder::new(sequence, previous_digest)
4726 .add_support_judgment(
4727 &j.support_judgment_id,
4728 &claim_id,
4729 &j.evidence_bundle_ref,
4730 state,
4731 &j.method,
4732 )
4733 .map_err(|e| {
4734 ErrorData::internal_error(
4735 format!("failed to build support judgment ledger entry: {e}"),
4736 None,
4737 )
4738 })?;
4739 ledger.append(entry).map_err(|e| {
4740 ErrorData::internal_error(
4741 format!("failed to record support judgment to ledger: {e}"),
4742 None,
4743 )
4744 })?;
4745 }
4746
4747 self.claim_trust
4748 .lock()
4749 .unwrap()
4750 .record_judgment(claim_id.clone(), state);
4751
4752 json_to_output(&serde_json::json!({
4753 "ok": true,
4754 "receipt": mcp_receipt("sm_judge_support"),
4755 "support_judgment_id": j.support_judgment_id,
4756 "claim_id": claim_id,
4757 "state": judgment.to_lowercase(),
4758 "message": "Support judgment recorded",
4759 }))
4760 }
4761
4762 #[tool(
4765 description = "Search facts that were valid (not superseded) as of a specific date. Uses bitemporal fields to filter results to only include facts that existed on the specified date.",
4766 annotations(read_only_hint = true)
4767 )]
4768 fn sm_search_as_of(
4769 &self,
4770 Parameters(SearchAsOfParams {
4771 query,
4772 as_of_date,
4773 top_k,
4774 namespace,
4775 }): Parameters<SearchAsOfParams>,
4776 ) -> Result<Json<StructuredOutput>, ErrorData> {
4777 let store = &self.bridge.store;
4778 let k = top_k.unwrap_or(5);
4779 let ns_slice: Option<Vec<&str>> = namespace.as_ref().map(|n| vec![n.as_str()]);
4780
4781 let _as_of = chrono::DateTime::parse_from_rfc3339(&as_of_date)
4783 .map_err(|e| ErrorData::invalid_params(
4784 format!("Invalid as_of_date '{as_of_date}': {e}. Use ISO 8601 format like 2026-01-15T00:00:00Z"),
4785 None,
4786 ))?
4787 .with_timezone(&chrono::Utc);
4788
4789 let results = tokio::task::block_in_place(|| {
4791 Handle::current().block_on(store.search_with_view(
4792 &query,
4793 Some(k * 2),
4794 ns_slice.as_deref(),
4795 None,
4796 semantic_memory::StateView::HistoricalAt(as_of_date.clone()),
4797 ))
4798 })
4799 .map_err(|e| ErrorData::internal_error(format!("search error: {e}"), None))?;
4800
4801 let filtered: Vec<_> = results.into_iter().take(k).collect();
4802
4803 let result_json: Vec<serde_json::Value> = filtered
4804 .iter()
4805 .map(|r| {
4806 serde_json::json!({
4807 "result_id": r.source.result_id(),
4808 "content": r.content,
4809 "score": r.score,
4810 })
4811 })
4812 .collect();
4813
4814 json_to_output(&serde_json::json!({
4815 "ok": true,
4816 "query": query,
4817 "as_of_date": as_of_date,
4818 "results": result_json,
4819 "count": filtered.len(),
4820 "message": format!("Found {} facts valid as of {}", filtered.len(), as_of_date),
4821 }))
4822 }
4823
4824 #[tool(
4827 description = "Verify a claim against risk class requirements. Low/medium claims need cheap checks. High claims need falsification. Critical claims need replay AND falsification. Returns disposition: promote, reject, quarantine, or defer.",
4828 annotations(read_only_hint = true)
4829 )]
4830 fn sm_verify_claim(
4831 &self,
4832 Parameters(VerifyClaimParams {
4833 claim,
4834 risk_class,
4835 evidence_refs,
4836 refutation_attempted,
4837 }): Parameters<VerifyClaimParams>,
4838 ) -> Result<Json<StructuredOutput>, ErrorData> {
4839 let risk = risk_class.to_lowercase();
4840 let has_evidence = evidence_refs
4841 .as_ref()
4842 .map(|v| !v.is_empty())
4843 .unwrap_or(false);
4844 let refuted = refutation_attempted.unwrap_or(false);
4845
4846 let (needs_replay, needs_falsification, disposition, rationale) = match risk.as_str() {
4848 "low" => (
4849 false,
4850 false,
4851 "promote",
4852 "Low risk: cheap checks only, claim can be promoted",
4853 ),
4854 "medium" => (
4855 true,
4856 false,
4857 "promote",
4858 "Medium risk: replay check required, claim can be promoted",
4859 ),
4860 "high" => (
4861 true,
4862 true,
4863 if refuted {
4864 "quarantine"
4865 } else if has_evidence {
4866 "promote"
4867 } else {
4868 "defer"
4869 },
4870 if refuted {
4871 "High risk: refutation attempted, claim quarantined"
4872 } else if has_evidence {
4873 "High risk: falsification passed with evidence, claim promoted"
4874 } else {
4875 "High risk: no evidence provided, claim deferred"
4876 },
4877 ),
4878 "critical" => (
4879 true,
4880 true,
4881 if refuted {
4882 "quarantine"
4883 } else if has_evidence && refutation_attempted == Some(true) {
4884 "promote"
4885 } else {
4886 "defer"
4887 },
4888 if refuted {
4889 "Critical risk: refutation found, claim quarantined"
4890 } else if has_evidence && refutation_attempted == Some(true) {
4891 "Critical risk: replay + falsification passed, claim promoted"
4892 } else {
4893 "Critical risk: requires evidence AND refutation, claim deferred"
4894 },
4895 ),
4896 _ => {
4897 return Err(ErrorData::invalid_params(
4898 format!("Invalid risk_class '{risk}'. Must be: low, medium, high, or critical"),
4899 None,
4900 ))
4901 }
4902 };
4903
4904 json_to_output(&serde_json::json!({
4905 "ok": true,
4906 "claim": claim,
4907 "risk_class": risk,
4908 "required_checks": {
4909 "cheap_checks": true,
4910 "replay_checks": needs_replay,
4911 "falsification_checks": needs_falsification,
4912 },
4913 "has_evidence": has_evidence,
4914 "refutation_attempted": refuted,
4915 "disposition": disposition,
4916 "rationale": rationale,
4917 "can_promote": disposition == "promote",
4918 }))
4919 }
4920
4921 #[tool(
4924 description = "Load a durable search receipt by receipt/request ID. Returns the stored receipt with evaluation time, retrieval family, result IDs, and digests.",
4925 annotations(read_only_hint = true)
4926 )]
4927 fn sm_get_search_receipt(
4928 &self,
4929 Parameters(GetSearchReceiptParams { receipt_id }): Parameters<GetSearchReceiptParams>,
4930 ) -> Result<Json<StructuredOutput>, ErrorData> {
4931 let store = &self.bridge.store;
4932 let result = tokio::task::block_in_place(|| {
4933 Handle::current().block_on(store.get_search_receipt(&receipt_id))
4934 });
4935 match result {
4936 Ok(Some(receipt)) => json_to_output(&serde_json::json!({
4937 "ok": true,
4938 "receipt": {
4939 "receipt_id": receipt.receipt_id,
4940 "trace_id": receipt.trace_id,
4941 "search_profile": receipt.search_profile,
4942 "evaluation_time": receipt.evaluation_time,
4943 "result_ids": receipt.result_ids,
4944 "query_embedding_digest": receipt.query_embedding_digest,
4945 "query_text_digest": receipt.query_text_digest,
4946 "query_input_digest": receipt.query_input_digest,
4947 "filter_digest": receipt.filter_digest,
4948 "redaction_state": receipt.redaction_state,
4949 "approximate": receipt.approximate,
4950 "attempt_family_id": receipt.attempt_family_id,
4951 "budget_id": receipt.budget_id,
4952 },
4953 })),
4954 Ok(None) => json_to_output(&serde_json::json!({
4955 "ok": true,
4956 "found": false,
4957 "receipt_id": receipt_id,
4958 "message": "No receipt found with that ID",
4959 })),
4960 Err(e) => Err(ErrorData::internal_error(
4961 format!("get_search_receipt error: {e}"),
4962 None,
4963 )),
4964 }
4965 }
4966
4967 #[tool(
4968 description = "Replay a durable search receipt with caller-supplied query text and filters. Compares original results to replay results, reporting matches, missing IDs, and added IDs.",
4969 annotations(read_only_hint = true)
4970 )]
4971 fn sm_replay_search_receipt(
4972 &self,
4973 Parameters(ReplaySearchReceiptParams {
4974 receipt_id,
4975 query,
4976 top_k,
4977 namespaces,
4978 }): Parameters<ReplaySearchReceiptParams>,
4979 ) -> Result<Json<StructuredOutput>, ErrorData> {
4980 let store = &self.bridge.store;
4981 let k = top_k.map(|v| v as usize);
4982 let ns_slice: Option<Vec<&str>> = namespaces
4983 .as_ref()
4984 .map(|v| v.iter().map(|s| s.as_str()).collect());
4985
4986 let result = tokio::task::block_in_place(|| {
4987 Handle::current().block_on(store.replay_search_receipt(
4988 &receipt_id,
4989 &query,
4990 k,
4991 ns_slice.as_deref(),
4992 None,
4993 ))
4994 });
4995 match result {
4996 Ok(report) => json_to_output(&serde_json::json!({
4997 "ok": true,
4998 "receipt_id": report.receipt_id,
4999 "replay_receipt_id": report.replay_receipt_id,
5000 "query_embedding_digest_matches": report.query_embedding_digest_matches,
5001 "result_ids_match": report.result_ids_match,
5002 "missing_result_ids": report.missing_result_ids,
5003 "added_result_ids": report.added_result_ids,
5004 "original_receipt": {
5005 "receipt_id": report.original_receipt.receipt_id,
5006 "result_ids": report.original_receipt.result_ids,
5007 "search_profile": report.original_receipt.search_profile,
5008 "evaluation_time": report.original_receipt.evaluation_time,
5009 },
5010 "replay_receipt": {
5011 "receipt_id": report.replay_receipt.receipt_id,
5012 "result_ids": report.replay_receipt.result_ids,
5013 "search_profile": report.replay_receipt.search_profile,
5014 "evaluation_time": report.replay_receipt.evaluation_time,
5015 },
5016 })),
5017 Err(e) => Err(ErrorData::internal_error(
5018 format!("replay_search_receipt error: {e}"),
5019 None,
5020 )),
5021 }
5022 }
5023
5024 #[tool(
5025 description = "Replay a durable search receipt using query text and filters retained by explicit opt-in. Returns the replay comparison report.",
5026 annotations(read_only_hint = true)
5027 )]
5028 fn sm_replay_search(
5029 &self,
5030 Parameters(ReplayStoredSearchParams { receipt_id }): Parameters<ReplayStoredSearchParams>,
5031 ) -> Result<Json<StructuredOutput>, ErrorData> {
5032 let result = tokio::task::block_in_place(|| {
5033 Handle::current().block_on(
5034 self.bridge
5035 .store
5036 .replay_search_from_stored_inputs(&receipt_id),
5037 )
5038 });
5039 match result {
5040 Ok(report) => json_to_output(&serde_json::json!({
5041 "ok": true,
5042 "receipt_id": report.receipt_id,
5043 "replay_receipt_id": report.replay_receipt_id,
5044 "query_embedding_digest_matches": report.query_embedding_digest_matches,
5045 "result_ids_match": report.result_ids_match,
5046 "missing_result_ids": report.missing_result_ids,
5047 "added_result_ids": report.added_result_ids,
5048 "original_result_ids": report.original_receipt.result_ids,
5049 "replay_result_ids": report.replay_receipt.result_ids,
5050 })),
5051 Err(e) => Err(ErrorData::internal_error(
5052 format!("replay_search error: {e}"),
5053 None,
5054 )),
5055 }
5056 }
5057
5058 #[tool(
5061 description = "Reconcile detected integrity issues. Actions: report_only (just check), rebuild_fts (rebuild FTS indexes), re_embed (re-embed all content). Returns an integrity report after the action.",
5062 annotations(idempotent_hint = true)
5063 )]
5064 fn sm_reconcile(
5065 &self,
5066 Parameters(ReconcileParams { action }): Parameters<ReconcileParams>,
5067 ) -> Result<Json<StructuredOutput>, ErrorData> {
5068 let action_enum = match action.to_lowercase().as_str() {
5069 "report_only" | "report-only" => semantic_memory::ReconcileAction::ReportOnly,
5070 "rebuild_fts" | "rebuild-fts" => semantic_memory::ReconcileAction::RebuildFts,
5071 "re_embed" | "re-embed" | "reembed" => semantic_memory::ReconcileAction::ReEmbed,
5072 _ => {
5073 return Err(ErrorData::invalid_params(
5074 format!("action must be 'report_only', 'rebuild_fts', or 're_embed', got '{action}'"),
5075 None,
5076 ));
5077 }
5078 };
5079 let store = &self.bridge.store;
5080 let result = tokio::task::block_in_place(|| {
5081 Handle::current().block_on(store.reconcile(action_enum))
5082 });
5083 match result {
5084 Ok(report) => json_to_output(&serde_json::json!({
5085 "ok": report.ok,
5086 "schema_version": report.schema_version,
5087 "fact_count": report.fact_count,
5088 "chunk_count": report.chunk_count,
5089 "message_count": report.message_count,
5090 "facts_missing_embeddings": report.facts_missing_embeddings,
5091 "chunks_missing_embeddings": report.chunks_missing_embeddings,
5092 "issues": report.issues,
5093 "issue_count": report.issues.len(),
5094 "action": action,
5095 })),
5096 Err(e) => Err(ErrorData::internal_error(
5097 format!("reconcile error: {e}"),
5098 None,
5099 )),
5100 }
5101 }
5102
5103 #[tool(
5106 description = "Vacuum the database to reclaim space after deletions. This is a maintenance operation that may take a moment.",
5107 annotations(idempotent_hint = true)
5108 )]
5109 fn sm_vacuum(&self) -> Result<Json<StructuredOutput>, ErrorData> {
5110 let store = &self.bridge.store;
5111 let result = tokio::task::block_in_place(|| Handle::current().block_on(store.vacuum()));
5112 match result {
5113 Ok(()) => json_to_output(&serde_json::json!({
5114 "ok": true,
5115 "receipt": mcp_receipt("sm_vacuum"),
5116 "message": "Database vacuumed successfully",
5117 })),
5118 Err(e) => Err(ErrorData::internal_error(
5119 format!("vacuum error: {e}"),
5120 None,
5121 )),
5122 }
5123 }
5124
5125 #[tool(
5126 description = "Re-embed all facts, chunks, messages, and episodes. Call after changing embedding models. Returns the count of items re-embedded.",
5127 annotations(idempotent_hint = true)
5128 )]
5129 fn sm_reembed_all(&self) -> Result<Json<StructuredOutput>, ErrorData> {
5130 let store = &self.bridge.store;
5131 let result =
5132 tokio::task::block_in_place(|| Handle::current().block_on(store.reembed_all()));
5133 match result {
5134 Ok(count) => json_to_output(&serde_json::json!({
5135 "ok": true,
5136 "receipt": mcp_receipt("sm_reembed_all"),
5137 "reembedded_count": count,
5138 "message": format!("Re-embedded {count} items"),
5139 })),
5140 Err(e) => Err(ErrorData::internal_error(
5141 format!("reembed_all error: {e}"),
5142 None,
5143 )),
5144 }
5145 }
5146
5147 #[tool(
5148 description = "Check if embeddings need re-generation after a model change. Returns true if the embedding model or dimensions have changed since the last embedding was stored.",
5149 annotations(read_only_hint = true)
5150 )]
5151 fn sm_embeddings_are_dirty(
5152 &self,
5153 Parameters(_params): Parameters<EmbeddingsAreDirtyParams>,
5154 ) -> Result<Json<StructuredOutput>, ErrorData> {
5155 let store = &self.bridge.store;
5156 let result = tokio::task::block_in_place(|| {
5157 Handle::current().block_on(store.embeddings_are_dirty())
5158 });
5159 match result {
5160 Ok(dirty) => json_to_output(&serde_json::json!({
5161 "ok": true,
5162 "dirty": dirty,
5163 "message": if dirty { "Embeddings are dirty and need re-generation. Call sm_reembed_all." } else { "Embeddings are up to date" },
5164 })),
5165 Err(e) => Err(ErrorData::internal_error(
5166 format!("embeddings_are_dirty error: {e}"),
5167 None,
5168 )),
5169 }
5170 }
5171
5172 #[tool(
5175 description = "Query imported claim projection rows. Filters by scope, text, valid-time, and claim state. Returns claim version rows with full provenance.",
5176 annotations(read_only_hint = true)
5177 )]
5178 fn sm_query_claim_versions(
5179 &self,
5180 Parameters(params): Parameters<ProjectionQueryParams>,
5181 ) -> Result<Json<StructuredOutput>, ErrorData> {
5182 let store = &self.bridge.store;
5183 let query = build_projection_query(params);
5184 let result = tokio::task::block_in_place(|| {
5185 Handle::current().block_on(store.query_claim_versions(query))
5186 });
5187 match result {
5188 Ok(rows) => json_to_output(&serde_json::json!({
5189 "ok": true,
5190 "results": serde_json::to_value(&rows).unwrap_or_else(|_| serde_json::json!([])),
5191 "count": rows.len(),
5192 })),
5193 Err(e) => Err(ErrorData::internal_error(
5194 format!("query_claim_versions error: {e}"),
5195 None,
5196 )),
5197 }
5198 }
5199
5200 #[tool(
5201 description = "Query imported relation projection rows. Filters by scope, text, valid-time, and subject entity. Returns relation version rows with full provenance.",
5202 annotations(read_only_hint = true)
5203 )]
5204 fn sm_query_relation_versions(
5205 &self,
5206 Parameters(params): Parameters<ProjectionQueryParams>,
5207 ) -> Result<Json<StructuredOutput>, ErrorData> {
5208 let store = &self.bridge.store;
5209 let query = build_projection_query(params);
5210 let result = tokio::task::block_in_place(|| {
5211 Handle::current().block_on(store.query_relation_versions(query))
5212 });
5213 match result {
5214 Ok(rows) => json_to_output(&serde_json::json!({
5215 "ok": true,
5216 "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5217 "count": rows.len(),
5218 })),
5219 Err(e) => Err(ErrorData::internal_error(
5220 format!("query_relation_versions error: {e}"),
5221 None,
5222 )),
5223 }
5224 }
5225
5226 #[tool(
5227 description = "Query imported episode projection rows. Filters by scope and text. Returns episode rows with cause/effect and outcome data.",
5228 annotations(read_only_hint = true)
5229 )]
5230 fn sm_query_episodes(
5231 &self,
5232 Parameters(params): Parameters<ProjectionQueryParams>,
5233 ) -> Result<Json<StructuredOutput>, ErrorData> {
5234 let store = &self.bridge.store;
5235 let query = build_projection_query(params);
5236 let result =
5237 tokio::task::block_in_place(|| Handle::current().block_on(store.query_episodes(query)));
5238 match result {
5239 Ok(rows) => json_to_output(&serde_json::json!({
5240 "ok": true,
5241 "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5242 "count": rows.len(),
5243 })),
5244 Err(e) => Err(ErrorData::internal_error(
5245 format!("query_episodes error: {e}"),
5246 None,
5247 )),
5248 }
5249 }
5250
5251 #[tool(
5252 description = "Query imported entity-alias rows. Filters by scope, canonical entity, and text. Returns alias rows with merge and review state.",
5253 annotations(read_only_hint = true)
5254 )]
5255 fn sm_query_entity_aliases(
5256 &self,
5257 Parameters(params): Parameters<ProjectionQueryParams>,
5258 ) -> Result<Json<StructuredOutput>, ErrorData> {
5259 let store = &self.bridge.store;
5260 let query = build_projection_query(params);
5261 let result = tokio::task::block_in_place(|| {
5262 Handle::current().block_on(store.query_entity_aliases(query))
5263 });
5264 match result {
5265 Ok(rows) => json_to_output(&serde_json::json!({
5266 "ok": true,
5267 "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5268 "count": rows.len(),
5269 })),
5270 Err(e) => Err(ErrorData::internal_error(
5271 format!("query_entity_aliases error: {e}"),
5272 None,
5273 )),
5274 }
5275 }
5276
5277 #[tool(
5278 description = "Query imported evidence-reference rows. Filters by scope, claim, and claim version. Returns evidence reference rows with fetch handles and source authority.",
5279 annotations(read_only_hint = true)
5280 )]
5281 fn sm_query_evidence_refs(
5282 &self,
5283 Parameters(params): Parameters<ProjectionQueryParams>,
5284 ) -> Result<Json<StructuredOutput>, ErrorData> {
5285 let store = &self.bridge.store;
5286 let query = build_projection_query(params);
5287 let result = tokio::task::block_in_place(|| {
5288 Handle::current().block_on(store.query_evidence_refs(query))
5289 });
5290 match result {
5291 Ok(rows) => json_to_output(&serde_json::json!({
5292 "ok": true,
5293 "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5294 "count": rows.len(),
5295 })),
5296 Err(e) => Err(ErrorData::internal_error(
5297 format!("query_evidence_refs error: {e}"),
5298 None,
5299 )),
5300 }
5301 }
5302
5303 #[tool(
5306 description = "Import a projection envelope atomically. All records are committed in a single transaction or the entire import is rolled back. Pass the envelope as a JSON string.",
5307 annotations(idempotent_hint = true)
5308 )]
5309 #[allow(deprecated)]
5310 fn sm_import_envelope(
5311 &self,
5312 Parameters(ImportEnvelopeParams { envelope_json }): Parameters<ImportEnvelopeParams>,
5313 ) -> Result<Json<StructuredOutput>, ErrorData> {
5314 let envelope: semantic_memory::projection_import::ImportEnvelope =
5315 serde_json::from_str(&envelope_json).map_err(|e| {
5316 ErrorData::invalid_params(format!("Failed to parse envelope JSON: {e}"), None)
5317 })?;
5318 envelope.validate().map_err(|e| {
5319 ErrorData::invalid_params(format!("Envelope validation failed: {e}"), None)
5320 })?;
5321 let store = &self.bridge.store;
5322 let result = tokio::task::block_in_place(|| {
5323 Handle::current().block_on(store.import_envelope(&envelope))
5324 });
5325 match result {
5326 Ok(receipt) => json_to_output(&serde_json::json!({
5327 "ok": true,
5328 "envelope_id": receipt.envelope_id,
5329 "was_duplicate": receipt.was_duplicate,
5330 "imported_count": receipt.record_count,
5331 "receipt_id": receipt.envelope_id,
5332 })),
5333 Err(e) => Err(ErrorData::internal_error(
5334 format!("import_envelope error: {e}"),
5335 None,
5336 )),
5337 }
5338 }
5339
5340 #[tool(
5341 description = "Check whether an envelope has already been imported. Returns import receipts for the given envelope ID.",
5342 annotations(read_only_hint = true)
5343 )]
5344 #[allow(deprecated)]
5345 fn sm_import_status(
5346 &self,
5347 Parameters(ImportStatusParams { envelope_id }): Parameters<ImportStatusParams>,
5348 ) -> Result<Json<StructuredOutput>, ErrorData> {
5349 use semantic_memory::projection_import::EnvelopeId;
5350 let store = &self.bridge.store;
5351 let env_id = EnvelopeId::new(&envelope_id);
5352 let result = tokio::task::block_in_place(|| {
5353 Handle::current().block_on(store.import_status(&env_id))
5354 });
5355 match result {
5356 Ok(receipts) => json_to_output(&serde_json::json!({
5357 "ok": true,
5358 "envelope_id": envelope_id,
5359 "receipts": serde_json::to_value(&receipts).unwrap_or(serde_json::json!([])),
5360 "count": receipts.len(),
5361 })),
5362 Err(e) => Err(ErrorData::internal_error(
5363 format!("import_status error: {e}"),
5364 None,
5365 )),
5366 }
5367 }
5368
5369 #[tool(
5370 description = "List recent imports, optionally filtered by namespace. Returns import receipt records.",
5371 annotations(read_only_hint = true)
5372 )]
5373 #[allow(deprecated)]
5374 fn sm_list_imports(
5375 &self,
5376 Parameters(ListImportsParams { namespace, limit }): Parameters<ListImportsParams>,
5377 ) -> Result<Json<StructuredOutput>, ErrorData> {
5378 let store = &self.bridge.store;
5379 let lim = limit.unwrap_or(20) as usize;
5380 let result = tokio::task::block_in_place(|| {
5381 Handle::current().block_on(store.list_imports(namespace.as_deref(), lim))
5382 });
5383 match result {
5384 Ok(receipts) => json_to_output(&serde_json::json!({
5385 "ok": true,
5386 "receipts": serde_json::to_value(&receipts).unwrap_or(serde_json::json!([])),
5387 "count": receipts.len(),
5388 })),
5389 Err(e) => Err(ErrorData::internal_error(
5390 format!("list_imports error: {e}"),
5391 None,
5392 )),
5393 }
5394 }
5395 #[cfg(feature = "llm-parser")]
5398 #[tool(
5399 description = "Parse JSON from raw LLM output. Handles think blocks, markdown fences, trailing text, and common JSON errors without an additional LLM call. Returns the extracted JSON as a string.",
5400 annotations(read_only_hint = true)
5401 )]
5402 fn sm_parse_json(
5403 &self,
5404 Parameters(ParseJsonParams { raw_output }): Parameters<ParseJsonParams>,
5405 ) -> Result<Json<StructuredOutput>, ErrorData> {
5406 match llm_output_parser::parse_json::<serde_json::Value>(&raw_output) {
5407 Ok(value) => Ok(structured_output(value)),
5408 Err(e) => Ok(json_to_output(&serde_json::json!({
5409 "ok": false,
5410 "error": e.to_string(),
5411 "input_preview": &raw_output.chars().take(200).collect::<String>(),
5412 }))?),
5413 }
5414 }
5415
5416 #[cfg(feature = "llm-parser")]
5417 #[tool(
5418 description = "Parse JSON from raw LLM output as an untyped serde_json::Value. Useful when the expected schema is unknown.",
5419 annotations(read_only_hint = true)
5420 )]
5421 fn sm_parse_json_value(
5422 &self,
5423 Parameters(ParseJsonValueParams { raw_output }): Parameters<ParseJsonValueParams>,
5424 ) -> Result<Json<StructuredOutput>, ErrorData> {
5425 match llm_output_parser::parse_json_value(&raw_output) {
5426 Ok(value) => Ok(structured_output(value)),
5427 Err(e) => Ok(json_to_output(&serde_json::json!({
5428 "ok": false,
5429 "error": e.to_string(),
5430 }))?),
5431 }
5432 }
5433
5434 #[cfg(feature = "llm-parser")]
5435 #[tool(
5436 description = "Strip </think> blocks from text. Removes chain-of-thought reasoning that some models emit. Returns cleaned text.",
5437 annotations(read_only_hint = true)
5438 )]
5439 fn sm_strip_think_tags(
5440 &self,
5441 Parameters(StripThinkTagsParams { text }): Parameters<StripThinkTagsParams>,
5442 ) -> Result<Json<StructuredOutput>, ErrorData> {
5443 Ok(structured_output(serde_json::json!({
5444 "ok": true,
5445 "text": llm_output_parser::strip_think_tags(&text),
5446 })))
5447 }
5448
5449 #[cfg(feature = "llm-parser")]
5450 #[tool(
5451 description = "Attempt to repair common LLM JSON errors: trailing commas, unquoted keys, single quotes, missing brackets. Returns the repaired JSON string or an error.",
5452 annotations(read_only_hint = true)
5453 )]
5454 fn sm_repair_json(
5455 &self,
5456 Parameters(RepairJsonParams { json_string }): Parameters<RepairJsonParams>,
5457 ) -> Result<Json<StructuredOutput>, ErrorData> {
5458 match llm_output_parser::try_repair_json(&json_string) {
5459 Some(repaired) => Ok(structured_output(serde_json::json!({
5460 "ok": true,
5461 "repaired_json": repaired,
5462 }))),
5463 None => Ok(json_to_output(&serde_json::json!({
5464 "ok": false,
5465 "error": "Could not repair JSON. The input may not be valid JSON even after common fixes.",
5466 }))?),
5467 }
5468 }
5469
5470 #[cfg(feature = "llm-parser")]
5471 #[tool(
5472 description = "Parse a string list from raw LLM output. Handles markdown bullet lists, numbered lists, comma-separated values, and JSON arrays. Returns a JSON array of cleaned strings.",
5473 annotations(read_only_hint = true)
5474 )]
5475 fn sm_parse_string_list(
5476 &self,
5477 Parameters(ParseStringListParams { raw_output }): Parameters<ParseStringListParams>,
5478 ) -> Result<Json<StructuredOutput>, ErrorData> {
5479 match llm_output_parser::parse_string_list(&raw_output) {
5480 Ok(list) => Ok(structured_output(serde_json::json!({
5481 "ok": true,
5482 "values": list,
5483 }))),
5484 Err(e) => Ok(json_to_output(&serde_json::json!({
5485 "ok": false,
5486 "error": e.to_string(),
5487 }))?),
5488 }
5489 }
5490
5491 #[cfg(feature = "llm-parser")]
5492 #[tool(
5493 description = "Parse a choice from raw LLM output given a list of valid options. Handles extra text, casing differences, and partial matches. Returns the matched option or an error.",
5494 annotations(read_only_hint = true)
5495 )]
5496 fn sm_parse_choice(
5497 &self,
5498 Parameters(ParseChoiceParams {
5499 raw_output,
5500 options,
5501 }): Parameters<ParseChoiceParams>,
5502 ) -> Result<Json<StructuredOutput>, ErrorData> {
5503 let opt_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
5504 match llm_output_parser::parse_choice(&raw_output, &opt_refs) {
5505 Ok(choice) => Ok(structured_output(serde_json::json!({
5506 "ok": true,
5507 "choice": choice,
5508 }))),
5509 Err(e) => Ok(json_to_output(&serde_json::json!({
5510 "ok": false,
5511 "error": e.to_string(),
5512 "options": options,
5513 }))?),
5514 }
5515 }
5516
5517 #[cfg(feature = "llm-parser")]
5518 #[tool(
5519 description = "Parse a number from raw LLM output. Handles text like 'The answer is 42' or 'Score: 0.85'. Returns the number as a string.",
5520 annotations(read_only_hint = true)
5521 )]
5522 fn sm_parse_number(
5523 &self,
5524 Parameters(ParseNumberParams { raw_output }): Parameters<ParseNumberParams>,
5525 ) -> Result<Json<StructuredOutput>, ErrorData> {
5526 match llm_output_parser::parse_number::<f64>(&raw_output) {
5527 Ok(n) if n.is_finite() => Ok(structured_output(serde_json::json!({
5528 "ok": true,
5529 "number": n,
5530 }))),
5531 Ok(_) => Ok(structured_output(serde_json::json!({
5532 "ok": false,
5533 "error": "Parsed number must be finite",
5534 }))),
5535 Err(e) => Ok(json_to_output(&serde_json::json!({
5536 "ok": false,
5537 "error": e.to_string(),
5538 }))?),
5539 }
5540 }
5541}
5542
5543#[derive(Debug, Clone, PartialEq, Eq)]
5544pub enum GraphPathOutcome {
5545 Found(Vec<String>),
5546 NoPathWithinCompleteSearch,
5547 BudgetExceeded,
5548 InvalidEndpoint(String),
5549}
5550
5551fn typed_graph_path(
5555 graph: &dyn semantic_memory::GraphView,
5556 from: &str,
5557 to: &str,
5558 max_depth: usize,
5559) -> Result<GraphPathOutcome, semantic_memory::MemoryError> {
5560 use semantic_memory::GraphDirection;
5561 let from_edges = graph.neighbors(from, GraphDirection::Both, 1)?;
5562 if from_edges.is_empty() {
5563 return Ok(GraphPathOutcome::InvalidEndpoint(from.to_string()));
5564 }
5565 let to_edges = graph.neighbors(to, GraphDirection::Both, 1)?;
5566 if to_edges.is_empty() {
5567 return Ok(GraphPathOutcome::InvalidEndpoint(to.to_string()));
5568 }
5569 if from == to {
5570 return Ok(GraphPathOutcome::Found(vec![from.to_string()]));
5571 }
5572
5573 let mut visited = HashSet::from([from.to_string()]);
5574 let mut parents = HashMap::<String, String>::new();
5575 let mut queue = VecDeque::from([(from.to_string(), 0usize)]);
5576 let mut hit_depth_budget = false;
5577 while let Some((node, depth)) = queue.pop_front() {
5578 let edges = graph.neighbors(&node, GraphDirection::Both, 1)?;
5579 for edge in edges {
5580 let next = if edge.source == node {
5581 edge.target
5582 } else {
5583 edge.source
5584 };
5585 if visited.contains(&next) {
5586 continue;
5587 }
5588 if depth >= max_depth {
5589 hit_depth_budget = true;
5590 continue;
5591 }
5592 visited.insert(next.clone());
5593 parents.insert(next.clone(), node.clone());
5594 if next == to {
5595 let mut path = vec![to.to_string()];
5596 let mut cursor = to.to_string();
5597 while let Some(parent) = parents.get(&cursor) {
5598 path.push(parent.clone());
5599 if parent == from {
5600 break;
5601 }
5602 cursor = parent.clone();
5603 }
5604 path.reverse();
5605 return Ok(GraphPathOutcome::Found(path));
5606 }
5607 if visited.len() >= 500 {
5608 return Ok(GraphPathOutcome::BudgetExceeded);
5609 }
5610 queue.push_back((next, depth + 1));
5611 }
5612 }
5613 Ok(if hit_depth_budget {
5614 GraphPathOutcome::BudgetExceeded
5615 } else {
5616 GraphPathOutcome::NoPathWithinCompleteSearch
5617 })
5618}
5619
5620#[cfg(test)]
5621#[allow(clippy::items_after_test_module)]
5622mod correctness_contract_tests {
5623 use super::*;
5624 use crate::bridge::{BridgeConfig, EmbedderBackend};
5625
5626 fn structured_value(body: &Json<StructuredOutput>) -> serde_json::Value {
5627 serde_json::to_value(&body.0).expect("structured tool output JSON")
5628 }
5629
5630 #[test]
5631 fn structured_output_preserves_object_shape_and_populates_protocol_field() {
5632 use rmcp::handler::server::tool::IntoCallToolResult;
5633
5634 let output = structured_output(serde_json::json!({
5635 "ok": true,
5636 "fact_id": "fact-1",
5637 }));
5638 let result = output.into_call_tool_result().expect("tool result");
5639
5640 assert_eq!(
5641 result.structured_content,
5642 Some(serde_json::json!({"ok": true, "fact_id": "fact-1"}))
5643 );
5644 assert_eq!(
5645 result.content.len(),
5646 1,
5647 "rmcp emits compatibility text content"
5648 );
5649 assert_eq!(result.is_error, Some(false));
5650 }
5651
5652 #[test]
5653 fn structured_output_wraps_non_object_values() {
5654 assert_eq!(
5655 structured_value(&structured_output(serde_json::json!(["a", "b"]))),
5656 serde_json::json!({"value": ["a", "b"]})
5657 );
5658 }
5659
5660 #[cfg(feature = "llm-parser")]
5661 #[test]
5662 fn parse_number_rejects_non_finite_values_without_panicking() {
5663 let dir = tempfile::tempdir().unwrap();
5664 let server = SemanticMemoryServer::new(
5665 MemoryBridge::open(BridgeConfig {
5666 memory_dir: dir.path().to_path_buf(),
5667 embedder_backend: EmbedderBackend::Mock,
5668 embedding_url: String::new(),
5669 embedding_model: "mock".into(),
5670 embedding_dims: 768,
5671 turbo_quant_enabled: false,
5672 turbo_quant_bits: None,
5673 turbo_quant_projections: None,
5674 })
5675 .unwrap(),
5676 "full",
5677 );
5678
5679 let output = server
5680 .sm_parse_number(Parameters(ParseNumberParams {
5681 raw_output: "NaN".into(),
5682 }))
5683 .expect("non-finite parse must return a structured result");
5684 let value = structured_value(&output);
5685
5686 assert_eq!(value["ok"], false);
5687 assert!(value["error"]
5688 .as_str()
5689 .is_some_and(|error| error.contains("finite")));
5690 }
5691
5692 #[test]
5693 fn every_registered_tool_declares_object_output_schema() {
5694 let tools = SemanticMemoryServer::tool_router().list_all();
5695 assert!(!tools.is_empty(), "tool router must register tools");
5696
5697 let missing: Vec<_> = tools
5698 .iter()
5699 .filter_map(|tool| {
5700 let schema = tool.output_schema.as_deref()?;
5701 (schema.get("type").and_then(serde_json::Value::as_str) != Some("object"))
5702 .then(|| tool.name.to_string())
5703 })
5704 .chain(
5705 tools
5706 .iter()
5707 .filter(|tool| tool.output_schema.is_none())
5708 .map(|tool| tool.name.to_string()),
5709 )
5710 .collect();
5711
5712 assert!(
5713 missing.is_empty(),
5714 "every tool must expose a root object outputSchema; missing/invalid: {missing:?}"
5715 );
5716 }
5717 use semantic_memory::{GraphDirection, GraphEdge, GraphEdgeType, GraphView, MemoryError};
5718
5719 struct TestGraph(Vec<(&'static str, &'static str)>);
5720
5721 fn add_fact_params(content: &str, idempotency_key: Option<&str>) -> AddFactParams {
5722 AddFactParams {
5723 content: content.into(),
5724 namespace: "authority-test".into(),
5725 source: Some("tests/authority-source.md".into()),
5726 extract_entities: Some(false),
5727 memory_kind: Some("durable_fact".into()),
5728 sensitivity: Some("internal".into()),
5729 evidence_refs: Some(vec!["evidence:authority-test".into()]),
5730 idempotency_key: idempotency_key.map(str::to_string),
5731 }
5732 }
5733
5734 fn invoke_add_fact(
5735 runtime: &tokio::runtime::Runtime,
5736 server: &SemanticMemoryServer,
5737 params: AddFactParams,
5738 ) -> Result<Json<StructuredOutput>, ErrorData> {
5739 runtime.block_on(async {
5740 tokio::task::block_in_place(|| server.sm_add_fact(Parameters(params)))
5741 })
5742 }
5743
5744 fn invoke_record_outcome(
5745 runtime: &tokio::runtime::Runtime,
5746 server: &SemanticMemoryServer,
5747 query: &str,
5748 outcome: &str,
5749 ) -> serde_json::Value {
5750 let body = runtime
5751 .block_on(async {
5752 tokio::task::block_in_place(|| {
5753 server.sm_record_outcome(Parameters(RecordOutcomeParams {
5754 query: query.to_string(),
5755 outcome: outcome.to_string(),
5756 }))
5757 })
5758 })
5759 .expect("record routing outcome");
5760 structured_value(&body)
5761 }
5762
5763 #[test]
5764 fn routing_feedback_batch_persists_and_survives_restart() {
5765 use semantic_memory::rl_routing::{is_trained, route_with_policy};
5766 use semantic_memory::routing::{QueryProfile, RetrievalRouter};
5767
5768 let dir = tempfile::tempdir().unwrap();
5769 let memory_dir = dir.path().to_path_buf();
5770 let make_config = || BridgeConfig {
5771 memory_dir: memory_dir.clone(),
5772 embedder_backend: EmbedderBackend::Mock,
5773 embedding_url: String::new(),
5774 embedding_model: "mock".into(),
5775 embedding_dims: 768,
5776 turbo_quant_enabled: false,
5777 turbo_quant_bits: None,
5778 turbo_quant_projections: None,
5779 };
5780 let runtime = tokio::runtime::Runtime::new().unwrap();
5781 let server = SemanticMemoryServer::new(MemoryBridge::open(make_config()).unwrap(), "full");
5782 let query = "compare rust vs python performance";
5783
5784 for index in 0..ROUTING_POLICY_PERSIST_BATCH {
5785 let receipt = invoke_record_outcome(&runtime, &server, query, "bad");
5786 assert_eq!(
5787 receipt["policy_state"]["persisted"],
5788 index + 1 == ROUTING_POLICY_PERSIST_BATCH
5789 );
5790 }
5791
5792 let persisted = runtime
5793 .block_on(server.bridge.store.load_routing_policy())
5794 .unwrap()
5795 .expect("batched routing policy persisted");
5796 assert_eq!(persisted.trained_examples, ROUTING_POLICY_PERSIST_BATCH);
5797 assert!(persisted.last_updated.is_some());
5798 assert!(is_trained(&persisted));
5799
5800 let profile = QueryProfile::from_query(query);
5801 let heuristic = RetrievalRouter::default().route(&profile);
5802 let before_restart = route_with_policy(&persisted, &profile);
5803 assert_ne!(before_restart.rerank_fine, heuristic.rerank_fine);
5804
5805 let policy_json = runtime
5806 .block_on(async { tokio::task::block_in_place(|| server.sm_get_routing_policy()) })
5807 .unwrap();
5808 let policy_json: serde_json::Value = structured_value(&policy_json);
5809 assert_eq!(
5810 policy_json["policy"]["training_examples_count"],
5811 ROUTING_POLICY_PERSIST_BATCH
5812 );
5813 assert!(policy_json["policy"]["last_updated"].is_string());
5814
5815 drop(server);
5816 let restarted =
5817 SemanticMemoryServer::new(MemoryBridge::open(make_config()).unwrap(), "full");
5818 let loaded = runtime
5819 .block_on(restarted.bridge.store.load_routing_policy())
5820 .unwrap()
5821 .expect("routing policy loaded after restart");
5822 let after_restart = route_with_policy(&loaded, &profile);
5823 assert_eq!(after_restart.bm25_coarse, before_restart.bm25_coarse);
5824 assert_eq!(after_restart.vector_medium, before_restart.vector_medium);
5825 assert_eq!(after_restart.rerank_fine, before_restart.rerank_fine);
5826 assert_eq!(after_restart.decoder, before_restart.decoder);
5827 }
5828
5829 fn governed_decision_server(
5830 scopes: semantic_memory::AuthorityScopesV1,
5831 ) -> (SemanticMemoryServer, tokio::runtime::Runtime, String) {
5832 use semantic_memory::{
5833 AuthorityPermit, ElevationRequirementV1, NamespaceScopeV1, OriginAuthorityLabelV1,
5834 OriginClassV1, OriginRiskV1, RevocationStatusV1, SubjectPrincipalV1,
5835 };
5836
5837 let dir = tempfile::tempdir().unwrap();
5838 let bridge = MemoryBridge::open(BridgeConfig {
5839 memory_dir: dir.path().to_path_buf(),
5840 embedder_backend: EmbedderBackend::Mock,
5841 embedding_url: String::new(),
5842 embedding_model: "mock".into(),
5843 embedding_dims: 768,
5844 turbo_quant_enabled: false,
5845 turbo_quant_bits: None,
5846 turbo_quant_projections: None,
5847 })
5848 .unwrap();
5849 let runtime = tokio::runtime::Runtime::new().unwrap();
5850 let origin = OriginAuthorityLabelV1::new(
5851 OriginClassV1::UserStatement,
5852 "principal:writer",
5853 "governed-decision-test",
5854 "blake3:governed-decision-source",
5855 OriginRiskV1::Low,
5856 scopes,
5857 ElevationRequirementV1::ExplicitOperatorApproval,
5858 None,
5859 RevocationStatusV1::Active,
5860 vec!["principal:alice".into(), "team:medical".into()],
5861 )
5862 .unwrap()
5863 .with_subject_principal(SubjectPrincipalV1::new("principal:patient").unwrap())
5864 .with_resource_scope(NamespaceScopeV1::exact("medical"));
5865 let fact_id = runtime
5866 .block_on(
5867 bridge.store.authority().append(
5868 AuthorityPermit::with_evidence(
5869 "principal:writer",
5870 "governed-decision-test",
5871 AuthorityPermit::APPEND_CAPABILITY,
5872 vec!["evidence:test".into()],
5873 )
5874 .with_origin(origin),
5875 "governed-decision".into(),
5876 "medical".into(),
5877 "CONFIDENTIAL_MEMORY_SENTINEL".into(),
5878 None,
5879 ),
5880 )
5881 .unwrap()
5882 .affected_ids[0]
5883 .clone();
5884 (SemanticMemoryServer::new(bridge, "full"), runtime, fact_id)
5885 }
5886
5887 fn decision_params(fact_id: &str) -> GovernedDecisionParams {
5888 GovernedDecisionParams {
5889 fact_id: fact_id.into(),
5890 caller: "principal:alice".into(),
5891 subject: "principal:patient".into(),
5892 audiences: vec!["principal:alice".into(), "team:medical".into()],
5893 scope: GovernedNamespaceScopeParams {
5894 namespace: "medical".into(),
5895 domain: None,
5896 workspace_id: None,
5897 repo_id: None,
5898 },
5899 delegation_or_elevation: None,
5900 }
5901 }
5902
5903 fn invoke_decision(
5904 runtime: &tokio::runtime::Runtime,
5905 server: &SemanticMemoryServer,
5906 purpose: semantic_memory::GovernedAccessPurposeV1,
5907 params: GovernedDecisionParams,
5908 ) -> serde_json::Value {
5909 let body = runtime
5910 .block_on(async {
5911 tokio::task::block_in_place(|| match purpose {
5912 semantic_memory::GovernedAccessPurposeV1::Assertion => {
5913 server.sm_decide_assertion_authority(Parameters(params))
5914 }
5915 semantic_memory::GovernedAccessPurposeV1::Action => {
5916 server.sm_decide_action_authority(Parameters(params))
5917 }
5918 _ => unreachable!(),
5919 })
5920 })
5921 .unwrap();
5922 structured_value(&body)
5923 }
5924
5925 #[test]
5926 fn recall_authority_does_not_imply_assertion_or_action_authority_and_denials_omit_content() {
5927 use semantic_memory::{AuthorityScopeV1, AuthorityScopesV1, GovernedAccessPurposeV1};
5928 let (server, runtime, fact_id) = governed_decision_server(AuthorityScopesV1 {
5929 recall: AuthorityScopeV1::Audience,
5930 assertion: AuthorityScopeV1::Denied,
5931 action: AuthorityScopeV1::Denied,
5932 });
5933
5934 for purpose in [
5935 GovernedAccessPurposeV1::Assertion,
5936 GovernedAccessPurposeV1::Action,
5937 ] {
5938 let receipt = invoke_decision(&runtime, &server, purpose, decision_params(&fact_id));
5939 assert_eq!(receipt["schema_version"], "origin_authority_decision_v1");
5940 assert_eq!(receipt["allowed"], false);
5941 assert_eq!(
5942 receipt["purpose"],
5943 match purpose {
5944 GovernedAccessPurposeV1::Assertion => "assertion",
5945 GovernedAccessPurposeV1::Action => "action",
5946 _ => unreachable!(),
5947 }
5948 );
5949 assert!(receipt["reasons"]
5950 .as_array()
5951 .unwrap()
5952 .iter()
5953 .any(|reason| { reason == "scope_or_principal_denied" }));
5954 let serialized = receipt.to_string();
5955 assert!(!serialized.contains("CONFIDENTIAL_MEMORY_SENTINEL"));
5956 for forbidden in ["fact", "content", "origin", "memory"] {
5957 assert!(receipt.get(forbidden).is_none(), "leaked field {forbidden}");
5958 }
5959 }
5960 }
5961
5962 #[test]
5963 fn assertion_decision_honors_delegation_expiry_audience_and_namespace() {
5964 use semantic_memory::{AuthorityScopeV1, AuthorityScopesV1, GovernedAccessPurposeV1};
5965 let (server, runtime, fact_id) = governed_decision_server(AuthorityScopesV1 {
5966 recall: AuthorityScopeV1::Audience,
5967 assertion: AuthorityScopeV1::Audience,
5968 action: AuthorityScopeV1::Audience,
5969 });
5970 let mut params = decision_params(&fact_id);
5971 params.delegation_or_elevation = Some(GovernedLeaseParams {
5972 lease_id: "lease:assertion".into(),
5973 delegator: "principal:patient".into(),
5974 delegatee: "principal:alice".into(),
5975 purposes: vec![GovernedAccessPurposeParam::Assertion],
5976 scope: params.scope.clone(),
5977 audiences: vec!["team:medical".into()],
5978 expires_at: "2999-01-01T00:00:00Z".into(),
5979 revoked: false,
5980 elevation: false,
5981 });
5982 assert_eq!(
5983 invoke_decision(
5984 &runtime,
5985 &server,
5986 GovernedAccessPurposeV1::Assertion,
5987 params.clone()
5988 )["allowed"],
5989 true
5990 );
5991
5992 let mut expired = params.clone();
5993 expired.delegation_or_elevation.as_mut().unwrap().expires_at =
5994 "2000-01-01T00:00:00Z".into();
5995 let receipt = invoke_decision(
5996 &runtime,
5997 &server,
5998 GovernedAccessPurposeV1::Assertion,
5999 expired,
6000 );
6001 assert_eq!(receipt["allowed"], false);
6002 assert!(receipt["reasons"]
6003 .as_array()
6004 .unwrap()
6005 .contains(&serde_json::json!("delegation_expired")));
6006
6007 let mut wrong_audience = params.clone();
6008 wrong_audience.audiences = vec!["team:other".into()];
6009 let receipt = invoke_decision(
6010 &runtime,
6011 &server,
6012 GovernedAccessPurposeV1::Assertion,
6013 wrong_audience,
6014 );
6015 assert_eq!(receipt["allowed"], false);
6016 assert!(receipt["reasons"]
6017 .as_array()
6018 .unwrap()
6019 .contains(&serde_json::json!("audience_intersection_empty")));
6020
6021 let mut wrong_namespace = params;
6022 wrong_namespace.scope.namespace = "other".into();
6023 let receipt = invoke_decision(
6024 &runtime,
6025 &server,
6026 GovernedAccessPurposeV1::Assertion,
6027 wrong_namespace,
6028 );
6029 assert_eq!(receipt["allowed"], false);
6030 assert!(receipt["reasons"]
6031 .as_array()
6032 .unwrap()
6033 .contains(&serde_json::json!("namespace_scope_mismatch")));
6034 }
6035
6036 #[test]
6037 fn action_decision_honors_live_elevation_without_converting_it_to_admin_access() {
6038 use semantic_memory::{AuthorityScopeV1, AuthorityScopesV1, GovernedAccessPurposeV1};
6039 let (server, runtime, fact_id) = governed_decision_server(AuthorityScopesV1 {
6040 recall: AuthorityScopeV1::Audience,
6041 assertion: AuthorityScopeV1::Denied,
6042 action: AuthorityScopeV1::Audience,
6043 });
6044 let mut params = decision_params(&fact_id);
6045 params.delegation_or_elevation = Some(GovernedLeaseParams {
6046 lease_id: "lease:elevation".into(),
6047 delegator: "principal:patient".into(),
6048 delegatee: "principal:alice".into(),
6049 purposes: vec![GovernedAccessPurposeParam::Admin],
6050 scope: params.scope.clone(),
6051 audiences: vec![],
6052 expires_at: "2999-01-01T00:00:00Z".into(),
6053 revoked: false,
6054 elevation: true,
6055 });
6056 let receipt = invoke_decision(&runtime, &server, GovernedAccessPurposeV1::Action, params);
6057 assert_eq!(receipt["allowed"], true);
6058 assert_eq!(receipt["purpose"], "action");
6059 assert_eq!(receipt["lease_id"], "lease:elevation");
6060 }
6061 impl GraphView for TestGraph {
6062 fn neighbors(
6063 &self,
6064 node: &str,
6065 _: GraphDirection,
6066 _: usize,
6067 ) -> Result<Vec<GraphEdge>, MemoryError> {
6068 Ok(self
6069 .0
6070 .iter()
6071 .filter(|(a, b)| *a == node || *b == node)
6072 .map(|(a, b)| GraphEdge {
6073 source: (*a).into(),
6074 target: (*b).into(),
6075 edge_type: GraphEdgeType::Entity {
6076 relation: "test".into(),
6077 },
6078 weight: 1.0,
6079 metadata: None,
6080 })
6081 .collect())
6082 }
6083 fn path(&self, _: &str, _: &str, _: usize) -> Result<Option<Vec<String>>, MemoryError> {
6084 unreachable!()
6085 }
6086 }
6087
6088 #[test]
6089 fn graph_path_outcomes_do_not_conflate_exhaustion_and_budget() {
6090 let graph = TestGraph(vec![("a", "b"), ("b", "c"), ("x", "y")]);
6091 assert_eq!(
6092 typed_graph_path(&graph, "a", "c", 2).unwrap(),
6093 GraphPathOutcome::Found(vec!["a".into(), "b".into(), "c".into()])
6094 );
6095 assert_eq!(
6096 typed_graph_path(&graph, "a", "c", 1).unwrap(),
6097 GraphPathOutcome::BudgetExceeded
6098 );
6099 assert_eq!(
6100 typed_graph_path(&graph, "a", "x", 5).unwrap(),
6101 GraphPathOutcome::NoPathWithinCompleteSearch
6102 );
6103 assert_eq!(
6104 typed_graph_path(&graph, "missing", "x", 5).unwrap(),
6105 GraphPathOutcome::InvalidEndpoint("missing".into())
6106 );
6107 }
6108
6109 #[test]
6110 fn sm_add_fact_uses_authority_append_and_preserves_output_contract() {
6111 let dir = tempfile::tempdir().unwrap();
6112 let server = SemanticMemoryServer::new(
6113 MemoryBridge::open(BridgeConfig {
6114 memory_dir: dir.path().to_path_buf(),
6115 embedder_backend: EmbedderBackend::Mock,
6116 embedding_url: String::new(),
6117 embedding_model: "mock".into(),
6118 embedding_dims: 768,
6119 turbo_quant_enabled: false,
6120 turbo_quant_bits: None,
6121 turbo_quant_projections: None,
6122 })
6123 .unwrap(),
6124 "full",
6125 );
6126 let runtime = tokio::runtime::Runtime::new().unwrap();
6127 server
6128 .bridge
6129 .store
6130 .authority()
6131 .set_fault(Some(semantic_memory::AuthorityFaultStage::BeforeAppend));
6132
6133 let error = invoke_add_fact(
6134 &runtime,
6135 &server,
6136 add_fact_params("must pass through authority", Some("mcp-authority-fault")),
6137 )
6138 .err()
6139 .expect("expected tool error");
6140 assert!(error.message.contains("authority fault injected"));
6141 assert_eq!(
6142 runtime
6143 .block_on(server.bridge.store.stats())
6144 .unwrap()
6145 .total_facts,
6146 0
6147 );
6148
6149 let body = invoke_add_fact(
6150 &runtime,
6151 &server,
6152 add_fact_params("must pass through authority", Some("mcp-authority-success")),
6153 )
6154 .unwrap();
6155 let json: serde_json::Value = structured_value(&body);
6156 assert_eq!(json["ok"], true);
6157 assert_eq!(json["namespace"], "authority-test");
6158 assert_eq!(json["message"], "Fact added successfully");
6159 assert!(json["fact_id"].as_str().is_some());
6160 assert_eq!(json.as_object().unwrap().len(), 5);
6161 assert!(json["receipt"]["receipt_id"].as_str().is_some());
6162 assert!(json["receipt"]["recorded_at"].as_str().is_some());
6163 assert_eq!(json["receipt"]["tool"], "sm_add_fact");
6164 }
6165
6166 #[test]
6167 fn sm_add_fact_replays_explicit_key_but_unkeyed_identical_writes_remain_distinct() {
6168 let dir = tempfile::tempdir().unwrap();
6169 let server = SemanticMemoryServer::new(
6170 MemoryBridge::open(BridgeConfig {
6171 memory_dir: dir.path().to_path_buf(),
6172 embedder_backend: EmbedderBackend::Mock,
6173 embedding_url: String::new(),
6174 embedding_model: "mock".into(),
6175 embedding_dims: 768,
6176 turbo_quant_enabled: false,
6177 turbo_quant_bits: None,
6178 turbo_quant_projections: None,
6179 })
6180 .unwrap(),
6181 "full",
6182 );
6183 let runtime = tokio::runtime::Runtime::new().unwrap();
6184
6185 let first = invoke_add_fact(
6186 &runtime,
6187 &server,
6188 add_fact_params("retry-safe", Some("caller-retry-key")),
6189 )
6190 .unwrap();
6191 let retry = invoke_add_fact(
6192 &runtime,
6193 &server,
6194 add_fact_params("retry-safe", Some("caller-retry-key")),
6195 )
6196 .unwrap();
6197 let first_fact = structured_value(&first)["fact_id"]
6200 .as_str()
6201 .unwrap()
6202 .to_string();
6203 let retry_fact = structured_value(&retry)["fact_id"]
6204 .as_str()
6205 .unwrap()
6206 .to_string();
6207 assert_eq!(first_fact, retry_fact);
6208
6209 let unkeyed_a = invoke_add_fact(
6210 &runtime,
6211 &server,
6212 add_fact_params("legitimate duplicate", None),
6213 )
6214 .unwrap();
6215 let unkeyed_b = invoke_add_fact(
6216 &runtime,
6217 &server,
6218 add_fact_params("legitimate duplicate", None),
6219 )
6220 .unwrap();
6221 let fact_id = |body: &Json<StructuredOutput>| {
6222 structured_value(body)["fact_id"]
6223 .as_str()
6224 .unwrap()
6225 .to_string()
6226 };
6227 assert_ne!(fact_id(&unkeyed_a), fact_id(&unkeyed_b));
6228 assert_eq!(
6229 runtime
6230 .block_on(server.bridge.store.stats())
6231 .unwrap()
6232 .total_facts,
6233 3
6234 );
6235 }
6236
6237 #[test]
6238 fn sm_delete_fact_uses_governed_forgetting_closure() {
6239 let dir = tempfile::tempdir().unwrap();
6240 let server = SemanticMemoryServer::new(
6241 MemoryBridge::open(BridgeConfig {
6242 memory_dir: dir.path().to_path_buf(),
6243 embedder_backend: EmbedderBackend::Mock,
6244 embedding_url: String::new(),
6245 embedding_model: "mock".into(),
6246 embedding_dims: 768,
6247 turbo_quant_enabled: false,
6248 turbo_quant_bits: None,
6249 turbo_quant_projections: None,
6250 })
6251 .unwrap(),
6252 "full",
6253 );
6254 let runtime = tokio::runtime::Runtime::new().unwrap();
6255 let added = invoke_add_fact(
6256 &runtime,
6257 &server,
6258 add_fact_params("forget through MCP", Some("mcp-forget-source")),
6259 )
6260 .unwrap();
6261 let fact_id = structured_value(&added)["fact_id"]
6262 .as_str()
6263 .unwrap()
6264 .to_string();
6265 let response = runtime
6266 .block_on(async {
6267 tokio::task::block_in_place(|| {
6268 server.sm_delete_fact(Parameters(DeleteFactParams {
6269 fact_id: fact_id.clone(),
6270 }))
6271 })
6272 })
6273 .unwrap();
6274 let json: serde_json::Value = structured_value(&response);
6275 assert_eq!(json["forgotten"], true);
6276 assert_eq!(json["deleted"], false);
6277 assert_eq!(
6278 json["forgetting_receipt"]["schema_version"],
6279 "forgetting_closure_receipt_v1"
6280 );
6281 let raw = runtime
6282 .block_on(server.bridge.store.get_fact_raw_compat(&fact_id))
6283 .unwrap()
6284 .unwrap();
6285 assert_eq!(raw.content, "[FORGOTTEN]");
6286
6287 let http_origin = semantic_memory::OriginAuthorityLabelV1::operator_system(
6288 "principal:semantic-memory-http",
6289 "caller:http-add",
6290 );
6291 let http_fact = runtime
6292 .block_on(
6293 server.bridge.store.authority().append(
6294 semantic_memory::AuthorityPermit::operator_system(
6295 "principal:semantic-memory-http",
6296 "caller:http-add",
6297 semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
6298 )
6299 .with_origin(http_origin),
6300 "cross-adapter-forget".into(),
6301 "authority-test".into(),
6302 "cross adapter fact".into(),
6303 None,
6304 ),
6305 )
6306 .unwrap()
6307 .affected_ids[0]
6308 .clone();
6309 let response = runtime
6310 .block_on(async {
6311 tokio::task::block_in_place(|| {
6312 server.sm_delete_fact(Parameters(DeleteFactParams {
6313 fact_id: http_fact.clone(),
6314 }))
6315 })
6316 })
6317 .unwrap();
6318 let json: serde_json::Value = structured_value(&response);
6319 assert_eq!(json["forgotten"], true);
6320 assert_eq!(
6321 runtime
6322 .block_on(server.bridge.store.get_fact_raw_compat(&http_fact))
6323 .unwrap()
6324 .unwrap()
6325 .content,
6326 "[FORGOTTEN]"
6327 );
6328 }
6329
6330 #[test]
6331 fn sm_add_fact_preserves_ephemeral_evidence_refs_admission_rule() {
6332 let dir = tempfile::tempdir().unwrap();
6333 let server = SemanticMemoryServer::new(
6334 MemoryBridge::open(BridgeConfig {
6335 memory_dir: dir.path().to_path_buf(),
6336 embedder_backend: EmbedderBackend::Mock,
6337 embedding_url: String::new(),
6338 embedding_model: "mock".into(),
6339 embedding_dims: 768,
6340 turbo_quant_enabled: false,
6341 turbo_quant_bits: None,
6342 turbo_quant_projections: None,
6343 })
6344 .unwrap(),
6345 "full",
6346 );
6347 let runtime = tokio::runtime::Runtime::new().unwrap();
6348 let mut params = add_fact_params("unsupported inference", Some("ephemeral-source-only"));
6349 params.memory_kind = Some("ephemeral_inference".into());
6350 params.evidence_refs = None;
6351
6352 let error = invoke_add_fact(&runtime, &server, params)
6353 .err()
6354 .expect("expected tool error");
6355 assert!(error.message.contains("requires evidence_refs"));
6356 assert_eq!(
6357 runtime
6358 .block_on(server.bridge.store.stats())
6359 .unwrap()
6360 .total_facts,
6361 0
6362 );
6363 }
6364
6365 #[test]
6366 fn sm_search_tool_returns_only_current_supersession_head() {
6367 let dir = tempfile::tempdir().unwrap();
6368 let bridge = MemoryBridge::open(BridgeConfig {
6369 memory_dir: dir.path().to_path_buf(),
6370 embedder_backend: EmbedderBackend::Mock,
6371 embedding_url: String::new(),
6372 embedding_model: "mock".into(),
6373 embedding_dims: 768,
6374 turbo_quant_enabled: false,
6375 turbo_quant_bits: None,
6376 turbo_quant_projections: None,
6377 })
6378 .unwrap();
6379 let runtime = tokio::runtime::Runtime::new().unwrap();
6380 let old = runtime
6381 .block_on(
6382 bridge
6383 .store
6384 .add_fact("state", "runtime channel is violet", None, None),
6385 )
6386 .unwrap();
6387 let new = runtime
6388 .block_on(
6389 bridge
6390 .store
6391 .add_fact("state", "runtime channel is saffron", None, None),
6392 )
6393 .unwrap();
6394 runtime
6395 .block_on(bridge.store.add_graph_edge(
6396 &format!("fact:{new}"),
6397 &format!("fact:{old}"),
6398 GraphEdgeType::Entity {
6399 relation: "supersedes".into(),
6400 },
6401 1.0,
6402 None,
6403 ))
6404 .unwrap();
6405 let server = SemanticMemoryServer::new(bridge, "full");
6406 let body = runtime
6407 .block_on(async {
6408 tokio::task::block_in_place(|| {
6409 server.sm_search(Parameters(SearchParams {
6410 query: "runtime channel".into(),
6411 top_k: Some(10),
6412 namespaces: Some(vec!["state".into()]),
6413 }))
6414 })
6415 })
6416 .unwrap();
6417 let body = structured_value(&body).to_string();
6418 assert!(body.contains("saffron"));
6419 assert!(!body.contains("violet"));
6420 }
6421
6422 #[test]
6423 fn witnessed_search_hydrates_complete_honest_fact_provenance() {
6424 let dir = tempfile::tempdir().unwrap();
6425 let bridge = MemoryBridge::open(BridgeConfig {
6426 memory_dir: dir.path().to_path_buf(),
6427 embedder_backend: EmbedderBackend::Mock,
6428 embedding_url: String::new(),
6429 embedding_model: "mock".into(),
6430 embedding_dims: 768,
6431 turbo_quant_enabled: false,
6432 turbo_quant_bits: None,
6433 turbo_quant_projections: None,
6434 })
6435 .unwrap();
6436 let runtime = tokio::runtime::Runtime::new().unwrap();
6437 let fact_id = runtime
6438 .block_on(bridge.store.add_fact(
6439 "provenance-test",
6440 "witnessed facts must retain their source",
6441 Some("tests/witnessed-source.md"),
6442 None,
6443 ))
6444 .unwrap();
6445 let server = SemanticMemoryServer::new(bridge, "full");
6446 let body = runtime
6447 .block_on(async {
6448 tokio::task::block_in_place(|| {
6449 server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6450 query: "witnessed facts retain source".into(),
6451 top_k: Some(5),
6452 namespaces: Some(vec!["provenance-test".into()]),
6453 request_id: Some("witnessed-provenance-test".into()),
6454 retrieval_mode: None,
6455 replay_mode: None,
6456 }))
6457 })
6458 })
6459 .unwrap();
6460 let json: serde_json::Value = structured_value(&body);
6461 let hit = json["results"]
6462 .as_array()
6463 .unwrap()
6464 .iter()
6465 .find(|hit| hit["memory_id"] == format!("fact:{fact_id}"))
6466 .expect("the stored fact is an injectible witnessed result");
6467
6468 assert_eq!(hit["namespace"], "provenance-test");
6469 assert_eq!(hit["source"], "tests/witnessed-source.md");
6470 assert_eq!(hit["trust"], "persisted_unjudged");
6471 assert_eq!(hit["state"], "current");
6472 assert_eq!(
6473 hit["retrieval_receipt_ref"],
6474 format!("receipt:{}", json["receipt_id"].as_str().unwrap())
6475 );
6476 }
6477
6478 #[test]
6479 fn witnessed_search_omits_noninjectible_fact_without_source() {
6480 let dir = tempfile::tempdir().unwrap();
6481 let bridge = MemoryBridge::open(BridgeConfig {
6482 memory_dir: dir.path().to_path_buf(),
6483 embedder_backend: EmbedderBackend::Mock,
6484 embedding_url: String::new(),
6485 embedding_model: "mock".into(),
6486 embedding_dims: 768,
6487 turbo_quant_enabled: false,
6488 turbo_quant_bits: None,
6489 turbo_quant_projections: None,
6490 })
6491 .unwrap();
6492 let runtime = tokio::runtime::Runtime::new().unwrap();
6493 let _fact_id = runtime
6494 .block_on(bridge.store.add_fact(
6495 "provenance-test",
6496 "source-less facts cannot be injected autonomously",
6497 None,
6498 None,
6499 ))
6500 .unwrap();
6501 let server = SemanticMemoryServer::new(bridge, "full");
6502 let body = runtime
6503 .block_on(async {
6504 tokio::task::block_in_place(|| {
6505 server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6506 query: "source-less facts autonomous injection".into(),
6507 top_k: Some(5),
6508 namespaces: Some(vec!["provenance-test".into()]),
6509 request_id: Some("witnessed-source-less-test".into()),
6510 retrieval_mode: None,
6511 replay_mode: None,
6512 }))
6513 })
6514 })
6515 .unwrap();
6516 let json: serde_json::Value = structured_value(&body);
6517 assert!(json["results"].as_array().unwrap().is_empty());
6518 }
6519
6520 #[test]
6521 fn sm_search_witnessed_exposes_authority_state_and_vector_evidence() {
6522 let dir = tempfile::tempdir().unwrap();
6523 let bridge = MemoryBridge::open(BridgeConfig {
6524 memory_dir: dir.path().to_path_buf(),
6525 embedder_backend: EmbedderBackend::Mock,
6526 embedding_url: String::new(),
6527 embedding_model: "mock".into(),
6528 embedding_dims: 768,
6529 turbo_quant_enabled: false,
6530 turbo_quant_bits: None,
6531 turbo_quant_projections: None,
6532 })
6533 .unwrap();
6534 let runtime = tokio::runtime::Runtime::new().unwrap();
6535 let permit = semantic_memory::AuthorityPermit::operator_system(
6536 "witness-principal",
6537 "witness-caller",
6538 semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
6539 );
6540 let receipt = runtime
6541 .block_on(bridge.store.authority().append(
6542 permit,
6543 "witnessed-state-1".into(),
6544 "stateful".into(),
6545 "governed witnessed state should expose".into(),
6546 Some("tests/witnessed-state.md".into()),
6547 ))
6548 .unwrap();
6549
6550 let server = SemanticMemoryServer::new(bridge, "lean");
6551 let body = runtime
6552 .block_on(async {
6553 tokio::task::block_in_place(|| {
6554 server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6555 query: "governed witnessed state should expose".into(),
6556 top_k: Some(5),
6557 namespaces: Some(vec!["stateful".into()]),
6558 request_id: Some("witnessed-state-request".into()),
6559 retrieval_mode: None,
6560 replay_mode: None,
6561 }))
6562 })
6563 })
6564 .unwrap();
6565 let json: serde_json::Value = structured_value(&body);
6566
6567 assert_eq!(json["retrieval_mode"], "hybrid");
6568 assert!(json["authority"]["snapshot_id"].as_str().is_some());
6569 assert!(
6570 json["authority"]["retrieval_epoch"].as_u64().is_some()
6571 || json["authority"]["retrieval_epoch"].as_str().is_some(),
6572 "authoritative retrieval epoch should be surfaced"
6573 );
6574 assert!(json["current_snapshot_id"].as_str().is_some());
6575 assert!(
6576 json["retrieval_epoch"].as_u64().is_some()
6577 || json["retrieval_epoch"].as_str().is_some(),
6578 "top-level retrieval epoch should be surfaced"
6579 );
6580
6581 assert_eq!(json["authority"]["status"], "Applied");
6582 assert_eq!(
6583 json["stage_outcomes"]["authority_snapshot"]["outcome"],
6584 "Applied"
6585 );
6586
6587 let hit = json["results"]
6588 .as_array()
6589 .unwrap()
6590 .iter()
6591 .find(|hit| {
6592 hit.get("content")
6593 .and_then(|value| value.as_str())
6594 .is_some_and(|content| content.contains("governed witnessed state"))
6595 })
6596 .expect("governed witnessed result should be returned");
6597
6598 assert!(hit["cosine_similarity"].as_f64().is_some());
6599 assert_eq!(
6600 hit["source"],
6601 serde_json::json!("tests/witnessed-state.md"),
6602 "witnessed path should retain source"
6603 );
6604 assert_eq!(
6605 json["ordered_results"][0]["result_id"],
6606 format!("fact:{}", receipt.affected_ids[0])
6607 );
6608 }
6609
6610 #[test]
6611 fn sm_search_witnessed_accepts_general_retrieval_modes() {
6612 let dir = tempfile::tempdir().unwrap();
6613 let bridge = MemoryBridge::open(BridgeConfig {
6614 memory_dir: dir.path().to_path_buf(),
6615 embedder_backend: EmbedderBackend::Mock,
6616 embedding_url: String::new(),
6617 embedding_model: "mock".into(),
6618 embedding_dims: 768,
6619 turbo_quant_enabled: false,
6620 turbo_quant_bits: None,
6621 turbo_quant_projections: None,
6622 })
6623 .unwrap();
6624 let rt = tokio::runtime::Runtime::new().unwrap();
6625 rt.block_on(
6626 bridge
6627 .store
6628 .add_fact("modes", "alpha beta gamma", Some("test"), None),
6629 )
6630 .unwrap();
6631 let server = SemanticMemoryServer::new(bridge, "full");
6632 for retrieval_mode in [
6633 RetrievalModeParam::Hybrid,
6634 RetrievalModeParam::FtsOnly,
6635 RetrievalModeParam::VectorOnly,
6636 ] {
6637 let body = rt
6638 .block_on(async {
6639 server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6640 query: "alpha beta".into(),
6641 top_k: Some(5),
6642 namespaces: Some(vec!["modes".into()]),
6643 request_id: None,
6644 retrieval_mode: Some(retrieval_mode),
6645 replay_mode: None,
6646 }))
6647 })
6648 .unwrap();
6649 let json: serde_json::Value = structured_value(&body);
6650 assert_eq!(json["ok"], true);
6651 assert!(json["receipt_id"].is_string());
6652 assert_eq!(
6653 json["retrieval_mode"],
6654 serde_json::to_value(retrieval_mode).unwrap()
6655 );
6656 }
6657 }
6658
6659 #[test]
6660 fn witnessed_search_opt_in_enables_complete_replay() {
6661 let dir = tempfile::tempdir().unwrap();
6662 let bridge = MemoryBridge::open(BridgeConfig {
6663 memory_dir: dir.path().to_path_buf(),
6664 embedder_backend: EmbedderBackend::Mock,
6665 embedding_url: String::new(),
6666 embedding_model: "mock".into(),
6667 embedding_dims: 768,
6668 turbo_quant_enabled: false,
6669 turbo_quant_bits: None,
6670 turbo_quant_projections: None,
6671 })
6672 .unwrap();
6673 let runtime = tokio::runtime::Runtime::new().unwrap();
6674 runtime
6675 .block_on(bridge.store.add_fact(
6676 "stored-replay",
6677 "complete replay uses explicitly retained inputs",
6678 Some("tests/stored-replay.md"),
6679 None,
6680 ))
6681 .unwrap();
6682 let server = SemanticMemoryServer::new(bridge, "full");
6683 let body = runtime
6684 .block_on(async {
6685 server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6686 query: "complete replay explicitly retained inputs".into(),
6687 top_k: Some(1),
6688 namespaces: Some(vec!["stored-replay".into()]),
6689 request_id: Some("mcp-stored-replay".into()),
6690 retrieval_mode: None,
6691 replay_mode: Some(ReplayModeParam::StoreInputs),
6692 }))
6693 })
6694 .unwrap();
6695 let witnessed: serde_json::Value = structured_value(&body);
6696 assert_eq!(witnessed["complete_replay_available"], true);
6697 assert_eq!(witnessed["stage_outcomes"]["replay"]["outcome"], "Applied");
6698
6699 let replay = runtime
6700 .block_on(async {
6701 server.sm_replay_search(Parameters(ReplayStoredSearchParams {
6702 receipt_id: "mcp-stored-replay".into(),
6703 }))
6704 })
6705 .unwrap();
6706 let replay: serde_json::Value = structured_value(&replay);
6707 assert_eq!(replay["result_ids_match"], true);
6708 assert_eq!(replay["query_embedding_digest_matches"], true);
6709 }
6710
6711 #[cfg(feature = "claim-integration")]
6712 fn claim_test_entries() -> Vec<claim_ledger::LedgerEntry> {
6713 let first = claim_ledger::LedgerEntryBuilder::new(1, None)
6714 .add_claim(
6715 "claim-1",
6716 "semantic-memory:fact:fact-1",
6717 "full",
6718 "verified claim",
6719 )
6720 .unwrap();
6721 let second = claim_ledger::LedgerEntryBuilder::new(2, Some(first.entry_digest.clone()))
6722 .add_support_judgment(
6723 "judgment-1",
6724 "claim-1",
6725 "bundle-1",
6726 claim_ledger::SupportState::Supported,
6727 "test",
6728 )
6729 .unwrap();
6730 vec![first, second]
6731 }
6732
6733 #[cfg(feature = "claim-integration")]
6734 fn write_claim_jsonl(path: &std::path::Path, entries: &[claim_ledger::LedgerEntry]) {
6735 let mut contents = String::new();
6736 for entry in entries {
6737 contents.push_str(&claim_ledger::serialize_entry(entry).unwrap());
6738 contents.push('\n');
6739 }
6740 std::fs::write(path, contents).unwrap();
6741 }
6742
6743 #[cfg(feature = "claim-integration")]
6744 #[test]
6745 fn claim_ledger_startup_rebuilds_from_verified_snapshot_and_tail() {
6746 let dir = tempfile::tempdir().unwrap();
6747 let legacy = dir.path().join("claim_ledger.jsonl");
6748 let entries = claim_test_entries();
6749 write_claim_jsonl(&legacy, &entries);
6750 let mut store = ClaimLedgerStore::open(legacy.clone());
6751 let result = store
6752 .compact(ClaimLedgerCompactionConfig {
6753 dry_run: false,
6754 max_entries: 0,
6755 max_bytes: 0,
6756 retain_tail_entries: 1,
6757 max_backups: 2,
6758 })
6759 .unwrap();
6760 assert_eq!(result["compacted"], true);
6761
6762 let reopened = ClaimLedgerStore::open(legacy);
6763 assert!(reopened.trust_enabled);
6764 assert!(reopened.snapshot.is_some());
6765 assert_eq!(reopened.entries.len(), 1);
6766 let mut index = ClaimTrustIndex::default();
6767 index.load_snapshot(reopened.snapshot.as_ref().unwrap());
6768 index.rebuild_from_ledger_incremental(&reopened.entries);
6769 assert_eq!(index.trust_for_fact("fact-1"), "supported");
6770 assert_eq!(index.last_processed_sequence, 2);
6771 }
6772
6773 #[cfg(feature = "claim-integration")]
6774 #[test]
6775 fn interrupted_compaction_files_leave_old_ledger_usable() {
6776 let dir = tempfile::tempdir().unwrap();
6777 let legacy = dir.path().join("claim_ledger.jsonl");
6778 let entries = claim_test_entries();
6779 write_claim_jsonl(&legacy, &entries);
6780 let interrupted = dir.path().join("claim_ledger_generations/.tmp-interrupted");
6781 std::fs::create_dir_all(&interrupted).unwrap();
6782 std::fs::write(interrupted.join("snapshot.json"), b"incomplete").unwrap();
6783
6784 let reopened = ClaimLedgerStore::open(legacy);
6785 assert!(reopened.trust_enabled);
6786 assert!(reopened.snapshot.is_none());
6787 assert_eq!(reopened.entries.len(), entries.len());
6788 }
6789
6790 #[cfg(feature = "claim-integration")]
6791 #[test]
6792 fn tampered_active_snapshot_disables_claim_trust() {
6793 let dir = tempfile::tempdir().unwrap();
6794 let legacy = dir.path().join("claim_ledger.jsonl");
6795 write_claim_jsonl(&legacy, &claim_test_entries());
6796 let mut store = ClaimLedgerStore::open(legacy.clone());
6797 store
6798 .compact(ClaimLedgerCompactionConfig {
6799 dry_run: false,
6800 max_entries: 0,
6801 max_bytes: 0,
6802 retain_tail_entries: 1,
6803 max_backups: 2,
6804 })
6805 .unwrap();
6806 let snapshot_path = store.path.parent().unwrap().join("snapshot.json");
6807 let mut snapshot: serde_json::Value =
6808 serde_json::from_slice(&std::fs::read(&snapshot_path).unwrap()).unwrap();
6809 snapshot["claims"][0]["normalized_claim"] = serde_json::json!("tampered");
6810 std::fs::write(&snapshot_path, serde_json::to_vec(&snapshot).unwrap()).unwrap();
6811
6812 let reopened = ClaimLedgerStore::open(legacy);
6813 assert!(!reopened.trust_enabled);
6814 assert!(reopened.entries.is_empty());
6815 }
6816
6817 #[cfg(feature = "claim-integration")]
6818 #[test]
6819 fn compact_claim_ledger_defaults_to_dry_run_without_writes() {
6820 let dir = tempfile::tempdir().unwrap();
6821 let bridge = MemoryBridge::open(BridgeConfig {
6822 memory_dir: dir.path().to_path_buf(),
6823 embedder_backend: EmbedderBackend::Mock,
6824 embedding_url: String::new(),
6825 embedding_model: "mock".into(),
6826 embedding_dims: 768,
6827 turbo_quant_enabled: false,
6828 turbo_quant_bits: None,
6829 turbo_quant_projections: None,
6830 })
6831 .unwrap();
6832 let server = SemanticMemoryServer::new(bridge, "full");
6833 assert!(server.exposes_tool("sm_compact_claim_ledger"));
6834 {
6835 let mut store = server.claim_ledger_store.lock().unwrap();
6836 for entry in claim_test_entries() {
6837 store.append(entry).unwrap();
6838 }
6839 }
6840 let before = std::fs::read(dir.path().join("claim_ledger.jsonl")).unwrap();
6841 let response = server
6842 .sm_compact_claim_ledger(Parameters(CompactClaimLedgerParams {
6843 dry_run: None,
6844 max_entries: Some(0),
6845 max_bytes: Some(0),
6846 retain_tail_entries: Some(1),
6847 max_backups: Some(2),
6848 }))
6849 .unwrap();
6850 let response: serde_json::Value = structured_value(&response);
6851 assert_eq!(response["dry_run"], true);
6852 assert_eq!(response["compacted"], false);
6853 assert_eq!(
6854 before,
6855 std::fs::read(dir.path().join("claim_ledger.jsonl")).unwrap()
6856 );
6857 assert!(!dir
6858 .path()
6859 .join("claim_ledger.active_compaction.json")
6860 .exists());
6861 assert!(!dir.path().join("claim_ledger_generations").exists());
6862 }
6863}
6864
6865fn build_path_segments(
6868 store: &semantic_memory::MemoryStore,
6869 path: &[String],
6870) -> Vec<serde_json::Value> {
6871 let mut segments = Vec::new();
6872 if path.len() < 2 {
6873 return segments;
6874 }
6875
6876 for i in 0..path.len() - 1 {
6877 let from = &path[i];
6878 let to = &path[i + 1];
6879
6880 let g = store.graph_view();
6882 match g.neighbors(from, semantic_memory::GraphDirection::Both, 1) {
6883 Ok(edges) => {
6884 let connecting = edges.iter().find(|e| {
6886 (e.source == *from && e.target == *to) || (e.source == *to && e.target == *from)
6887 });
6888
6889 if let Some(edge) = connecting {
6890 let edge_type_str = match &edge.edge_type {
6891 semantic_memory::GraphEdgeType::Semantic { cosine_similarity } => {
6892 serde_json::json!({
6893 "type": "semantic",
6894 "cosine_similarity": cosine_similarity,
6895 })
6896 }
6897 semantic_memory::GraphEdgeType::Temporal { delta_secs } => {
6898 serde_json::json!({
6899 "type": "temporal",
6900 "delta_secs": delta_secs,
6901 })
6902 }
6903 semantic_memory::GraphEdgeType::Causal {
6904 confidence,
6905 evidence_ids,
6906 } => {
6907 serde_json::json!({
6908 "type": "causal",
6909 "confidence": confidence,
6910 "evidence_ids": evidence_ids,
6911 })
6912 }
6913 semantic_memory::GraphEdgeType::Entity { relation } => {
6914 serde_json::json!({
6915 "type": "entity",
6916 "relation": relation,
6917 })
6918 }
6919 };
6920
6921 segments.push(serde_json::json!({
6922 "source": from,
6923 "target": to,
6924 "edge_type": edge_type_str,
6925 "weight": edge.weight,
6926 "metadata": edge.metadata,
6927 }));
6928 } else {
6929 segments.push(serde_json::json!({
6932 "source": from,
6933 "target": to,
6934 "edge_type": null,
6935 "weight": null,
6936 "metadata": null,
6937 }));
6938 }
6939 }
6940 Err(_) => {
6941 segments.push(serde_json::json!({
6942 "source": from,
6943 "target": to,
6944 "edge_type": null,
6945 "weight": null,
6946 "metadata": null,
6947 }));
6948 }
6949 }
6950 }
6951
6952 segments
6953}
6954
6955#[tool_handler(
6956 router = self.tool_router,
6957 name = "semantic-memory-mcp",
6958 version = "0.5.2",
6959 instructions = "Persistent local semantic memory with hybrid search, graph reasoning, and conversation persistence. ALWAYS search first before asking the user for context. Use sm_decide_assertion_authority or sm_decide_action_authority for content-free, fixed-purpose authority decisions; recall authority never implies either purpose. In the full operator profile, use sm_search_with_routing for complex/multi-hop queries, sm_get_fact to hydrate IDs returned by graph tools, sm_supersede_fact (not delete) for stale corrections, and sm_add_graph_edge after adding facts to connect them. Read tools are safe; write tools (add/delete/supersede) should be user-approved. Search auto-filters superseded facts unless querying for history."
6960)]
6961impl ServerHandler for SemanticMemoryServer {}