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 = serde_json::Value::Object(meta);
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_with_metadata(
2172 permit,
2173 caller_idempotency_key,
2174 namespace.clone(),
2175 content.clone(),
2176 source.clone(),
2177 Some(metadata),
2178 ))
2179 });
2180
2181 match result {
2182 Ok(receipt) => {
2183 let id = receipt.affected_ids.first().cloned().ok_or_else(|| {
2184 ErrorData::internal_error(
2185 "authority append returned no affected fact id".to_string(),
2186 None,
2187 )
2188 })?;
2189 self.auto_link_fact_to_claims(&id, &content);
2192 if extract_entities == Some(true) {
2194 let prompt = format!(
2195 "Extract entities from this text as JSON. Format: {{\"entities\": [{{\"name\": \"...\", \"type\": \"person|project|concept|tool|version|path\"}}]}}\nText: {content}\nJSON:"
2196 );
2197 let body = serde_json::json!({
2198 "model": "granite4.1:3b",
2199 "prompt": prompt,
2200 "stream": false,
2201 "options": {"temperature": 0, "num_predict": 200}
2202 });
2203 if let Ok(resp) = reqwest::blocking::Client::new()
2204 .post("http://127.0.0.1:11434/api/generate")
2205 .json(&body)
2206 .send()
2207 {
2208 if let Ok(v) = resp.json::<serde_json::Value>() {
2209 if let Some(response_str) = v.get("response").and_then(|r| r.as_str()) {
2210 let parsed_result =
2212 boundary_compiler::parse_with_dup_check(response_str.trim());
2213 if let Ok(parsed) = parsed_result {
2214 if let Some(entities) =
2215 parsed.get("entities").and_then(|e| e.as_array())
2216 {
2217 let fact_node = format!("fact:{id}");
2218 for entity in entities {
2219 if let Some(name) =
2220 entity.get("name").and_then(|n| n.as_str())
2221 {
2222 let entity_node = format!("entity:{name}");
2223 let _ = tokio::task::block_in_place(|| {
2224 Handle::current()
2225 .block_on(store.add_graph_edge(
2226 &fact_node,
2227 &entity_node,
2228 semantic_memory::GraphEdgeType::Entity {
2229 relation: "mentions".to_string(),
2230 },
2231 1.0,
2232 None,
2233 ))
2234 });
2235 }
2236 }
2237 }
2238 }
2239 }
2240 }
2241 }
2242 }
2243
2244 json_to_output(&serde_json::json!({
2245 "ok": true,
2246 "fact_id": id,
2247 "namespace": namespace,
2248 "receipt": mcp_receipt("sm_add_fact"),
2249 "message": "Fact added successfully",
2250 }))
2251 }
2252 Err(e) => Err(ErrorData::internal_error(
2253 format!("Error adding fact: {e}"),
2254 None,
2255 )),
2256 }
2257 }
2258
2259 #[tool(
2260 description = "Ingest a document with automatic chunking. Splits into chunks, each embedded and indexed. Returns document ID and chunk count.",
2261 annotations(idempotent_hint = true)
2262 )]
2263 fn sm_ingest_document(
2264 &self,
2265 Parameters(IngestDocumentParams {
2266 content,
2267 title,
2268 namespace,
2269 }): Parameters<IngestDocumentParams>,
2270 ) -> Result<Json<StructuredOutput>, ErrorData> {
2271 let store = &self.bridge.store;
2272 let result = tokio::task::block_in_place(|| {
2273 Handle::current()
2274 .block_on(store.ingest_document(&title, &content, &namespace, None, None))
2275 });
2276
2277 match result {
2278 Ok(doc_id) => {
2279 let chunk_count = tokio::task::block_in_place(|| {
2280 Handle::current().block_on(store.count_chunks_for_document(&doc_id))
2281 })
2282 .unwrap_or(0);
2283 json_to_output(&serde_json::json!({
2284 "ok": true,
2285 "receipt": mcp_receipt("sm_ingest_document"),
2286 "document_id": doc_id,
2287 "title": title,
2288 "chunk_count": chunk_count,
2289 "message": "Document ingested successfully",
2290 }))
2291 }
2292 Err(e) => Err(ErrorData::internal_error(
2293 format!("Error ingesting document: {e}"),
2294 None,
2295 )),
2296 }
2297 }
2298
2299 #[tool(
2300 description = "Get knowledge base statistics: fact/chunk/document/session counts, DB size, embedding model, and graph edge count.",
2301 annotations(read_only_hint = true)
2302 )]
2303 fn sm_stats(&self) -> Result<Json<StatsOutput>, ErrorData> {
2304 let store = &self.bridge.store;
2305 let core = tokio::task::block_in_place(|| Handle::current().block_on(store.stats()));
2306 let graph = tokio::task::block_in_place(|| {
2307 Handle::current().block_on(store.list_all_graph_edges())
2308 });
2309 let core_health = match &core {
2310 Ok(_) => serde_json::json!({"health": "healthy", "error": null}),
2311 Err(e) => serde_json::json!({"health": "error", "error": e.to_string()}),
2312 };
2313 let graph_health = match &graph {
2314 Ok(_) => serde_json::json!({"health": "healthy", "error": null}),
2315 Err(e) => serde_json::json!({"health": "error", "error": e.to_string()}),
2316 };
2317 let core_value = core.ok();
2318 let graph_count = graph.ok().map(|edges| edges.len());
2319 Ok(Json(StatsOutput {
2320 ok: core_value.is_some() && graph_count.is_some(),
2321 components: serde_json::json!({"core": core_health, "graph": graph_health}),
2322 facts: core_value.as_ref().map(|s| s.total_facts),
2323 chunks: core_value.as_ref().map(|s| s.total_chunks),
2324 documents: core_value.as_ref().map(|s| s.total_documents),
2325 sessions: core_value.as_ref().map(|s| s.total_sessions),
2326 messages: core_value.as_ref().map(|s| s.total_messages),
2327 graph_edges: graph_count,
2328 db_size_bytes: core_value.as_ref().map(|s| s.database_size_bytes),
2329 db_size_mb: core_value
2330 .as_ref()
2331 .map(|s| (s.database_size_bytes as f64 / 1_048_576.0 * 100.0).round() / 100.0),
2332 embedding_model: core_value.as_ref().and_then(|s| s.embedding_model.clone()),
2333 embedding_dimensions: core_value.as_ref().and_then(|s| s.embedding_dimensions),
2334 }))
2335 }
2336
2337 #[tool(
2338 description = "Find shortest path between two items in the knowledge graph. Traverses all edge types. Returns node IDs with edge evidence per hop.",
2339 annotations(read_only_hint = true)
2340 )]
2341 fn sm_graph_path(
2342 &self,
2343 Parameters(GraphPathParams {
2344 from_id,
2345 to_id,
2346 max_depth,
2347 }): Parameters<GraphPathParams>,
2348 ) -> Result<Json<StructuredOutput>, ErrorData> {
2349 let depth = max_depth.map(|v| v as usize).unwrap_or(5);
2350 let store = &self.bridge.store;
2351 let g = store.graph_view();
2352
2353 match typed_graph_path(g.as_ref(), &from_id, &to_id, depth) {
2354 Ok(GraphPathOutcome::Found(path)) => {
2355 let path_segments = build_path_segments(store, &path);
2357 json_to_output(&serde_json::json!({
2358 "ok": true,
2359 "outcome": "Found",
2360 "from": from_id,
2361 "to": to_id,
2362 "path": path,
2363 "path_length": path.len(),
2364 "segments": path_segments,
2365 }))
2366 }
2367 Ok(GraphPathOutcome::NoPathWithinCompleteSearch) => {
2368 json_to_output(&serde_json::json!({
2369 "ok": true,
2370 "outcome": "NoPathWithinCompleteSearch",
2371 "from": from_id,
2372 "to": to_id,
2373 "path": null,
2374 "message": format!("No path found from {from_id} to {to_id} within depth {depth}"),
2375 }))
2376 }
2377 Ok(GraphPathOutcome::BudgetExceeded) => json_to_output(&serde_json::json!({
2378 "ok": false, "outcome": "BudgetExceeded", "from": from_id, "to": to_id,
2379 "path": null, "budget": {"max_depth": depth}
2380 })),
2381 Ok(GraphPathOutcome::InvalidEndpoint(endpoint)) => json_to_output(&serde_json::json!({
2382 "ok": false, "outcome": "InvalidEndpoint", "invalid_endpoint": endpoint,
2383 "from": from_id, "to": to_id, "path": null
2384 })),
2385 Err(e) => Err(ErrorData::internal_error(
2386 format!("Graph view error: {e}"),
2387 None,
2388 )),
2389 }
2390 }
2391
2392 #[tool(
2395 description = "Fetch one fact by id (bare UUID or prefixed 'fact:<uuid>'). Returns full content, namespace, source, timestamps, and metadata.",
2396 annotations(read_only_hint = true)
2397 )]
2398 fn sm_get_fact(
2399 &self,
2400 Parameters(GetFactParams { fact_id }): Parameters<GetFactParams>,
2401 ) -> Result<Json<StructuredOutput>, ErrorData> {
2402 let bare = fact_id
2403 .strip_prefix("fact:")
2404 .unwrap_or(&fact_id)
2405 .to_string();
2406 let store = &self.bridge.store;
2407 let result =
2408 tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&bare)));
2409 match result {
2410 Ok(Some(f)) => json_to_output(&serde_json::json!({
2411 "ok": true,
2412 "found": true,
2413 "fact": {
2414 "result_id": format!("fact:{}", f.id),
2415 "id": f.id,
2416 "namespace": f.namespace,
2417 "content": f.content,
2418 "source": f.source,
2419 "created_at": f.created_at,
2420 "updated_at": f.updated_at,
2421 "metadata": f.metadata,
2422 },
2423 })),
2424 Ok(None) => json_to_output(&serde_json::json!({
2425 "ok": true,
2426 "found": false,
2427 "message": format!("No fact with id '{fact_id}'"),
2428 })),
2429 Err(e) => Err(ErrorData::internal_error(
2430 format!("get_fact error: {e}"),
2431 None,
2432 )),
2433 }
2434 }
2435
2436 #[tool(
2437 description = "Enumerate facts in a namespace (newest first) with pagination. Exhaustive, not similarity-ranked — for browsing, auditing, or deduping.",
2438 annotations(read_only_hint = true)
2439 )]
2440 fn sm_list_facts(
2441 &self,
2442 Parameters(ListFactsParams {
2443 namespace,
2444 limit,
2445 offset,
2446 }): Parameters<ListFactsParams>,
2447 ) -> Result<Json<StructuredOutput>, ErrorData> {
2448 let lim = limit.map(|v| v as usize).unwrap_or(50);
2449 let off = offset.map(|v| v as usize).unwrap_or(0);
2450 let store = &self.bridge.store;
2451 let result = tokio::task::block_in_place(|| {
2452 Handle::current().block_on(store.list_facts(&namespace, lim, off))
2453 });
2454 match result {
2455 Ok(facts) => {
2456 let arr: Vec<serde_json::Value> = facts
2457 .iter()
2458 .map(|f| {
2459 serde_json::json!({
2460 "result_id": format!("fact:{}", f.id),
2461 "id": f.id,
2462 "namespace": f.namespace,
2463 "content": f.content,
2464 "source": f.source,
2465 "updated_at": f.updated_at,
2466 })
2467 })
2468 .collect();
2469 json_to_output(&serde_json::json!({
2470 "ok": true,
2471 "namespace": namespace,
2472 "count": arr.len(),
2473 "limit": lim,
2474 "offset": off,
2475 "facts": arr,
2476 }))
2477 }
2478 Err(e) => Err(ErrorData::internal_error(
2479 format!("list_facts error: {e}"),
2480 None,
2481 )),
2482 }
2483 }
2484
2485 #[tool(
2486 description = "List namespaces that currently contain facts. Use before sm_list_facts to discover what is stored.",
2487 annotations(read_only_hint = true)
2488 )]
2489 fn sm_list_namespaces(&self) -> Result<Json<StructuredOutput>, ErrorData> {
2490 let store = &self.bridge.store;
2491 let result = tokio::task::block_in_place(|| {
2492 Handle::current().block_on(store.list_fact_namespaces())
2493 });
2494 match result {
2495 Ok(ns) => json_to_output(&serde_json::json!({
2496 "ok": true,
2497 "count": ns.len(),
2498 "namespaces": ns,
2499 })),
2500 Err(e) => Err(ErrorData::internal_error(
2501 format!("list_namespaces error: {e}"),
2502 None,
2503 )),
2504 }
2505 }
2506
2507 #[tool(
2508 description = "Fetch a fact plus its graph neighbors WITH their content in one call. Hydrates neighbor facts for ids returned by graph tools.",
2509 annotations(read_only_hint = true)
2510 )]
2511 fn sm_get_fact_neighbors(
2512 &self,
2513 Parameters(GetFactNeighborsParams { item_id }): Parameters<GetFactNeighborsParams>,
2514 ) -> Result<Json<StructuredOutput>, ErrorData> {
2515 let node_id = if item_id.contains(':') {
2516 item_id.clone()
2517 } else {
2518 format!("fact:{item_id}")
2519 };
2520 let bare = node_id
2521 .strip_prefix("fact:")
2522 .unwrap_or(&node_id)
2523 .to_string();
2524 let store = &self.bridge.store;
2525
2526 let center =
2527 tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&bare)))
2528 .map_err(|e| ErrorData::internal_error(format!("get_fact error: {e}"), None))?;
2529 let edges = tokio::task::block_in_place(|| {
2530 Handle::current().block_on(store.list_graph_edges_for_node(&node_id))
2531 })
2532 .map_err(|e| ErrorData::internal_error(format!("list edges error: {e}"), None))?;
2533
2534 let mut neighbors: Vec<serde_json::Value> = Vec::new();
2535 for e in &edges {
2536 let outgoing = e.source == node_id;
2537 let other = if outgoing { &e.target } else { &e.source };
2538 let other_bare = other.strip_prefix("fact:").unwrap_or(other).to_string();
2539 let content = tokio::task::block_in_place(|| {
2540 Handle::current().block_on(store.get_fact(&other_bare))
2541 })
2542 .ok()
2543 .flatten()
2544 .map(|f| f.content);
2545 neighbors.push(serde_json::json!({
2546 "neighbor_id": other,
2547 "direction": if outgoing { "out" } else { "in" },
2548 "edge_type": e.edge_type,
2549 "weight": e.weight,
2550 "content": content,
2551 }));
2552 }
2553 json_to_output(&serde_json::json!({
2554 "ok": true,
2555 "item_id": node_id,
2556 "center_content": center.map(|f| f.content),
2557 "neighbor_count": neighbors.len(),
2558 "neighbors": neighbors,
2559 }))
2560 }
2561
2562 #[tool(
2563 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.",
2564 annotations(idempotent_hint = true)
2565 )]
2566 fn sm_supersede_fact(
2567 &self,
2568 Parameters(SupersedeFactParams {
2569 old_fact_id,
2570 content,
2571 namespace,
2572 source,
2573 reason,
2574 }): Parameters<SupersedeFactParams>,
2575 ) -> Result<Json<StructuredOutput>, ErrorData> {
2576 use semantic_memory::GraphEdgeType;
2577
2578 let old_bare = old_fact_id
2579 .strip_prefix("fact:")
2580 .unwrap_or(&old_fact_id)
2581 .to_string();
2582 let old_node = format!("fact:{old_bare}");
2583 let store = &self.bridge.store;
2584 let old =
2585 tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&old_bare)))
2586 .map_err(|e| ErrorData::internal_error(format!("get old fact error: {e}"), None))?;
2587 let Some(old_fact) = old else {
2588 return Err(ErrorData::invalid_params(
2589 format!("No fact with id '{old_fact_id}'"),
2590 None,
2591 ));
2592 };
2593
2594 let ns = namespace.unwrap_or_else(|| old_fact.namespace.clone());
2595 let new_id = tokio::task::block_in_place(|| {
2596 Handle::current().block_on(store.add_fact(&ns, &content, source.as_deref(), None))
2597 })
2598 .map_err(|e| ErrorData::internal_error(format!("add replacement fact error: {e}"), None))?;
2599 let new_node = format!("fact:{new_id}");
2600 let metadata = serde_json::json!({
2601 "reason": reason.unwrap_or_else(|| "replacement fact supersedes stale fact".to_string()),
2602 "old_fact_id": old_bare,
2603 });
2604 let edge = tokio::task::block_in_place(|| {
2605 Handle::current().block_on(store.add_graph_edge(
2606 &new_node,
2607 &old_node,
2608 GraphEdgeType::Entity {
2609 relation: "supersedes".to_string(),
2610 },
2611 1.0,
2612 Some(metadata),
2613 ))
2614 })
2615 .map_err(|e| ErrorData::internal_error(format!("add supersedes edge error: {e}"), None))?;
2616
2617 json_to_output(&serde_json::json!({
2618 "ok": true,
2619 "receipt": mcp_receipt("sm_supersede_fact"),
2620 "new_fact_id": new_id,
2621 "new_result_id": new_node,
2622 "old_fact_id": old_bare,
2623 "old_result_id": old_node,
2624 "namespace": ns,
2625 "edge_id": edge.id,
2626 "relation": "supersedes",
2627 }))
2628 }
2629
2630 #[allow(dead_code)]
2637 fn sm_create_session(
2638 &self,
2639 Parameters(CreateSessionParams { channel, metadata }): Parameters<CreateSessionParams>,
2640 ) -> Result<Json<StructuredOutput>, ErrorData> {
2641 let meta: Option<serde_json::Value> = metadata
2642 .as_deref()
2643 .and_then(|s| serde_json::from_str(s).ok());
2644 let store = &self.bridge.store;
2645 let result = tokio::task::block_in_place(|| {
2646 Handle::current().block_on(store.create_session_with_metadata(&channel, meta))
2647 });
2648 match result {
2649 Ok(id) => json_to_output(
2650 &serde_json::json!({"ok": true, "session_id": id, "channel": channel, "receipt": mcp_receipt("sm_create_session")}),
2651 ),
2652 Err(e) => Err(ErrorData::internal_error(
2653 format!("create_session error: {e}"),
2654 None,
2655 )),
2656 }
2657 }
2658
2659 #[allow(dead_code)]
2663 fn sm_add_message(
2664 &self,
2665 Parameters(AddMessageParams {
2666 session_id,
2667 role,
2668 content,
2669 }): Parameters<AddMessageParams>,
2670 ) -> Result<Json<StructuredOutput>, ErrorData> {
2671 let parsed_role = match role.to_lowercase().as_str() {
2672 "user" => semantic_memory::types::Role::User,
2673 "assistant" => semantic_memory::types::Role::Assistant,
2674 "system" => semantic_memory::types::Role::System,
2675 "tool" => semantic_memory::types::Role::Tool,
2676 other => {
2677 return Err(ErrorData::invalid_params(
2678 format!("invalid role '{other}' (use user|assistant|system|tool)"),
2679 None,
2680 ))
2681 }
2682 };
2683 let store = &self.bridge.store;
2684 let result = tokio::task::block_in_place(|| {
2685 Handle::current().block_on(store.add_message_embedded(
2686 &session_id,
2687 parsed_role,
2688 &content,
2689 None,
2690 None,
2691 ))
2692 });
2693 match result {
2694 Ok(id) => json_to_output(
2695 &serde_json::json!({"ok": true, "message_id": id, "session_id": session_id, "receipt": mcp_receipt("sm_add_message")}),
2696 ),
2697 Err(e) => Err(ErrorData::internal_error(
2698 format!("add_message error: {e}"),
2699 None,
2700 )),
2701 }
2702 }
2703
2704 #[tool(
2705 description = "List recent conversation sessions (newest first) with message counts.",
2706 annotations(read_only_hint = true)
2707 )]
2708 fn sm_list_sessions(
2709 &self,
2710 Parameters(ListSessionsParams { limit, offset }): Parameters<ListSessionsParams>,
2711 ) -> Result<Json<StructuredOutput>, ErrorData> {
2712 let lim = limit.map(|v| v as usize).unwrap_or(20);
2713 let off = offset.map(|v| v as usize).unwrap_or(0);
2714 let store = &self.bridge.store;
2715 let result = tokio::task::block_in_place(|| {
2716 Handle::current().block_on(store.list_sessions(lim, off))
2717 });
2718 match result {
2719 Ok(sessions) => json_to_output(&serde_json::json!({
2720 "ok": true,
2721 "count": sessions.len(),
2722 "sessions": sessions.iter().map(|s| serde_json::json!({
2723 "session_id": s.id,
2724 "channel": s.channel,
2725 "message_count": s.message_count,
2726 "created_at": s.created_at,
2727 "updated_at": s.updated_at,
2728 })).collect::<Vec<_>>(),
2729 })),
2730 Err(e) => Err(ErrorData::internal_error(
2731 format!("list_sessions error: {e}"),
2732 None,
2733 )),
2734 }
2735 }
2736
2737 #[tool(
2738 description = "Get most recent messages from a session within a token budget (default 4000), chronological order. Returns role, content, timestamps.",
2739 annotations(read_only_hint = true)
2740 )]
2741 fn sm_get_messages(
2742 &self,
2743 Parameters(GetMessagesParams {
2744 session_id,
2745 max_tokens,
2746 }): Parameters<GetMessagesParams>,
2747 ) -> Result<Json<StructuredOutput>, ErrorData> {
2748 let budget = max_tokens.unwrap_or(4000);
2749 let store = &self.bridge.store;
2750 let result = tokio::task::block_in_place(|| {
2751 Handle::current().block_on(store.get_messages_within_budget(&session_id, budget))
2752 });
2753 match result {
2754 Ok(msgs) => json_to_output(&serde_json::json!({
2755 "ok": true,
2756 "session_id": session_id,
2757 "count": msgs.len(),
2758 "messages": msgs.iter().map(|m| serde_json::json!({
2759 "id": m.id,
2760 "role": m.role,
2761 "content": m.content,
2762 "token_count": m.token_count,
2763 "created_at": m.created_at,
2764 })).collect::<Vec<_>>(),
2765 })),
2766 Err(e) => Err(ErrorData::internal_error(
2767 format!("get_messages error: {e}"),
2768 None,
2769 )),
2770 }
2771 }
2772
2773 #[tool(
2774 description = "Hybrid semantic search over stored conversation MESSAGES (not facts). Recall what was discussed in past sessions. Returns ranked messages.",
2775 annotations(read_only_hint = true)
2776 )]
2777 fn sm_search_conversations(
2778 &self,
2779 Parameters(SearchConversationsParams { query, top_k }): Parameters<
2780 SearchConversationsParams,
2781 >,
2782 ) -> Result<Json<StructuredOutput>, ErrorData> {
2783 let k = top_k.map(|v| v as usize);
2784 let store = &self.bridge.store;
2785 let result = tokio::task::block_in_place(|| {
2786 Handle::current().block_on(store.search_conversations(&query, k, None))
2787 });
2788 match result {
2789 Ok(results) => json_to_output(&serde_json::json!({
2790 "ok": true,
2791 "count": results.len(),
2792 "results": results.iter().map(|r| serde_json::json!({
2793 "result_id": r.source.result_id(),
2794 "content": r.content,
2795 "score": r.score,
2796 "cosine_similarity": r.cosine_similarity,
2797 })).collect::<Vec<_>>(),
2798 })),
2799 Err(e) => Err(ErrorData::internal_error(
2800 format!("search_conversations error: {e}"),
2801 None,
2802 )),
2803 }
2804 }
2805
2806 #[tool(
2813 description = "Profile a query and get an adaptive routing decision. Determines which retrieval stages (BM25, vector, rerank, graph, decoder, discord) to activate.",
2814 annotations(read_only_hint = true)
2815 )]
2816 fn sm_route_query(
2817 &self,
2818 Parameters(RouteQueryParams { query }): Parameters<RouteQueryParams>,
2819 ) -> Result<Json<StructuredOutput>, ErrorData> {
2820 use semantic_memory::rl_routing::{is_trained, route_with_policy};
2821 use semantic_memory::routing::{QueryProfile, RetrievalRouter};
2822
2823 let router = RetrievalRouter {
2824 decoder_enabled: true,
2825 discord_enabled: true,
2826 corpus_density: 0.5,
2827 ..Default::default()
2828 };
2829
2830 let profile = QueryProfile::from_query(&query);
2831 let policy = tokio::task::block_in_place(|| {
2832 Handle::current().block_on(self.bridge.store.load_routing_policy())
2833 })
2834 .map_err(|e| ErrorData::internal_error(format!("load routing policy error: {e}"), None))?;
2835 let (decision, routing_source) = match policy.as_ref().filter(|p| is_trained(p)) {
2836 Some(policy) => (route_with_policy(policy, &profile), "trained_policy"),
2837 None => (router.route(&profile), "heuristic"),
2838 };
2839 json_to_output(&serde_json::json!({
2840 "ok": true,
2841 "routing_source": routing_source,
2842 "bm25_coarse": decision.bm25_coarse,
2843 "vector_medium": decision.vector_medium,
2844 "rerank_fine": decision.rerank_fine,
2845 "graph_expansion": decision.graph_expansion,
2846 "decoder": decision.decoder,
2847 "discord": decision.discord,
2848 "no_retrieval": decision.no_retrieval,
2849 "reasoning": decision.reasoning,
2850 }))
2851 }
2852
2853 #[tool(
2854 description = "Adaptive search: profiles query, routes to appropriate stages, applies factor graph belief propagation if decoder is activated. Returns results with stable IDs.",
2855 annotations(read_only_hint = true)
2856 )]
2857 fn sm_search_with_routing(
2858 &self,
2859 Parameters(SearchWithRoutingParams {
2860 query,
2861 top_k,
2862 contradictions,
2863 group_by_community,
2864 }): Parameters<SearchWithRoutingParams>,
2865 ) -> Result<Json<StructuredOutput>, ErrorData> {
2866 use semantic_memory::integration::plan_execution;
2867 use semantic_memory::rl_routing::{is_trained, route_with_policy};
2868 use semantic_memory::routing::{QueryProfile, RetrievalRouter};
2869
2870 let k = top_k.map(|v| v as usize).unwrap_or(5);
2871 let allow_superseded = query_allows_superseded(&query);
2872 let search_k = if allow_superseded { k } else { (k * 4).max(20) };
2873
2874 let router = RetrievalRouter {
2875 decoder_enabled: true,
2876 discord_enabled: true,
2877 corpus_density: 0.5,
2878 ..Default::default()
2879 };
2880
2881 let store = &self.bridge.store;
2884 let policy =
2885 tokio::task::block_in_place(|| Handle::current().block_on(store.load_routing_policy()))
2886 .map_err(|e| {
2887 ErrorData::internal_error(format!("load routing policy error: {e}"), None)
2888 })?;
2889 let profile = QueryProfile::from_query(&query);
2890 let (decision, routing_source) = match policy.as_ref().filter(|p| is_trained(p)) {
2891 Some(policy) => (route_with_policy(policy, &profile), "trained_policy"),
2892 None => (router.route(&profile), "heuristic"),
2893 };
2894 let contras = contradictions.unwrap_or_default();
2895 let plan = plan_execution(&decision, contras.clone());
2896
2897 let store = &self.bridge.store;
2898 let search_result = tokio::task::block_in_place(|| {
2899 Handle::current().block_on(store.search(&query, Some(search_k), None, None))
2900 });
2901
2902 match search_result {
2903 Ok(results) => {
2904 let superseded_targets = if allow_superseded {
2905 HashSet::new()
2906 } else {
2907 load_superseded_targets(store)?
2908 };
2909 let fresh_results: Vec<_> = results
2910 .iter()
2911 .filter(|r| !superseded_targets.contains(&r.source.result_id()))
2912 .collect();
2913 let result_refs: Vec<_> = if superseded_targets.is_empty() {
2914 results.iter().collect()
2915 } else {
2916 fresh_results
2917 };
2918 let superseded_filtered_count = results.len().saturating_sub(result_refs.len());
2919 let json_results: Vec<serde_json::Value> = result_refs
2920 .iter()
2921 .take(k)
2922 .map(|r| {
2923 serde_json::json!({
2924 "result_id": r.source.result_id(),
2925 "content": r.content,
2926 "score": r.score,
2927 })
2928 })
2929 .collect();
2930
2931 let mut factor_graph_payload = serde_json::json!({
2932 "enabled": false,
2933 });
2934
2935 let mut decoder_executed = false;
2936 let mut discord_executed = false;
2937 let mut discord_results_payload: Vec<serde_json::Value> = Vec::new();
2938
2939 if decision.decoder {
2940 #[cfg(feature = "full")]
2941 {
2942 use semantic_memory::factor_graph::{
2943 factors_from_edges, FactorGraph, FactorGraphConfig,
2944 };
2945
2946 let graph_edges = tokio::task::block_in_place(|| {
2947 Handle::current().block_on(store.list_all_graph_edges())
2948 });
2949
2950 match graph_edges {
2951 Ok(edges) => {
2952 let raw_edges: Vec<(
2953 String,
2954 String,
2955 semantic_memory::GraphEdgeType,
2956 f64,
2957 Option<String>,
2958 )> = edges
2959 .iter()
2960 .map(|edge| {
2961 let parsed_type = edge
2962 .edge_type_parsed
2963 .clone()
2964 .or_else(|| serde_json::from_str(&edge.edge_type).ok())
2965 .unwrap_or(semantic_memory::GraphEdgeType::Entity {
2966 relation: "unknown".to_string(),
2967 });
2968 (
2969 edge.source.clone(),
2970 edge.target.clone(),
2971 parsed_type,
2972 edge.weight,
2973 edge.metadata.clone(),
2974 )
2975 })
2976 .collect();
2977
2978 let nodes: Vec<(String, f64)> = result_refs
2979 .iter()
2980 .map(|r| (r.source.result_id(), r.score))
2981 .collect();
2982 let factors = factors_from_edges(&raw_edges);
2983 let graph =
2984 FactorGraph::new(&nodes, factors, FactorGraphConfig::default());
2985 let propagated = graph.propagate();
2986 let top_beliefs = propagated.top_k(k);
2987
2988 factor_graph_payload = serde_json::json!({
2989 "enabled": true,
2990 "top_k_beliefs": top_beliefs
2991 .into_iter()
2992 .map(|(item_id, belief)| serde_json::json!({
2993 "item_id": item_id,
2994 "belief": belief,
2995 }))
2996 .collect::<Vec<_>>(),
2997 "iterations": propagated.iterations,
2998 "converged": propagated.converged,
2999 "elapsed_ms": propagated.elapsed_ms,
3000 "factor_counts": {
3001 "semantic": propagated.factor_counts.semantic,
3002 "temporal": propagated.factor_counts.temporal,
3003 "causal": propagated.factor_counts.causal,
3004 "entity": propagated.factor_counts.entity,
3005 "total": propagated.factor_counts.total(),
3006 },
3007 });
3008 decoder_executed = true;
3009 }
3010 Err(e) => {
3011 factor_graph_payload = serde_json::json!({
3012 "enabled": false,
3013 "error": format!("factor graph analysis failed: {e}"),
3014 });
3015 }
3016 }
3017 }
3018
3019 #[cfg(not(feature = "full"))]
3020 {
3021 factor_graph_payload = serde_json::json!({
3022 "enabled": false,
3023 "reason": "factor graph analysis requires the `full` feature",
3024 });
3025 }
3026
3027 if !plan.contradictions.is_empty() {
3028 use semantic_memory::decoder::{compute_correction, detect_syndromes};
3029 let result_scores: Vec<(String, f64)> = result_refs
3030 .iter()
3031 .map(|r| (r.source.result_id(), r.score))
3032 .collect();
3033 let syndromes = detect_syndromes(&result_scores, &plan.contradictions);
3034 let _ = compute_correction(&syndromes, 10.0);
3035 decoder_executed = true;
3036 }
3037 }
3038
3039 if plan.use_discord {
3040 use semantic_memory::discord::DiscordScorer;
3041 let direct_ids: Vec<String> =
3042 result_refs.iter().map(|r| r.source.result_id()).collect();
3043 let existing_ids: std::collections::HashSet<String> =
3044 direct_ids.iter().cloned().collect();
3045 if let Ok(edges) = load_neighborhood_edge_refs(&self.bridge.store, &direct_ids)
3046 {
3047 let scorer = DiscordScorer::with_defaults();
3048 let discord_hits = scorer.score(&direct_ids, &edges);
3049 for hit in &discord_hits {
3050 if !existing_ids.contains(&hit.item_id) {
3051 discord_results_payload.push(serde_json::json!({
3052 "result_id": hit.item_id,
3053 "discord_score": hit.discord_score,
3054 "anchor_ids": hit.anchor_ids,
3055 "relationship_types": hit.relationship_types,
3056 }));
3057 }
3058 }
3059 discord_executed = true;
3060 }
3061 }
3062
3063 let mut matryoshka_payload = serde_json::json!({
3064 "enabled": false,
3065 });
3066 if decision.vector_medium {
3067 #[cfg(feature = "full")]
3068 {
3069 use semantic_memory::integration::multi_resolution_route;
3070 use semantic_memory::matryoshka::MatryoshkaConfig;
3071 use semantic_memory::routing::QueryProfile;
3072
3073 let route_profile = QueryProfile::from_query(&query);
3074 let route_decision =
3075 multi_resolution_route(&route_profile, &MatryoshkaConfig::default());
3076 matryoshka_payload = serde_json::json!({
3077 "enabled": true,
3078 "candidate_dim": route_decision.candidate_dim,
3079 "heuristic_recall_estimate": route_decision.estimated_recall,
3080 "recall_basis": "heuristic_dimensional_model_not_corpus_measured",
3081 "embedding_dim": route_decision.embedding_dim,
3082 "reasoning": route_decision.reasoning,
3083 });
3084 }
3085
3086 #[cfg(not(feature = "full"))]
3087 {
3088 matryoshka_payload = serde_json::json!({
3089 "enabled": false,
3090 "reason": "matryoshka routing requires the `full` feature",
3091 });
3092 }
3093 }
3094
3095 let grouped_results_payload: serde_json::Value = if group_by_community == Some(true)
3097 {
3098 let seed_ids: Vec<String> = result_refs
3099 .iter()
3100 .take(k)
3101 .map(|r| r.source.result_id())
3102 .collect();
3103 let edges = load_neighborhood_edge_pairs(store, &seed_ids).unwrap_or_default();
3104 if !edges.is_empty() {
3105 use semantic_memory::community::detect_communities;
3106 let communities = detect_communities(&edges, 1.0, 42);
3107 let mut member_to_comm: std::collections::HashMap<String, String> =
3108 std::collections::HashMap::new();
3109 for c in &communities {
3110 for m in &c.members {
3111 member_to_comm.insert(m.clone(), c.id.clone());
3112 }
3113 }
3114 let mut groups: std::collections::HashMap<String, Vec<serde_json::Value>> =
3115 std::collections::HashMap::new();
3116 let mut ungrouped: Vec<serde_json::Value> = Vec::new();
3117 for r in &json_results {
3118 if let Some(rid) = r.get("result_id").and_then(|v| v.as_str()) {
3119 match member_to_comm.get(rid).cloned() {
3120 Some(cid) => groups.entry(cid).or_default().push(r.clone()),
3121 None => ungrouped.push(r.clone()),
3122 }
3123 }
3124 }
3125 let mut map = serde_json::Map::new();
3126 for (cid, items) in groups {
3127 map.insert(format!("community_{cid}"), serde_json::json!(items));
3128 }
3129 if !ungrouped.is_empty() {
3130 map.insert("ungrouped".to_string(), serde_json::json!(ungrouped));
3131 }
3132 serde_json::Value::Object(map)
3133 } else {
3134 serde_json::Value::Null
3135 }
3136 } else {
3137 serde_json::Value::Null
3138 };
3139
3140 let mut topology_payload = serde_json::json!({ "auto_called": false });
3142 {
3143 use semantic_memory::routing::{QueryComplexityClass, QueryProfile};
3144 let route_profile = QueryProfile::from_query(&query);
3145 if route_profile.complexity_class == QueryComplexityClass::Synthesis
3146 && result_refs.len() > 10
3147 {
3148 #[cfg(feature = "full")]
3149 {
3150 use semantic_memory::topology::{compute_betti_numbers, find_voids};
3151 let edges = load_stored_edge_pairs(store).unwrap_or_default();
3152 if !edges.is_empty() {
3153 let mut adjacency: std::collections::HashMap<String, Vec<String>> =
3154 std::collections::HashMap::new();
3155 for (src, tgt) in &edges {
3156 adjacency.entry(src.clone()).or_default().push(tgt.clone());
3157 adjacency.entry(tgt.clone()).or_default().push(src.clone());
3158 }
3159 let betti = compute_betti_numbers(&adjacency);
3160 let voids = find_voids(&edges);
3161 topology_payload = serde_json::json!({
3162 "auto_called": true,
3163 "trigger": "synthesis_class_with_10_plus_results",
3164 "betti_numbers": {
3165 "betti_0": betti.betti_0,
3166 "betti_1": betti.betti_1,
3167 },
3168 "void_count": voids.len(),
3169 "voids": voids.iter().map(|v| serde_json::json!({
3170 "description": v.description,
3171 "void_type": format!("{:?}", v.void_type),
3172 "nearby_items": v.nearby_items,
3173 "suggested_connections": v.suggested_connections,
3174 })).collect::<Vec<_>>(),
3175 });
3176 } else {
3177 topology_payload = serde_json::json!({
3178 "auto_called": true,
3179 "trigger": "synthesis_class_with_10_plus_results",
3180 "note": "no graph edges in store",
3181 });
3182 }
3183 }
3184 #[cfg(not(feature = "full"))]
3185 {
3186 topology_payload = serde_json::json!({
3187 "auto_called": true,
3188 "trigger": "synthesis_class_with_10_plus_results",
3189 "error": "topology requires the full feature",
3190 });
3191 }
3192 }
3193 }
3194
3195 json_to_output(&serde_json::json!({
3196 "ok": true,
3197 "routing_decision": {
3198 "source": routing_source,
3199 "bm25_coarse": decision.bm25_coarse,
3200 "vector_medium": decision.vector_medium,
3201 "rerank_fine": decision.rerank_fine,
3202 "graph_expansion": decision.graph_expansion,
3203 "decoder": decision.decoder,
3204 "discord": decision.discord,
3205 "no_retrieval": decision.no_retrieval,
3206 "reasoning": decision.reasoning,
3207 },
3208 "results": json_results,
3209 "count": json_results.len(),
3210 "superseded_filtered_count": superseded_filtered_count,
3211 "decoder_planned": plan.use_decoder,
3212 "decoder_executed": decoder_executed,
3213 "discord_planned": plan.use_discord,
3214 "discord_executed": discord_executed,
3215 "discord_results": discord_results_payload,
3216 "factor_graph": factor_graph_payload,
3217 "matryoshka": matryoshka_payload,
3218 "grouped_results": grouped_results_payload,
3219 "topology": topology_payload,
3220 }))
3221 }
3222 Err(e) => Err(ErrorData::internal_error(
3223 format!("Search error: {e}"),
3224 None,
3225 )),
3226 }
3227 }
3228
3229 #[tool(
3230 description = "Detect contradictions in search results. Runs syndrome detection, computes corrections, and applies belief propagation to refine confidence scores.",
3231 annotations(read_only_hint = true)
3232 )]
3233 fn sm_decoder_analyze(
3234 &self,
3235 Parameters(DecoderAnalyzeParams {
3236 results,
3237 contradictions,
3238 }): Parameters<DecoderAnalyzeParams>,
3239 ) -> Result<Json<StructuredOutput>, ErrorData> {
3240 use semantic_memory::decoder::{
3241 compute_correction, detect_syndromes, pass_messages, ConflictGraph,
3242 };
3243
3244 let contras = contradictions.unwrap_or_default();
3245 let syndromes = detect_syndromes(&results, &contras);
3246 let corrections = compute_correction(&syndromes, 10.0);
3247 let graph = ConflictGraph::from_syndromes(&results, &syndromes);
3248 let mp = pass_messages(&graph, 50, 0.001);
3249
3250 json_to_output(&serde_json::json!({
3251 "ok": true,
3252 "syndromes": syndromes.iter().map(|s| serde_json::json!({
3253 "id": s.id,
3254 "severity": format!("{:?}", s.severity),
3255 "items": s.items,
3256 "description": s.description,
3257 "type": format!("{:?}", s.syndrome_type),
3258 })).collect::<Vec<_>>(),
3259 "syndrome_count": syndromes.len(),
3260 "corrections": corrections.iter().map(|c| serde_json::json!({
3261 "id": c.id,
3262 "confidence": c.confidence,
3263 "cost": c.cost,
3264 "operations": c.operations.len(),
3265 })).collect::<Vec<_>>(),
3266 "correction_count": corrections.len(),
3267 "message_passing": {
3268 "iterations": mp.iterations,
3269 "converged": mp.converged,
3270 "elapsed_ms": mp.elapsed_ms,
3271 },
3272 }))
3273 }
3274
3275 #[tool(
3276 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.",
3277 annotations(read_only_hint = true)
3278 )]
3279 fn sm_detect_contradictions(
3280 &self,
3281 Parameters(DetectContradictionsParams {
3282 query,
3283 top_k,
3284 record_to_ledger,
3285 }): Parameters<DetectContradictionsParams>,
3286 ) -> Result<Json<StructuredOutput>, ErrorData> {
3287 use semantic_memory::contradiction_detect::{detect_contradictions, DetectorConfig};
3288
3289 let k = top_k.map(|v| v as usize).unwrap_or(10);
3290 let store = &self.bridge.store;
3291 let results = tokio::task::block_in_place(|| {
3292 Handle::current().block_on(store.search(&query, Some(k), None, None))
3293 })
3294 .map_err(|e| ErrorData::internal_error(format!("search failed: {e}"), None))?;
3295
3296 let items: Vec<(String, String)> = results
3297 .iter()
3298 .map(|r| (r.source.result_id(), r.content.clone()))
3299 .collect();
3300
3301 let pairs = detect_contradictions(&items, &DetectorConfig::default());
3302
3303 let (ledger_recorded, ledger_entries) = if record_to_ledger.unwrap_or(false) {
3309 #[cfg(feature = "claim-integration")]
3310 {
3311 use claim_ledger::{LedgerEntryBuilder, SupportState};
3312 let mut count = 0usize;
3313 let mut entries = Vec::new();
3314 for p in &pairs {
3315 let fact_a = p.a.strip_prefix("fact:").unwrap_or(&p.a).to_string();
3316 let fact_b = p.b.strip_prefix("fact:").unwrap_or(&p.b).to_string();
3317 {
3318 let mut idx = self.claim_trust.lock().unwrap();
3319 idx.record_judgment(fact_a.clone(), SupportState::Contradicted);
3320 idx.record_judgment(fact_b.clone(), SupportState::Contradicted);
3321 }
3322
3323 let pattern = p
3324 .signals
3325 .iter()
3326 .map(|s| format!("{s:?}"))
3327 .collect::<Vec<_>>()
3328 .join(",");
3329 let contradiction_id =
3330 claim_ledger::ids::contradiction_id(&fact_a, &fact_b, &pattern);
3331
3332 let mut ledger = self.claim_ledger_store.lock().unwrap();
3333 let sequence = ledger.next_sequence();
3334 let previous_digest = ledger.last_digest();
3335 let append_result = LedgerEntryBuilder::new(sequence, previous_digest)
3336 .add_contradiction_candidate(
3337 &contradiction_id,
3338 vec![fact_a.clone(), fact_b.clone()],
3339 &pattern,
3340 &p.reason,
3341 )
3342 .map_err(|e| e.to_string())
3343 .and_then(|entry| ledger.append(entry));
3344 drop(ledger);
3345
3346 match append_result {
3347 Ok(entry_hash) => {
3348 count += 1;
3349 entries.push(serde_json::json!({
3350 "contradiction_id": contradiction_id,
3351 "claim_refs": [fact_a, fact_b],
3352 "sequence": sequence,
3353 "entry_hash": entry_hash,
3354 }));
3355 }
3356 Err(e) => {
3357 return Err(ErrorData::internal_error(
3358 format!("failed to record contradiction to ledger: {e}"),
3359 None,
3360 ));
3361 }
3362 }
3363 }
3364 (count, entries)
3365 }
3366 #[cfg(not(feature = "claim-integration"))]
3367 {
3368 (0, Vec::new())
3369 }
3370 } else {
3371 (0, Vec::new())
3372 };
3373
3374 json_to_output(&serde_json::json!({
3375 "ok": true,
3376 "query": query,
3377 "items_scanned": items.len(),
3378 "contradictions": pairs.iter().map(|p| serde_json::json!({
3379 "a": p.a,
3380 "b": p.b,
3381 "score": p.score,
3382 "signals": p.signals.iter().map(|s| format!("{s:?}")).collect::<Vec<_>>(),
3383 "reason": p.reason,
3384 })).collect::<Vec<_>>(),
3385 "count": pairs.len(),
3386 "ledger_recorded": ledger_recorded,
3387 "ledger_entries": ledger_entries,
3388 "receipt": mcp_receipt("sm_detect_contradictions"),
3389 }))
3390 }
3391
3392 #[tool(
3393 description = "Second-order retrieval: find items related to your search results through the graph, but NOT themselves direct hits. Loads edges from store automatically.",
3394 annotations(read_only_hint = true)
3395 )]
3396 fn sm_discord_search(
3397 &self,
3398 Parameters(DiscordSearchParams { direct_result_ids }): Parameters<DiscordSearchParams>,
3399 ) -> Result<Json<StructuredOutput>, ErrorData> {
3400 use semantic_memory::discord::DiscordScorer;
3401
3402 let edges = load_neighborhood_edge_refs(&self.bridge.store, &direct_result_ids)?;
3405 let scorer = DiscordScorer::with_defaults();
3406 let results = scorer.score(&direct_result_ids, &edges);
3407
3408 json_to_output(&serde_json::json!({
3409 "ok": true,
3410 "discord_results": results.iter().map(|r| serde_json::json!({
3411 "item_id": r.item_id,
3412 "discord_score": r.discord_score,
3413 "anchor_ids": r.anchor_ids,
3414 "relationship_types": r.relationship_types,
3415 })).collect::<Vec<_>>(),
3416 "count": results.len(),
3417 "edges_loaded": edges.len(),
3418 "edges_scope": "neighborhood",
3419 }))
3420 }
3421
3422 #[tool(
3423 description = "Set provenance (evidence confidence) for an item. Confidence in [0.0, 1.0] with support count. Returns a provenance receipt.",
3424 annotations(idempotent_hint = true)
3425 )]
3426 fn sm_set_provenance(
3427 &self,
3428 Parameters(SetProvenanceParams {
3429 item_id,
3430 confidence,
3431 support_count,
3432 }): Parameters<SetProvenanceParams>,
3433 ) -> Result<Json<StructuredOutput>, ErrorData> {
3434 use semantic_memory::provenance::{
3435 ConfidenceSemiring, ConfidenceValue, ProvenanceItemType,
3436 };
3437
3438 if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
3440 return Err(ErrorData::invalid_params(
3441 format!("confidence must be a finite value in [0.0, 1.0], got {confidence}"),
3442 None,
3443 ));
3444 }
3445
3446 let value = ConfidenceValue::new(confidence, support_count);
3447 let store = &self.bridge.store;
3448
3449 let result = tokio::task::block_in_place(|| {
3450 Handle::current().block_on(store.set_provenance::<ConfidenceSemiring>(
3451 &ProvenanceItemType::Fact,
3452 &item_id,
3453 &value,
3454 &[],
3455 None,
3456 ))
3457 });
3458
3459 match result {
3460 Ok(receipt) => json_to_output(&serde_json::json!({
3461 "ok": true,
3462 "provenance_id": receipt.provenance_id,
3463 "item_id": receipt.item_id,
3464 "semiring_type": receipt.semiring_type,
3465 "recorded_at": receipt.recorded_at,
3466 "message": "Provenance set successfully",
3467 })),
3468 Err(e) => Err(ErrorData::internal_error(
3469 format!("Provenance error: {e}"),
3470 None,
3471 )),
3472 }
3473 }
3474
3475 #[tool(
3476 description = "Run a memory lifecycle pass: analyze items for syndromes, compute corrections, identify subtraction candidates, and check compression needs.",
3477 annotations(read_only_hint = true)
3478 )]
3479 fn sm_run_lifecycle(
3480 &self,
3481 Parameters(RunLifecycleParams { item_ids }): Parameters<RunLifecycleParams>,
3482 ) -> Result<Json<StructuredOutput>, ErrorData> {
3483 use semantic_memory::decoder::{compute_correction, detect_syndromes};
3484 use semantic_memory::integration::{
3485 corrections_to_subtraction_candidates, should_trigger_recompression,
3486 };
3487
3488 let results: Vec<(String, f64)> = item_ids.iter().map(|id| (id.clone(), 0.5)).collect();
3489 let syndromes = detect_syndromes(&results, &[]);
3490 let corrections = compute_correction(&syndromes, 10.0);
3491
3492 let sub_candidates = corrections_to_subtraction_candidates(&corrections);
3493
3494 let subtracted_count = sub_candidates.len();
3495 let remaining_count = item_ids.len().saturating_sub(subtracted_count);
3496 let recompression = should_trigger_recompression(subtracted_count, remaining_count, false);
3497
3498 let store = &self.bridge.store;
3499 let graph_edges = tokio::task::block_in_place(|| {
3500 Handle::current().block_on(store.list_all_graph_edges())
3501 });
3502 let stored_edges: Vec<(String, String)> = graph_edges
3503 .as_ref()
3504 .map(|edges| {
3505 edges
3506 .iter()
3507 .map(|edge| (edge.source.clone(), edge.target.clone()))
3508 .collect()
3509 })
3510 .unwrap_or_default();
3511
3512 let mut topology_voids: Vec<serde_json::Value> = Vec::new();
3513 let mut betti = serde_json::json!({
3514 "betti_0": 0usize,
3515 "betti_1": 0usize,
3516 });
3517 #[allow(unused_mut)]
3518 let mut topology_error: Option<String> = None;
3519
3520 let mut communities: Vec<serde_json::Value> = Vec::new();
3521 let mut community_contradictions: Vec<serde_json::Value> = Vec::new();
3522 #[allow(unused_mut)]
3523 let mut community_error: Option<String> = None;
3524
3525 let mut subgraph_assessment = serde_json::json!({
3526 "subgraphs_identified": 0usize,
3527 "subgraphs_pruned": 0usize,
3528 });
3529 #[allow(unused_mut)]
3530 let mut subgraph_error: Option<String> = None;
3531
3532 #[cfg(feature = "full")]
3533 {
3534 use std::collections::HashMap;
3535
3536 if !stored_edges.is_empty() {
3537 let analysis_edges = stored_edges.clone();
3538
3539 use semantic_memory::topology::{compute_betti_numbers, find_voids};
3540
3541 let mut adjacency: HashMap<String, Vec<String>> = HashMap::new();
3542 for (left, right) in &analysis_edges {
3543 adjacency
3544 .entry(left.clone())
3545 .or_default()
3546 .push(right.clone());
3547 adjacency
3548 .entry(right.clone())
3549 .or_default()
3550 .push(left.clone());
3551 }
3552
3553 let betti_numbers = compute_betti_numbers(&adjacency);
3554 betti = serde_json::json!({
3555 "betti_0": betti_numbers.betti_0,
3556 "betti_1": betti_numbers.betti_1,
3557 });
3558
3559 topology_voids = find_voids(&analysis_edges)
3560 .into_iter()
3561 .map(|v| {
3562 serde_json::json!({
3563 "description": v.description,
3564 "void_type": format!("{:?}", v.void_type),
3565 "nearby_items": v.nearby_items,
3566 "suggested_connections": v.suggested_connections,
3567 })
3568 })
3569 .collect();
3570
3571 use semantic_memory::community::{
3572 community_contradiction_scan, detect_communities,
3573 };
3574
3575 let detected = detect_communities(&analysis_edges, 1.0, 42);
3576 communities = detected
3577 .iter()
3578 .map(|c| {
3579 serde_json::json!({
3580 "id": c.id,
3581 "members": c.members,
3582 "level": c.level,
3583 "parent": c.parent,
3584 "member_count": c.members.len(),
3585 })
3586 })
3587 .collect();
3588
3589 community_contradictions = community_contradiction_scan(&detected, &[])
3590 .into_iter()
3591 .map(|cc| {
3592 serde_json::json!({
3593 "community_id": cc.community_id,
3594 "item_a": cc.item_a,
3595 "item_b": cc.item_b,
3596 "description": cc.description,
3597 })
3598 })
3599 .collect();
3600
3601 use semantic_memory::integration::autonomous_subgraph_maintenance;
3602 use semantic_memory::subgraph_pruning::AccessLog;
3603 use std::collections::HashSet;
3604
3605 let mut access_items: HashSet<String> = HashSet::new();
3606 for (left, right) in &analysis_edges {
3607 access_items.insert(left.clone());
3608 access_items.insert(right.clone());
3609 }
3610
3611 let access_logs = access_items
3612 .into_iter()
3613 .map(|item| AccessLog {
3614 item_id: item,
3615 access_count: 1,
3616 last_accessed: "1970-01-01T00:00:00Z".to_string(),
3617 })
3618 .collect::<Vec<_>>();
3619
3620 let report = autonomous_subgraph_maintenance(&analysis_edges, &access_logs, &[], 0);
3621 subgraph_assessment = serde_json::json!({
3622 "subgraphs_identified": report.subgraphs_identified,
3623 "subgraphs_pruned": report.subgraphs_pruned,
3624 "summary": report.summary,
3625 });
3626 }
3627 }
3628
3629 #[cfg(not(feature = "full"))]
3630 {
3631 if !stored_edges.is_empty() {
3632 topology_error = Some(
3633 "topology/community/subgraph phases require the `full` feature".to_string(),
3634 );
3635 community_error = Some(
3636 "topology/community/subgraph phases require the `full` feature".to_string(),
3637 );
3638 subgraph_error = Some(
3639 "topology/community/subgraph phases require the `full` feature".to_string(),
3640 );
3641 }
3642 }
3643
3644 #[cfg(feature = "full")]
3645 let (f32_count, compressed_count) =
3646 item_ids
3647 .iter()
3648 .fold((0usize, 0usize), |(f32_count, compressed_count), _| {
3649 use semantic_memory::compression_governor::{
3650 decide_quantization, QuantizationLevel,
3651 };
3652
3653 match decide_quantization(0.5) {
3654 QuantizationLevel::F32 => (f32_count + 1, compressed_count),
3655 _ => (f32_count, compressed_count + 1),
3656 }
3657 });
3658 #[cfg(not(feature = "full"))]
3659 let (f32_count, compressed_count) = (0usize, 0usize);
3660
3661 json_to_output(&serde_json::json!({
3662 "ok": true,
3663 "items_analyzed": item_ids.len(),
3664 "syndromes_detected": syndromes.len(),
3665 "corrections_computed": corrections.len(),
3666 "subtraction_candidates": sub_candidates.iter().map(|c| serde_json::json!({
3667 "item_id": c.item_id,
3668 "structuring_score": c.structuring_score,
3669 "operation_type": c.operation_type,
3670 "reason": c.reason,
3671 })).collect::<Vec<_>>(),
3672 "recompression_triggered": recompression.triggered,
3673 "recompression_reason": recompression.reason,
3674 "topology": {
3675 "enabled": !stored_edges.is_empty(),
3676 "voids": topology_voids,
3677 "void_count": topology_voids.len(),
3678 "betti_numbers": betti,
3679 "error": topology_error,
3680 },
3681 "community_detection": {
3682 "enabled": !stored_edges.is_empty(),
3683 "communities": communities,
3684 "community_count": communities.len(),
3685 "contradictions": community_contradictions,
3686 "contradiction_count": community_contradictions.len(),
3687 "error": community_error,
3688 },
3689 "subgraph_pruning_assessment": {
3690 "enabled": !stored_edges.is_empty(),
3691 "subgraph_count": subgraph_assessment["subgraphs_identified"].as_u64().unwrap_or(0),
3692 "pruned_count": subgraph_assessment["subgraphs_pruned"].as_u64().unwrap_or(0),
3693 "summary": subgraph_assessment["summary"].as_str().unwrap_or(""),
3694 "error": subgraph_error,
3695 },
3696 "turbo_quantization_assessment": {
3697 "items_assessed": item_ids.len(),
3698 "would_retain_f32": f32_count,
3699 "would_compress": compressed_count,
3700 },
3701 "summary": format!(
3702 "Analyzed {} items: {} syndromes, {} corrections, {} subtraction candidates, recompression: {}",
3703 item_ids.len(), syndromes.len(), corrections.len(), sub_candidates.len(),
3704 if recompression.triggered { "needed" } else { "not needed" }
3705 ),
3706 }))
3707 }
3708
3709 #[tool(
3712 description = "Add a durable, typed graph edge between two nodes. Edge types: semantic, temporal, causal, entity. Idempotent — same edge returns existing ID.",
3713 annotations(idempotent_hint = true)
3714 )]
3715 fn sm_add_graph_edge(
3716 &self,
3717 Parameters(params): Parameters<AddGraphEdgeParams>,
3718 ) -> Result<Json<StructuredOutput>, ErrorData> {
3719 use semantic_memory::GraphEdgeType;
3720
3721 if let Some(cs) = params.cosine_similarity {
3723 if !cs.is_finite() || !(0.0..=1.0).contains(&cs) {
3724 return Err(ErrorData::invalid_params(
3725 format!("cosine_similarity must be finite and in [0.0, 1.0], got {cs}"),
3726 None,
3727 ));
3728 }
3729 }
3730 if let Some(conf) = params.confidence {
3731 if !conf.is_finite() || !(0.0..=1.0).contains(&conf) {
3732 return Err(ErrorData::invalid_params(
3733 format!("confidence must be finite and in [0.0, 1.0], got {conf}"),
3734 None,
3735 ));
3736 }
3737 }
3738
3739 let edge_type = match params.edge_type {
3740 EdgeType::Semantic => GraphEdgeType::Semantic {
3741 cosine_similarity: params.cosine_similarity.unwrap_or(0.5),
3742 },
3743 EdgeType::Temporal => GraphEdgeType::Temporal {
3744 delta_secs: params.delta_secs.unwrap_or(0),
3745 },
3746 EdgeType::Causal => GraphEdgeType::Causal {
3747 confidence: params.confidence.unwrap_or(0.5),
3748 evidence_ids: params.evidence_ids.unwrap_or_default(),
3749 },
3750 EdgeType::Entity => GraphEdgeType::Entity {
3751 relation: params.relation.unwrap_or_else(|| "related".to_string()),
3752 },
3753 };
3754
3755 let metadata = match params.metadata.as_deref() {
3757 None => None,
3758 Some(s) => match serde_json::from_str::<serde_json::Value>(s) {
3759 Ok(v) => Some(v),
3760 Err(e) => {
3761 return Err(ErrorData::invalid_params(
3762 format!("metadata is not valid JSON: {e}"),
3763 None,
3764 ))
3765 }
3766 },
3767 };
3768
3769 let store = &self.bridge.store;
3770 let result = tokio::task::block_in_place(|| {
3771 Handle::current().block_on(store.add_graph_edge(
3772 ¶ms.source,
3773 ¶ms.target,
3774 edge_type,
3775 params.weight,
3776 metadata,
3777 ))
3778 });
3779
3780 match result {
3781 Ok(edge) => json_to_output(&serde_json::json!({
3782 "ok": true,
3783 "receipt": mcp_receipt("sm_add_graph_edge"),
3784 "id": edge.id,
3785 "source": edge.source,
3786 "target": edge.target,
3787 "edge_type": edge.edge_type,
3788 "weight": edge.weight,
3789 "content_digest": edge.content_digest,
3790 "recorded_at": edge.recorded_at,
3791 "message": "Graph edge added successfully",
3792 })),
3793 Err(e) => Err(ErrorData::internal_error(
3794 format!("Error adding graph edge: {e}"),
3795 None,
3796 )),
3797 }
3798 }
3799
3800 #[tool(
3801 description = "List graph edges for a specific node (as source or target), or all edges if no node_id. Returns non-invalidated edges only.",
3802 annotations(read_only_hint = true)
3803 )]
3804 fn sm_list_graph_edges(
3805 &self,
3806 Parameters(ListGraphEdgesParams { node_id }): Parameters<ListGraphEdgesParams>,
3807 ) -> Result<Json<StructuredOutput>, ErrorData> {
3808 let store = &self.bridge.store;
3809 let result = match node_id {
3810 Some(id) => tokio::task::block_in_place(|| {
3811 Handle::current().block_on(store.list_graph_edges_for_node(&id))
3812 }),
3813 None => tokio::task::block_in_place(|| {
3814 Handle::current().block_on(store.list_all_graph_edges())
3815 }),
3816 };
3817
3818 match result {
3819 Ok(edges) => json_to_output(&serde_json::json!({
3820 "ok": true,
3821 "edges": edges.iter().map(|e| serde_json::json!({
3822 "id": e.id,
3823 "source": e.source,
3824 "target": e.target,
3825 "edge_type": e.edge_type,
3826 "weight": e.weight,
3827 "metadata": e.metadata,
3828 "recorded_at": e.recorded_at,
3829 })).collect::<Vec<_>>(),
3830 "count": edges.len(),
3831 })),
3832 Err(e) => Err(ErrorData::internal_error(
3833 format!("Error listing graph edges: {e}"),
3834 None,
3835 )),
3836 }
3837 }
3838
3839 #[tool(
3840 description = "Invalidate a stored graph edge by ID. Append-only — edge is never deleted, only marked invalidated with a reason.",
3841 annotations(idempotent_hint = true)
3842 )]
3843 fn sm_invalidate_graph_edge(
3844 &self,
3845 Parameters(InvalidateGraphEdgeParams { edge_id, reason }): Parameters<
3846 InvalidateGraphEdgeParams,
3847 >,
3848 ) -> Result<Json<StructuredOutput>, ErrorData> {
3849 let store = &self.bridge.store;
3850 let result = tokio::task::block_in_place(|| {
3851 Handle::current().block_on(store.invalidate_graph_edge(&edge_id, &reason))
3852 });
3853
3854 match result {
3855 Ok(()) => json_to_output(&serde_json::json!({
3856 "ok": true,
3857 "receipt": mcp_receipt("sm_invalidate_graph_edge"),
3858 "edge_id": edge_id,
3859 "message": "Edge invalidated successfully",
3860 })),
3861 Err(e) => Err(ErrorData::internal_error(
3862 format!("Error invalidating edge: {e}"),
3863 None,
3864 )),
3865 }
3866 }
3867
3868 #[tool(
3871 description = "Run factor graph belief propagation on stored graph edges. Models all 4 edge types as factors. Returns unified confidence scores after convergence.",
3872 annotations(read_only_hint = true)
3873 )]
3874 fn sm_factor_graph(
3875 &self,
3876 Parameters(params): Parameters<FactorGraphParams>,
3877 ) -> Result<Json<StructuredOutput>, ErrorData> {
3878 use semantic_memory::factor_graph::{factors_from_edges, FactorGraph, FactorGraphConfig};
3879
3880 let defaults = FactorGraphConfig::default();
3881 let config = FactorGraphConfig {
3882 semantic_weight: params.semantic_weight.unwrap_or(defaults.semantic_weight),
3883 temporal_weight: params.temporal_weight.unwrap_or(defaults.temporal_weight),
3884 causal_weight: params.causal_weight.unwrap_or(defaults.causal_weight),
3885 entity_weight: params.entity_weight.unwrap_or(defaults.entity_weight),
3886 self_influence: params.self_influence.unwrap_or(defaults.self_influence),
3887 max_iterations: params
3888 .max_iterations
3889 .map(|v| v as usize)
3890 .unwrap_or(defaults.max_iterations),
3891 convergence_threshold: params
3892 .convergence_threshold
3893 .unwrap_or(defaults.convergence_threshold),
3894 };
3895
3896 let seed_ids: Vec<String> = params.nodes.iter().map(|n| n.item_id.clone()).collect();
3899 let raw_edges = load_neighborhood_factor_edges(&self.bridge.store, &seed_ids)?;
3900 let factors = factors_from_edges(&raw_edges);
3901
3902 let nodes: Vec<(String, f64)> = params
3903 .nodes
3904 .iter()
3905 .map(|n| (n.item_id.clone(), n.initial_belief))
3906 .collect();
3907
3908 let graph = FactorGraph::new(&nodes, factors, config);
3909 let result = graph.propagate();
3910
3911 json_to_output(&serde_json::json!({
3912 "ok": true,
3913 "node_beliefs": result.node_beliefs,
3914 "iterations": result.iterations,
3915 "converged": result.converged,
3916 "elapsed_ms": result.elapsed_ms,
3917 "edges_loaded": raw_edges.len(),
3918 "edges_scope": "neighborhood",
3919 "factor_counts": {
3920 "semantic": result.factor_counts.semantic,
3921 "temporal": result.factor_counts.temporal,
3922 "causal": result.factor_counts.causal,
3923 "entity": result.factor_counts.entity,
3924 "total": result.factor_counts.total(),
3925 },
3926 "config": {
3927 "semantic_weight": result.config.semantic_weight,
3928 "temporal_weight": result.config.temporal_weight,
3929 "causal_weight": result.config.causal_weight,
3930 "entity_weight": result.config.entity_weight,
3931 "self_influence": result.config.self_influence,
3932 "max_iterations": result.config.max_iterations,
3933 "convergence_threshold": result.config.convergence_threshold,
3934 },
3935 }))
3936 }
3937
3938 #[tool(
3939 description = "Find topological voids in the knowledge graph. Computes Betti numbers (components and cycles) and detects structural gaps. Loads edges from store.",
3940 annotations(read_only_hint = true)
3941 )]
3942 fn sm_topology(
3943 &self,
3944 Parameters(_params): Parameters<TopologyParams>,
3945 ) -> Result<Json<StructuredOutput>, ErrorData> {
3946 use semantic_memory::topology::{compute_betti_numbers, find_voids, gap_report};
3947
3948 let edges = load_stored_edge_pairs(&self.bridge.store)?;
3950
3951 let mut adjacency: std::collections::HashMap<String, Vec<String>> =
3952 std::collections::HashMap::new();
3953 for (src, tgt) in &edges {
3954 adjacency.entry(src.clone()).or_default().push(tgt.clone());
3955 adjacency.entry(tgt.clone()).or_default().push(src.clone());
3956 }
3957
3958 let betti = compute_betti_numbers(&adjacency);
3959 let voids = find_voids(&edges);
3960 let report = gap_report(&voids);
3961
3962 json_to_output(&serde_json::json!({
3963 "ok": true,
3964 "betti_numbers": {
3965 "betti_0": betti.betti_0,
3966 "betti_1": betti.betti_1,
3967 },
3968 "voids": voids.iter().map(|v| serde_json::json!({
3969 "description": v.description,
3970 "nearby_items": v.nearby_items,
3971 "suggested_connections": v.suggested_connections,
3972 "void_type": format!("{:?}", v.void_type),
3973 })).collect::<Vec<_>>(),
3974 "void_count": voids.len(),
3975 "edges_loaded_from_store": edges.len(),
3976 "report": report,
3977 }))
3978 }
3979
3980 #[tool(
3981 description = "Detect communities in the knowledge graph (Leiden-inspired). Returns community assignments, optional contradiction scans, and compression recommendations.",
3982 annotations(read_only_hint = true)
3983 )]
3984 fn sm_community(
3985 &self,
3986 Parameters(params): Parameters<CommunityParams>,
3987 ) -> Result<Json<StructuredOutput>, ErrorData> {
3988 use semantic_memory::community::{
3989 community_aware_compression, community_contradiction_scan, detect_communities,
3990 };
3991
3992 let edges = load_stored_edge_pairs(&self.bridge.store)?;
3994
3995 let resolution = params.resolution.unwrap_or(1.0);
3996 let seed = params.seed.unwrap_or(42);
3997
3998 let communities = detect_communities(&edges, resolution, seed);
3999
4000 let contradictions = params.contradictions.unwrap_or_default();
4001 let community_contras = community_contradiction_scan(&communities, &contradictions);
4002
4003 let importance_scores = params.importance_scores.unwrap_or_default();
4004 let compression = community_aware_compression(&communities, &importance_scores);
4005
4006 let summarize = params.summarize.unwrap_or(false);
4007 let store = &self.bridge.store;
4008 let communities_json: Vec<serde_json::Value> = communities
4009 .iter()
4010 .map(|c| {
4011 let summary: Option<String> = if summarize && !c.members.is_empty() {
4012 let member_texts: Vec<String> = c
4013 .members
4014 .iter()
4015 .filter_map(|mid| {
4016 let bare = mid.strip_prefix("fact:").unwrap_or(mid);
4017 tokio::task::block_in_place(|| {
4018 Handle::current().block_on(store.get_fact(bare))
4019 })
4020 .ok()
4021 .flatten()
4022 .map(|f| f.content)
4023 })
4024 .collect();
4025 if !member_texts.is_empty() {
4026 let combined = member_texts.join("\n---\n");
4027 let prompt = format!(
4028 "Summarize these related facts in 1-2 sentences:\n{combined}\nSummary:"
4029 );
4030 let body = serde_json::json!({
4031 "model": "granite4.1:3b",
4032 "prompt": prompt,
4033 "stream": false,
4034 "options": {"temperature": 0, "num_predict": 100}
4035 });
4036 reqwest::blocking::Client::new()
4037 .post("http://127.0.0.1:11434/api/generate")
4038 .json(&body)
4039 .send()
4040 .ok()
4041 .and_then(|resp| resp.json::<serde_json::Value>().ok())
4042 .and_then(|v| {
4043 v.get("response")
4044 .and_then(|r| r.as_str())
4045 .map(|s| s.trim().to_string())
4046 })
4047 } else {
4048 None
4049 }
4050 } else {
4051 None
4052 };
4053 serde_json::json!({
4054 "id": c.id,
4055 "members": c.members,
4056 "level": c.level,
4057 "parent": c.parent,
4058 "member_count": c.members.len(),
4059 "summary": summary,
4060 })
4061 })
4062 .collect();
4063
4064 json_to_output(&serde_json::json!({
4065 "ok": true,
4066 "communities": communities_json,
4067 "community_count": communities.len(),
4068 "contradictions": community_contras.iter().map(|cc| serde_json::json!({
4069 "community_id": cc.community_id,
4070 "item_a": cc.item_a,
4071 "item_b": cc.item_b,
4072 "description": cc.description,
4073 })).collect::<Vec<_>>(),
4074 "contradiction_count": community_contras.len(),
4075 "compression_recommendations": compression.iter().map(|cr| serde_json::json!({
4076 "community_id": cr.community_id,
4077 "quantization_level": cr.quantization_level,
4078 "reason": cr.reason,
4079 })).collect::<Vec<_>>(),
4080 "compression_count": compression.len(),
4081 "edges_loaded_from_store": edges.len(),
4082 }))
4083 }
4084
4085 #[tool(
4091 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.",
4092 annotations(read_only_hint = true)
4093 )]
4094 fn sm_subgraph_prune(
4095 &self,
4096 Parameters(SubgraphPruneParams { dry_run, max_prune }): Parameters<SubgraphPruneParams>,
4097 ) -> Result<Json<StructuredOutput>, ErrorData> {
4098 #[cfg(feature = "subgraph-pruning")]
4099 {
4100 use semantic_memory::integration::autonomous_subgraph_maintenance;
4101 use semantic_memory::subgraph_pruning::AccessLog;
4102
4103 let dry = dry_run.unwrap_or(true);
4104 let max = max_prune.map(|v| v as usize).unwrap_or(5);
4105
4106 let edges = load_stored_edge_pairs(&self.bridge.store)?;
4108
4109 let access_logs: Vec<AccessLog> = Vec::new();
4111
4112 let raw_edges = tokio::task::block_in_place(|| {
4114 Handle::current().block_on(self.bridge.store.list_all_graph_edges())
4115 })
4116 .map_err(|e| ErrorData::internal_error(format!("load edges failed: {e}"), None))?;
4117 let contradictions: Vec<(String, String)> = raw_edges
4118 .iter()
4119 .filter_map(|e| {
4120 let parsed = e
4121 .edge_type_parsed
4122 .clone()
4123 .or_else(|| serde_json::from_str(&e.edge_type).ok());
4124 match parsed {
4125 Some(semantic_memory::GraphEdgeType::Entity { relation })
4126 if relation == "contradicts" =>
4127 {
4128 Some((e.source.clone(), e.target.clone()))
4129 }
4130 _ => None,
4131 }
4132 })
4133 .collect();
4134
4135 let prune_count = if dry { 0 } else { max };
4136 let report =
4137 autonomous_subgraph_maintenance(&edges, &access_logs, &contradictions, prune_count);
4138
4139 json_to_output(&serde_json::json!({
4140 "ok": true,
4141 "dry_run": dry,
4142 "subgraphs_identified": report.subgraphs_identified,
4143 "subgraphs_pruned": report.subgraphs_pruned,
4144 "receipts": report.receipts.iter().map(|r| serde_json::json!({
4145 "subgraph_root": r.subgraph_root,
4146 "pruned_nodes": r.pruned_nodes,
4147 })).collect::<Vec<_>>(),
4148 "summary": report.summary,
4149 "receipt_field": mcp_receipt("sm_subgraph_prune"),
4150 }))
4151 }
4152 #[cfg(not(feature = "subgraph-pruning"))]
4153 {
4154 let _ = (dry_run, max_prune);
4155 json_to_output(&serde_json::json!({
4156 "ok": true,
4157 "note": "subgraph-pruning feature not enabled",
4158 "receipt": mcp_receipt("sm_subgraph_prune"),
4159 }))
4160 }
4161 }
4162
4163 #[tool(
4164 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.",
4165 annotations(destructive_hint = true)
4166 )]
4167 fn sm_delete_fact(
4168 &self,
4169 Parameters(DeleteFactParams { fact_id }): Parameters<DeleteFactParams>,
4170 ) -> Result<Json<StructuredOutput>, ErrorData> {
4171 let bare = fact_id
4172 .strip_prefix("fact:")
4173 .unwrap_or(&fact_id)
4174 .to_string();
4175 let store = &self.bridge.store;
4176 let fact = tokio::task::block_in_place(|| {
4177 Handle::current().block_on(store.get_fact_raw_compat(&bare))
4178 })
4179 .map_err(|e| ErrorData::internal_error(format!("delete_fact lookup error: {e}"), None))?
4180 .ok_or_else(|| ErrorData::invalid_params(format!("fact not found: fact:{bare}"), None))?;
4181 let origin_record = tokio::task::block_in_place(|| {
4182 Handle::current().block_on(store.authority().get_origin_authority(&bare))
4183 })
4184 .map_err(|e| {
4185 ErrorData::internal_error(format!("delete_fact origin lookup error: {e}"), None)
4186 })?
4187 .ok_or_else(|| {
4188 ErrorData::invalid_params(
4189 format!("fact has no governed origin authority: fact:{bare}"),
4190 None,
4191 )
4192 })?;
4193 let resource_principal = origin_record.label.origin_principal;
4194 let permit = semantic_memory::AuthorityPermit::operator_system(
4195 resource_principal.clone(),
4196 "caller:sm_delete_fact",
4197 semantic_memory::AuthorityPermit::FORGET_CAPABILITY,
4198 )
4199 .with_origin(semantic_memory::OriginAuthorityLabelV1::operator_system(
4200 &resource_principal,
4201 "caller:sm_delete_fact",
4202 ));
4203 let request = semantic_memory::ForgettingClosureRequestV1::new(
4204 vec![bare.clone()],
4205 fact.namespace,
4206 "explicit MCP fact-forgetting request",
4207 4096,
4208 );
4209 let result = tokio::task::block_in_place(|| {
4210 Handle::current().block_on(store.authority().forget(
4211 permit,
4212 format!("mcp-sm-delete-fact:{}", uuid::Uuid::new_v4()),
4213 request,
4214 ))
4215 });
4216 match result {
4217 Ok(receipt) => json_to_output(&serde_json::json!({
4218 "ok": true,
4219 "deleted": false,
4220 "forgotten": true,
4221 "fact_id": format!("fact:{bare}"),
4222 "forgetting_receipt": receipt,
4223 "message": "Fact forgotten through governed dependency closure",
4224 })),
4225 Err(e) => Err(ErrorData::internal_error(
4226 format!("delete_fact forgetting error: {e}"),
4227 None,
4228 )),
4229 }
4230 }
4231
4232 #[tool(
4233 description = "Permanently delete ALL memory in a namespace — facts, documents, chunks, sessions/messages. HARD delete, irreversible. Returns per-surface deletion count.",
4234 annotations(destructive_hint = true)
4235 )]
4236 fn sm_delete_namespace(
4237 &self,
4238 Parameters(DeleteNamespaceParams { namespace }): Parameters<DeleteNamespaceParams>,
4239 ) -> Result<Json<StructuredOutput>, ErrorData> {
4240 let store = &self.bridge.store;
4241 let result = tokio::task::block_in_place(|| {
4242 Handle::current().block_on(store.delete_namespace(&namespace))
4243 });
4244 match result {
4245 Ok(r) => json_to_output(&serde_json::json!({
4246 "ok": true,
4247 "receipt": mcp_receipt("sm_delete_namespace"),
4248 "namespace": namespace,
4249 "deleted": {
4250 "facts": r.facts,
4251 "documents": r.documents,
4252 "chunks": r.chunks,
4253 "messages": r.messages,
4254 "sessions": r.sessions,
4255 "episodes": r.episodes,
4256 "projection_rows": r.projection_rows,
4257 },
4258 "message": "Namespace permanently deleted",
4259 })),
4260 Err(e) => Err(ErrorData::internal_error(
4261 format!("delete_namespace error: {e}"),
4262 None,
4263 )),
4264 }
4265 }
4266
4267 #[tool(
4268 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.",
4269 annotations(idempotent_hint = true)
4270 )]
4271 fn sm_update_fact(
4272 &self,
4273 Parameters(UpdateFactParams { fact_id, content }): Parameters<UpdateFactParams>,
4274 ) -> Result<Json<StructuredOutput>, ErrorData> {
4275 let bare = fact_id
4276 .strip_prefix("fact:")
4277 .unwrap_or(&fact_id)
4278 .to_string();
4279 let store = &self.bridge.store;
4280 let result = tokio::task::block_in_place(|| {
4281 Handle::current().block_on(store.update_fact(&bare, &content))
4282 });
4283 match result {
4284 Ok(()) => json_to_output(&serde_json::json!({
4285 "ok": true,
4286 "receipt": mcp_receipt("sm_update_fact"),
4287 "fact_id": format!("fact:{bare}"),
4288 "message": "Fact content updated and re-embedded",
4289 })),
4290 Err(e) => Err(ErrorData::internal_error(
4291 format!("update_fact error: {e}"),
4292 None,
4293 )),
4294 }
4295 }
4296
4297 #[tool(
4298 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."
4299 )]
4300 fn sm_consolidate_facts(
4301 &self,
4302 Parameters(ConsolidateFactsParams {
4303 keep_id,
4304 supersede_id,
4305 merged_content,
4306 }): Parameters<ConsolidateFactsParams>,
4307 ) -> Result<Json<StructuredOutput>, ErrorData> {
4308 let keep_bare = keep_id
4309 .strip_prefix("fact:")
4310 .unwrap_or(&keep_id)
4311 .to_string();
4312 let sup_bare = supersede_id
4313 .strip_prefix("fact:")
4314 .unwrap_or(&supersede_id)
4315 .to_string();
4316 let store = &self.bridge.store;
4317
4318 let keep_fact =
4320 tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&keep_bare)));
4321 let sup_fact =
4322 tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&sup_bare)));
4323
4324 let (_namespace, final_content) = match (keep_fact, sup_fact) {
4325 (Ok(Some(k)), Ok(Some(s))) => {
4326 let ns = k.namespace.clone();
4327 let content = merged_content.unwrap_or_else(|| {
4328 if k.content.len() >= s.content.len() {
4329 if !k.content.contains(&s.content) {
4330 format!("{}\n\nAdditional: {}", k.content, s.content)
4331 } else {
4332 k.content.clone()
4333 }
4334 } else if !s.content.contains(&k.content) {
4335 format!("{}\n\nAdditional: {}", s.content, k.content)
4336 } else {
4337 s.content.clone()
4338 }
4339 });
4340 (ns, content)
4341 }
4342 (Ok(Some(k)), _) => (
4343 k.namespace.clone(),
4344 merged_content.unwrap_or(k.content.clone()),
4345 ),
4346 (Err(_), _) | (Ok(None), _) => {
4347 return Err(ErrorData::internal_error(
4348 "keep fact not found".to_string(),
4349 None,
4350 ));
4351 }
4352 };
4353
4354 let result = tokio::task::block_in_place(|| {
4356 Handle::current().block_on(store.consolidate_facts(
4357 &keep_bare,
4358 &sup_bare,
4359 &final_content,
4360 ))
4361 });
4362
4363 match result {
4364 Ok(()) => json_to_output(&serde_json::json!({
4365 "ok": true,
4366 "receipt": mcp_receipt("sm_consolidate_facts"),
4367 "kept_fact_id": format!("fact:{}", keep_bare),
4368 "superseded_fact_id": format!("fact:{}", sup_bare),
4369 "message": format!("Facts consolidated atomically: kept fact:{} supersedes fact:{}", keep_bare, sup_bare),
4370 })),
4371 Err(e) => Err(ErrorData::internal_error(
4372 format!("consolidation failed: {e}"),
4373 None,
4374 )),
4375 }
4376 }
4377
4378 #[tool(
4381 description = "Return the current persisted RL routing policy, including weights, training example count, and last update time.",
4382 annotations(read_only_hint = true)
4383 )]
4384 fn sm_get_routing_policy(&self) -> Result<Json<StructuredOutput>, ErrorData> {
4385 use semantic_memory::rl_routing::is_trained;
4386
4387 let policy = tokio::task::block_in_place(|| {
4388 Handle::current().block_on(self.bridge.store.load_routing_policy())
4389 })
4390 .map_err(|e| ErrorData::internal_error(format!("load routing policy error: {e}"), None))?;
4391
4392 match policy {
4393 Some(policy) => json_to_output(&serde_json::json!({
4394 "ok": true,
4395 "policy": {
4396 "weights": policy.weights,
4397 "training_examples_count": policy.trained_examples,
4398 "trained": is_trained(&policy),
4399 "last_updated": policy.last_updated,
4400 }
4401 })),
4402 None => json_to_output(&serde_json::json!({
4403 "ok": true,
4404 "policy": null,
4405 })),
4406 }
4407 }
4408
4409 #[tool(
4410 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.",
4411 annotations(
4412 read_only_hint = false,
4413 destructive_hint = false,
4414 idempotent_hint = false
4415 )
4416 )]
4417 fn sm_record_outcome(
4418 &self,
4419 Parameters(RecordOutcomeParams { query, outcome }): Parameters<RecordOutcomeParams>,
4420 ) -> Result<Json<StructuredOutput>, ErrorData> {
4421 use semantic_memory::rl_routing::{
4422 is_trained, record_routing_outcome, route_with_policy, RoutingOutcome,
4423 };
4424 use semantic_memory::routing::{QueryProfile, RetrievalRouter};
4425
4426 let outcome_enum = match outcome.to_lowercase().as_str() {
4427 "good" => RoutingOutcome::Good,
4428 "bad" => RoutingOutcome::Bad,
4429 "neutral" => RoutingOutcome::Neutral,
4430 _ => {
4431 return Err(ErrorData::invalid_params(
4432 format!("outcome must be 'good', 'bad', or 'neutral', got '{outcome}'"),
4433 None,
4434 ));
4435 }
4436 };
4437
4438 let profile = QueryProfile::from_query(&query);
4439 let router = RetrievalRouter::default();
4440 let store = &self.bridge.store;
4441 let mut batch = self
4442 .routing_policy_batch
4443 .lock()
4444 .map_err(|_| ErrorData::internal_error("routing policy batch lock poisoned", None))?;
4445 let mut policy = match batch.policy.take() {
4446 Some(policy) => policy,
4447 None => tokio::task::block_in_place(|| {
4448 Handle::current().block_on(store.load_routing_policy())
4449 })
4450 .map_err(|e| {
4451 ErrorData::internal_error(format!("load routing policy error: {e}"), None)
4452 })?
4453 .unwrap_or_default(),
4454 };
4455 let decision = if is_trained(&policy) {
4456 route_with_policy(&policy, &profile)
4457 } else {
4458 router.route(&profile)
4459 };
4460 record_routing_outcome(&mut policy, &profile, &decision, outcome_enum);
4461 batch.pending_outcomes += 1;
4462 let persisted = batch.pending_outcomes >= ROUTING_POLICY_PERSIST_BATCH;
4463 if persisted {
4464 if let Err(e) = tokio::task::block_in_place(|| {
4465 Handle::current().block_on(store.save_routing_policy(&policy))
4466 }) {
4467 batch.policy = Some(policy);
4468 return Err(ErrorData::internal_error(
4469 format!("persist routing policy error: {e}"),
4470 None,
4471 ));
4472 }
4473 batch.pending_outcomes = 0;
4474 }
4475 let pending_outcomes = batch.pending_outcomes;
4476 batch.policy = Some(policy.clone());
4477
4478 json_to_output(&serde_json::json!({
4479 "ok": true,
4480 "receipt": mcp_receipt("sm_record_outcome"),
4481 "mutating": true,
4482 "query": query,
4483 "feedback": {"kind": "ProxyLabel", "label": outcome},
4484 "routing_decision": {
4485 "bm25_coarse": decision.bm25_coarse,
4486 "vector_medium": decision.vector_medium,
4487 "rerank_fine": decision.rerank_fine,
4488 "graph_expansion": decision.graph_expansion,
4489 "decoder": decision.decoder,
4490 "discord": decision.discord,
4491 "no_retrieval": decision.no_retrieval,
4492 "reasoning": decision.reasoning,
4493 },
4494 "policy_state": {
4495 "trained_examples": policy.trained_examples,
4496 "baseline": policy.baseline,
4497 "weights": policy.weights,
4498 "last_updated": policy.last_updated,
4499 "persisted": persisted,
4500 "pending_outcomes": pending_outcomes,
4501 },
4502 "message": if persisted {
4503 "Routing outcome recorded and policy batch persisted to DB"
4504 } else {
4505 "Routing outcome recorded; policy persistence batch pending"
4506 },
4507 }))
4508 }
4509
4510 #[cfg(feature = "claim-integration")]
4513 #[tool(
4514 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.",
4515 annotations(read_only_hint = false, destructive_hint = true)
4516 )]
4517 fn sm_compact_claim_ledger(
4518 &self,
4519 Parameters(CompactClaimLedgerParams {
4520 dry_run,
4521 max_entries,
4522 max_bytes,
4523 retain_tail_entries,
4524 max_backups,
4525 }): Parameters<CompactClaimLedgerParams>,
4526 ) -> Result<Json<StructuredOutput>, ErrorData> {
4527 let mut ledger = self.claim_ledger_store.lock().unwrap();
4528 let result = ledger
4529 .compact(ClaimLedgerCompactionConfig {
4530 dry_run: dry_run.unwrap_or(true),
4531 max_entries: max_entries.unwrap_or(10_000),
4532 max_bytes: max_bytes.unwrap_or(16 * 1024 * 1024),
4533 retain_tail_entries: retain_tail_entries.unwrap_or(256),
4534 max_backups: max_backups.unwrap_or(3),
4535 })
4536 .map_err(|error| ErrorData::internal_error(error, None))?;
4537 if result["compacted"].as_bool().unwrap_or(false) {
4538 let mut index = self.claim_trust.lock().unwrap();
4539 index.disable();
4540 if ledger.trust_enabled {
4541 index.enabled = true;
4542 if let Some(snapshot) = &ledger.snapshot {
4543 index.load_snapshot(snapshot);
4544 }
4545 index.rebuild_from_ledger_incremental(&ledger.entries);
4546 }
4547 }
4548 json_to_output(&result)
4549 }
4550
4551 #[cfg(feature = "claim-integration")]
4552 #[tool(
4553 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.",
4554 annotations(read_only_hint = false, idempotent_hint = true)
4555 )]
4556 fn sm_create_claim(
4557 &self,
4558 Parameters(CreateClaimParams {
4559 fact_id,
4560 source_span,
4561 }): Parameters<CreateClaimParams>,
4562 ) -> Result<Json<StructuredOutput>, ErrorData> {
4563 use claim_ledger::Claim;
4564 let bare = fact_id
4565 .strip_prefix("fact:")
4566 .unwrap_or(&fact_id)
4567 .to_string();
4568 let store = &self.bridge.store;
4569
4570 let fact =
4572 tokio::task::block_in_place(|| Handle::current().block_on(store.get_fact(&bare)));
4573 let fact = match fact {
4574 Ok(Some(f)) => f,
4575 _ => {
4576 return Err(ErrorData::internal_error(
4577 format!("fact not found: {fact_id}"),
4578 None,
4579 ))
4580 }
4581 };
4582
4583 let source_id = format!("semantic-memory:fact:{bare}");
4585 let span_id = source_span.unwrap_or_else(|| "full".to_string());
4586 let claim = Claim::new(&source_id, &span_id, &fact.content, "fact");
4587
4588 let claim_id = claim.claim_id.clone();
4589 let normalized = &claim.normalized_claim;
4590
4591 {
4592 let mut ledger = self.claim_ledger_store.lock().unwrap();
4593 let sequence = ledger.next_sequence();
4594 let previous_digest = ledger.last_digest();
4595 let entry = claim_ledger::LedgerEntryBuilder::new(sequence, previous_digest)
4596 .add_claim(&claim_id, &source_id, &span_id, normalized)
4597 .map_err(|e| {
4598 ErrorData::internal_error(
4599 format!("failed to build claim ledger entry: {e}"),
4600 None,
4601 )
4602 })?;
4603 ledger.append(entry).map_err(|e| {
4604 ErrorData::internal_error(format!("failed to record claim to ledger: {e}"), None)
4605 })?;
4606 }
4607
4608 {
4609 let mut idx = self.claim_trust.lock().unwrap();
4610 idx.link_fact(bare.clone(), claim_id.clone());
4611 idx.register_claim(claim_id.clone(), normalized.clone());
4612 }
4613
4614 json_to_output(&serde_json::json!({
4615 "ok": true,
4616 "receipt": mcp_receipt("sm_create_claim"),
4617 "claim_id": claim_id,
4618 "source_id": source_id,
4619 "span_id": span_id,
4620 "claim_text": fact.content,
4621 "normalized_claim": normalized,
4622 "claim_type": "fact",
4623 "message": "Claim created from semantic-memory fact with source-spanned provenance",
4624 }))
4625 }
4626
4627 #[cfg(feature = "claim-integration")]
4628 #[tool(
4629 description = "Add evidence to a claim. Creates an EvidenceBundle linking the evidence text to the claim. Returns the evidence bundle ID.",
4630 annotations(read_only_hint = false)
4631 )]
4632 fn sm_add_evidence(
4633 &self,
4634 Parameters(AddEvidenceParams {
4635 claim_id,
4636 evidence_text,
4637 source_type,
4638 }): Parameters<AddEvidenceParams>,
4639 ) -> Result<Json<StructuredOutput>, ErrorData> {
4640 use claim_ledger::{EvidenceBundle, EvidenceLink, EvidenceRelation};
4641 let mut bundle = EvidenceBundle::new(&claim_id);
4642 let link = EvidenceLink {
4643 relation: EvidenceRelation::Supports,
4644 source_id: source_type.unwrap_or_else(|| "semantic-memory".to_string()),
4645 span_id: "full".to_string(),
4646 quote: evidence_text.clone(),
4647 digest: claim_ledger::ids::sha256_text(&evidence_text),
4648 support_role: "supporting".to_string(),
4649 };
4650 bundle.evidence_links.push(link);
4651
4652 let evidence_path = self.bridge.memory_dir.join("evidence_bundles.jsonl");
4654 let line = serde_json::to_string(&bundle).map_err(|e| {
4655 ErrorData::internal_error(format!("failed to serialize evidence bundle: {e}"), None)
4656 })?;
4657 use std::io::Write;
4658 let mut file = std::fs::OpenOptions::new()
4659 .create(true)
4660 .append(true)
4661 .open(&evidence_path)
4662 .map_err(|e| {
4663 ErrorData::internal_error(
4664 format!("failed to open evidence bundles file: {e}"),
4665 None,
4666 )
4667 })?;
4668 writeln!(file, "{line}").map_err(|e| {
4669 ErrorData::internal_error(format!("failed to write evidence bundle: {e}"), None)
4670 })?;
4671 file.sync_data().map_err(|e| {
4672 ErrorData::internal_error(format!("failed to fsync evidence bundle: {e}"), None)
4673 })?;
4674
4675 json_to_output(&serde_json::json!({
4676 "ok": true,
4677 "receipt": mcp_receipt("sm_add_evidence"),
4678 "evidence_bundle_id": bundle.evidence_bundle_id,
4679 "claim_id": claim_id,
4680 "evidence_count": bundle.evidence_links.len(),
4681 "message": "Evidence added to claim and persisted",
4682 }))
4683 }
4684
4685 #[cfg(feature = "claim-integration")]
4686 #[tool(
4687 description = "Judge the support state of a claim. Creates a SupportJudgment (supported, unsupported, contested, or heuristic_only) with optional rationale.",
4688 annotations(read_only_hint = false)
4689 )]
4690 fn sm_judge_support(
4691 &self,
4692 Parameters(JudgeSupportParams {
4693 claim_id,
4694 judgment,
4695 rationale,
4696 }): Parameters<JudgeSupportParams>,
4697 ) -> Result<Json<StructuredOutput>, ErrorData> {
4698 use claim_ledger::{SupportJudgment, SupportState};
4699 let state = match judgment.to_lowercase().as_str() {
4700 "supported" => SupportState::Supported,
4701 "partially_supported" | "partial" => SupportState::PartiallySupported,
4702 "unsupported" => SupportState::Unsupported,
4703 "contradicted" | "contested" => SupportState::Contradicted,
4704 "heuristic_only" | "heuristic" => SupportState::HeuristicOnly,
4705 _ => return Err(ErrorData::invalid_params(
4706 format!("Invalid judgment '{judgment}'. Must be: supported, partially_supported, unsupported, contradicted, or heuristic_only"),
4707 None,
4708 )),
4709 };
4710 let j = SupportJudgment {
4711 support_judgment_id: claim_ledger::ids::ulid(),
4712 claim_id: claim_id.clone(),
4713 evidence_bundle_ref: claim_ledger::ids::evidence_bundle_id(&claim_id),
4714 support_state: state,
4715 method: "agent_judgment".to_string(),
4716 rationale: rationale.unwrap_or_default(),
4717 contradiction_refs: Vec::new(),
4718 proof_debt: Vec::new(),
4719 created_recorded_time: chrono::Utc::now(),
4720 };
4721
4722 {
4723 let mut ledger = self.claim_ledger_store.lock().unwrap();
4724 let sequence = ledger.next_sequence();
4725 let previous_digest = ledger.last_digest();
4726 let entry = claim_ledger::LedgerEntryBuilder::new(sequence, previous_digest)
4727 .add_support_judgment(
4728 &j.support_judgment_id,
4729 &claim_id,
4730 &j.evidence_bundle_ref,
4731 state,
4732 &j.method,
4733 )
4734 .map_err(|e| {
4735 ErrorData::internal_error(
4736 format!("failed to build support judgment ledger entry: {e}"),
4737 None,
4738 )
4739 })?;
4740 ledger.append(entry).map_err(|e| {
4741 ErrorData::internal_error(
4742 format!("failed to record support judgment to ledger: {e}"),
4743 None,
4744 )
4745 })?;
4746 }
4747
4748 self.claim_trust
4749 .lock()
4750 .unwrap()
4751 .record_judgment(claim_id.clone(), state);
4752
4753 json_to_output(&serde_json::json!({
4754 "ok": true,
4755 "receipt": mcp_receipt("sm_judge_support"),
4756 "support_judgment_id": j.support_judgment_id,
4757 "claim_id": claim_id,
4758 "state": judgment.to_lowercase(),
4759 "message": "Support judgment recorded",
4760 }))
4761 }
4762
4763 #[tool(
4766 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.",
4767 annotations(read_only_hint = true)
4768 )]
4769 fn sm_search_as_of(
4770 &self,
4771 Parameters(SearchAsOfParams {
4772 query,
4773 as_of_date,
4774 top_k,
4775 namespace,
4776 }): Parameters<SearchAsOfParams>,
4777 ) -> Result<Json<StructuredOutput>, ErrorData> {
4778 let store = &self.bridge.store;
4779 let k = top_k.unwrap_or(5);
4780 let ns_slice: Option<Vec<&str>> = namespace.as_ref().map(|n| vec![n.as_str()]);
4781
4782 let _as_of = chrono::DateTime::parse_from_rfc3339(&as_of_date)
4784 .map_err(|e| ErrorData::invalid_params(
4785 format!("Invalid as_of_date '{as_of_date}': {e}. Use ISO 8601 format like 2026-01-15T00:00:00Z"),
4786 None,
4787 ))?
4788 .with_timezone(&chrono::Utc);
4789
4790 let results = tokio::task::block_in_place(|| {
4792 Handle::current().block_on(store.search_with_view(
4793 &query,
4794 Some(k * 2),
4795 ns_slice.as_deref(),
4796 None,
4797 semantic_memory::StateView::HistoricalAt(as_of_date.clone()),
4798 ))
4799 })
4800 .map_err(|e| ErrorData::internal_error(format!("search error: {e}"), None))?;
4801
4802 let filtered: Vec<_> = results.into_iter().take(k).collect();
4803
4804 let result_json: Vec<serde_json::Value> = filtered
4805 .iter()
4806 .map(|r| {
4807 serde_json::json!({
4808 "result_id": r.source.result_id(),
4809 "content": r.content,
4810 "score": r.score,
4811 })
4812 })
4813 .collect();
4814
4815 json_to_output(&serde_json::json!({
4816 "ok": true,
4817 "query": query,
4818 "as_of_date": as_of_date,
4819 "results": result_json,
4820 "count": filtered.len(),
4821 "message": format!("Found {} facts valid as of {}", filtered.len(), as_of_date),
4822 }))
4823 }
4824
4825 #[tool(
4828 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.",
4829 annotations(read_only_hint = true)
4830 )]
4831 fn sm_verify_claim(
4832 &self,
4833 Parameters(VerifyClaimParams {
4834 claim,
4835 risk_class,
4836 evidence_refs,
4837 refutation_attempted,
4838 }): Parameters<VerifyClaimParams>,
4839 ) -> Result<Json<StructuredOutput>, ErrorData> {
4840 let risk = risk_class.to_lowercase();
4841 let has_evidence = evidence_refs
4842 .as_ref()
4843 .map(|v| !v.is_empty())
4844 .unwrap_or(false);
4845 let refuted = refutation_attempted.unwrap_or(false);
4846
4847 let (needs_replay, needs_falsification, disposition, rationale) = match risk.as_str() {
4849 "low" => (
4850 false,
4851 false,
4852 "promote",
4853 "Low risk: cheap checks only, claim can be promoted",
4854 ),
4855 "medium" => (
4856 true,
4857 false,
4858 "promote",
4859 "Medium risk: replay check required, claim can be promoted",
4860 ),
4861 "high" => (
4862 true,
4863 true,
4864 if refuted {
4865 "quarantine"
4866 } else if has_evidence {
4867 "promote"
4868 } else {
4869 "defer"
4870 },
4871 if refuted {
4872 "High risk: refutation attempted, claim quarantined"
4873 } else if has_evidence {
4874 "High risk: falsification passed with evidence, claim promoted"
4875 } else {
4876 "High risk: no evidence provided, claim deferred"
4877 },
4878 ),
4879 "critical" => (
4880 true,
4881 true,
4882 if refuted {
4883 "quarantine"
4884 } else if has_evidence && refutation_attempted == Some(true) {
4885 "promote"
4886 } else {
4887 "defer"
4888 },
4889 if refuted {
4890 "Critical risk: refutation found, claim quarantined"
4891 } else if has_evidence && refutation_attempted == Some(true) {
4892 "Critical risk: replay + falsification passed, claim promoted"
4893 } else {
4894 "Critical risk: requires evidence AND refutation, claim deferred"
4895 },
4896 ),
4897 _ => {
4898 return Err(ErrorData::invalid_params(
4899 format!("Invalid risk_class '{risk}'. Must be: low, medium, high, or critical"),
4900 None,
4901 ))
4902 }
4903 };
4904
4905 json_to_output(&serde_json::json!({
4906 "ok": true,
4907 "claim": claim,
4908 "risk_class": risk,
4909 "required_checks": {
4910 "cheap_checks": true,
4911 "replay_checks": needs_replay,
4912 "falsification_checks": needs_falsification,
4913 },
4914 "has_evidence": has_evidence,
4915 "refutation_attempted": refuted,
4916 "disposition": disposition,
4917 "rationale": rationale,
4918 "can_promote": disposition == "promote",
4919 }))
4920 }
4921
4922 #[tool(
4925 description = "Load a durable search receipt by receipt/request ID. Returns the stored receipt with evaluation time, retrieval family, result IDs, and digests.",
4926 annotations(read_only_hint = true)
4927 )]
4928 fn sm_get_search_receipt(
4929 &self,
4930 Parameters(GetSearchReceiptParams { receipt_id }): Parameters<GetSearchReceiptParams>,
4931 ) -> Result<Json<StructuredOutput>, ErrorData> {
4932 let store = &self.bridge.store;
4933 let result = tokio::task::block_in_place(|| {
4934 Handle::current().block_on(store.get_search_receipt(&receipt_id))
4935 });
4936 match result {
4937 Ok(Some(receipt)) => json_to_output(&serde_json::json!({
4938 "ok": true,
4939 "receipt": {
4940 "receipt_id": receipt.receipt_id,
4941 "trace_id": receipt.trace_id,
4942 "search_profile": receipt.search_profile,
4943 "evaluation_time": receipt.evaluation_time,
4944 "result_ids": receipt.result_ids,
4945 "query_embedding_digest": receipt.query_embedding_digest,
4946 "query_text_digest": receipt.query_text_digest,
4947 "query_input_digest": receipt.query_input_digest,
4948 "filter_digest": receipt.filter_digest,
4949 "redaction_state": receipt.redaction_state,
4950 "approximate": receipt.approximate,
4951 "attempt_family_id": receipt.attempt_family_id,
4952 "budget_id": receipt.budget_id,
4953 },
4954 })),
4955 Ok(None) => json_to_output(&serde_json::json!({
4956 "ok": true,
4957 "found": false,
4958 "receipt_id": receipt_id,
4959 "message": "No receipt found with that ID",
4960 })),
4961 Err(e) => Err(ErrorData::internal_error(
4962 format!("get_search_receipt error: {e}"),
4963 None,
4964 )),
4965 }
4966 }
4967
4968 #[tool(
4969 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.",
4970 annotations(read_only_hint = true)
4971 )]
4972 fn sm_replay_search_receipt(
4973 &self,
4974 Parameters(ReplaySearchReceiptParams {
4975 receipt_id,
4976 query,
4977 top_k,
4978 namespaces,
4979 }): Parameters<ReplaySearchReceiptParams>,
4980 ) -> Result<Json<StructuredOutput>, ErrorData> {
4981 let store = &self.bridge.store;
4982 let k = top_k.map(|v| v as usize);
4983 let ns_slice: Option<Vec<&str>> = namespaces
4984 .as_ref()
4985 .map(|v| v.iter().map(|s| s.as_str()).collect());
4986
4987 let result = tokio::task::block_in_place(|| {
4988 Handle::current().block_on(store.replay_search_receipt(
4989 &receipt_id,
4990 &query,
4991 k,
4992 ns_slice.as_deref(),
4993 None,
4994 ))
4995 });
4996 match result {
4997 Ok(report) => json_to_output(&serde_json::json!({
4998 "ok": true,
4999 "receipt_id": report.receipt_id,
5000 "replay_receipt_id": report.replay_receipt_id,
5001 "query_embedding_digest_matches": report.query_embedding_digest_matches,
5002 "result_ids_match": report.result_ids_match,
5003 "missing_result_ids": report.missing_result_ids,
5004 "added_result_ids": report.added_result_ids,
5005 "original_receipt": {
5006 "receipt_id": report.original_receipt.receipt_id,
5007 "result_ids": report.original_receipt.result_ids,
5008 "search_profile": report.original_receipt.search_profile,
5009 "evaluation_time": report.original_receipt.evaluation_time,
5010 },
5011 "replay_receipt": {
5012 "receipt_id": report.replay_receipt.receipt_id,
5013 "result_ids": report.replay_receipt.result_ids,
5014 "search_profile": report.replay_receipt.search_profile,
5015 "evaluation_time": report.replay_receipt.evaluation_time,
5016 },
5017 })),
5018 Err(e) => Err(ErrorData::internal_error(
5019 format!("replay_search_receipt error: {e}"),
5020 None,
5021 )),
5022 }
5023 }
5024
5025 #[tool(
5026 description = "Replay a durable search receipt using query text and filters retained by explicit opt-in. Returns the replay comparison report.",
5027 annotations(read_only_hint = true)
5028 )]
5029 fn sm_replay_search(
5030 &self,
5031 Parameters(ReplayStoredSearchParams { receipt_id }): Parameters<ReplayStoredSearchParams>,
5032 ) -> Result<Json<StructuredOutput>, ErrorData> {
5033 let result = tokio::task::block_in_place(|| {
5034 Handle::current().block_on(
5035 self.bridge
5036 .store
5037 .replay_search_from_stored_inputs(&receipt_id),
5038 )
5039 });
5040 match result {
5041 Ok(report) => json_to_output(&serde_json::json!({
5042 "ok": true,
5043 "receipt_id": report.receipt_id,
5044 "replay_receipt_id": report.replay_receipt_id,
5045 "query_embedding_digest_matches": report.query_embedding_digest_matches,
5046 "result_ids_match": report.result_ids_match,
5047 "missing_result_ids": report.missing_result_ids,
5048 "added_result_ids": report.added_result_ids,
5049 "original_result_ids": report.original_receipt.result_ids,
5050 "replay_result_ids": report.replay_receipt.result_ids,
5051 })),
5052 Err(e) => Err(ErrorData::internal_error(
5053 format!("replay_search error: {e}"),
5054 None,
5055 )),
5056 }
5057 }
5058
5059 #[tool(
5062 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.",
5063 annotations(idempotent_hint = true)
5064 )]
5065 fn sm_reconcile(
5066 &self,
5067 Parameters(ReconcileParams { action }): Parameters<ReconcileParams>,
5068 ) -> Result<Json<StructuredOutput>, ErrorData> {
5069 let action_enum = match action.to_lowercase().as_str() {
5070 "report_only" | "report-only" => semantic_memory::ReconcileAction::ReportOnly,
5071 "rebuild_fts" | "rebuild-fts" => semantic_memory::ReconcileAction::RebuildFts,
5072 "re_embed" | "re-embed" | "reembed" => semantic_memory::ReconcileAction::ReEmbed,
5073 _ => {
5074 return Err(ErrorData::invalid_params(
5075 format!("action must be 'report_only', 'rebuild_fts', or 're_embed', got '{action}'"),
5076 None,
5077 ));
5078 }
5079 };
5080 let store = &self.bridge.store;
5081 let result = tokio::task::block_in_place(|| {
5082 Handle::current().block_on(store.reconcile(action_enum))
5083 });
5084 match result {
5085 Ok(report) => json_to_output(&serde_json::json!({
5086 "ok": report.ok,
5087 "schema_version": report.schema_version,
5088 "fact_count": report.fact_count,
5089 "chunk_count": report.chunk_count,
5090 "message_count": report.message_count,
5091 "facts_missing_embeddings": report.facts_missing_embeddings,
5092 "chunks_missing_embeddings": report.chunks_missing_embeddings,
5093 "issues": report.issues,
5094 "issue_count": report.issues.len(),
5095 "action": action,
5096 })),
5097 Err(e) => Err(ErrorData::internal_error(
5098 format!("reconcile error: {e}"),
5099 None,
5100 )),
5101 }
5102 }
5103
5104 #[tool(
5107 description = "Vacuum the database to reclaim space after deletions. This is a maintenance operation that may take a moment.",
5108 annotations(idempotent_hint = true)
5109 )]
5110 fn sm_vacuum(&self) -> Result<Json<StructuredOutput>, ErrorData> {
5111 let store = &self.bridge.store;
5112 let result = tokio::task::block_in_place(|| Handle::current().block_on(store.vacuum()));
5113 match result {
5114 Ok(()) => json_to_output(&serde_json::json!({
5115 "ok": true,
5116 "receipt": mcp_receipt("sm_vacuum"),
5117 "message": "Database vacuumed successfully",
5118 })),
5119 Err(e) => Err(ErrorData::internal_error(
5120 format!("vacuum error: {e}"),
5121 None,
5122 )),
5123 }
5124 }
5125
5126 #[tool(
5127 description = "Re-embed all facts, chunks, messages, and episodes. Call after changing embedding models. Returns the count of items re-embedded.",
5128 annotations(idempotent_hint = true)
5129 )]
5130 fn sm_reembed_all(&self) -> Result<Json<StructuredOutput>, ErrorData> {
5131 let store = &self.bridge.store;
5132 let result =
5133 tokio::task::block_in_place(|| Handle::current().block_on(store.reembed_all()));
5134 match result {
5135 Ok(count) => json_to_output(&serde_json::json!({
5136 "ok": true,
5137 "receipt": mcp_receipt("sm_reembed_all"),
5138 "reembedded_count": count,
5139 "message": format!("Re-embedded {count} items"),
5140 })),
5141 Err(e) => Err(ErrorData::internal_error(
5142 format!("reembed_all error: {e}"),
5143 None,
5144 )),
5145 }
5146 }
5147
5148 #[tool(
5149 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.",
5150 annotations(read_only_hint = true)
5151 )]
5152 fn sm_embeddings_are_dirty(
5153 &self,
5154 Parameters(_params): Parameters<EmbeddingsAreDirtyParams>,
5155 ) -> Result<Json<StructuredOutput>, ErrorData> {
5156 let store = &self.bridge.store;
5157 let result = tokio::task::block_in_place(|| {
5158 Handle::current().block_on(store.embeddings_are_dirty())
5159 });
5160 match result {
5161 Ok(dirty) => json_to_output(&serde_json::json!({
5162 "ok": true,
5163 "dirty": dirty,
5164 "message": if dirty { "Embeddings are dirty and need re-generation. Call sm_reembed_all." } else { "Embeddings are up to date" },
5165 })),
5166 Err(e) => Err(ErrorData::internal_error(
5167 format!("embeddings_are_dirty error: {e}"),
5168 None,
5169 )),
5170 }
5171 }
5172
5173 #[tool(
5176 description = "Query imported claim projection rows. Filters by scope, text, valid-time, and claim state. Returns claim version rows with full provenance.",
5177 annotations(read_only_hint = true)
5178 )]
5179 fn sm_query_claim_versions(
5180 &self,
5181 Parameters(params): Parameters<ProjectionQueryParams>,
5182 ) -> Result<Json<StructuredOutput>, ErrorData> {
5183 let store = &self.bridge.store;
5184 let query = build_projection_query(params);
5185 let result = tokio::task::block_in_place(|| {
5186 Handle::current().block_on(store.query_claim_versions(query))
5187 });
5188 match result {
5189 Ok(rows) => json_to_output(&serde_json::json!({
5190 "ok": true,
5191 "results": serde_json::to_value(&rows).unwrap_or_else(|_| serde_json::json!([])),
5192 "count": rows.len(),
5193 })),
5194 Err(e) => Err(ErrorData::internal_error(
5195 format!("query_claim_versions error: {e}"),
5196 None,
5197 )),
5198 }
5199 }
5200
5201 #[tool(
5202 description = "Query imported relation projection rows. Filters by scope, text, valid-time, and subject entity. Returns relation version rows with full provenance.",
5203 annotations(read_only_hint = true)
5204 )]
5205 fn sm_query_relation_versions(
5206 &self,
5207 Parameters(params): Parameters<ProjectionQueryParams>,
5208 ) -> Result<Json<StructuredOutput>, ErrorData> {
5209 let store = &self.bridge.store;
5210 let query = build_projection_query(params);
5211 let result = tokio::task::block_in_place(|| {
5212 Handle::current().block_on(store.query_relation_versions(query))
5213 });
5214 match result {
5215 Ok(rows) => json_to_output(&serde_json::json!({
5216 "ok": true,
5217 "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5218 "count": rows.len(),
5219 })),
5220 Err(e) => Err(ErrorData::internal_error(
5221 format!("query_relation_versions error: {e}"),
5222 None,
5223 )),
5224 }
5225 }
5226
5227 #[tool(
5228 description = "Query imported episode projection rows. Filters by scope and text. Returns episode rows with cause/effect and outcome data.",
5229 annotations(read_only_hint = true)
5230 )]
5231 fn sm_query_episodes(
5232 &self,
5233 Parameters(params): Parameters<ProjectionQueryParams>,
5234 ) -> Result<Json<StructuredOutput>, ErrorData> {
5235 let store = &self.bridge.store;
5236 let query = build_projection_query(params);
5237 let result =
5238 tokio::task::block_in_place(|| Handle::current().block_on(store.query_episodes(query)));
5239 match result {
5240 Ok(rows) => json_to_output(&serde_json::json!({
5241 "ok": true,
5242 "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5243 "count": rows.len(),
5244 })),
5245 Err(e) => Err(ErrorData::internal_error(
5246 format!("query_episodes error: {e}"),
5247 None,
5248 )),
5249 }
5250 }
5251
5252 #[tool(
5253 description = "Query imported entity-alias rows. Filters by scope, canonical entity, and text. Returns alias rows with merge and review state.",
5254 annotations(read_only_hint = true)
5255 )]
5256 fn sm_query_entity_aliases(
5257 &self,
5258 Parameters(params): Parameters<ProjectionQueryParams>,
5259 ) -> Result<Json<StructuredOutput>, ErrorData> {
5260 let store = &self.bridge.store;
5261 let query = build_projection_query(params);
5262 let result = tokio::task::block_in_place(|| {
5263 Handle::current().block_on(store.query_entity_aliases(query))
5264 });
5265 match result {
5266 Ok(rows) => json_to_output(&serde_json::json!({
5267 "ok": true,
5268 "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5269 "count": rows.len(),
5270 })),
5271 Err(e) => Err(ErrorData::internal_error(
5272 format!("query_entity_aliases error: {e}"),
5273 None,
5274 )),
5275 }
5276 }
5277
5278 #[tool(
5279 description = "Query imported evidence-reference rows. Filters by scope, claim, and claim version. Returns evidence reference rows with fetch handles and source authority.",
5280 annotations(read_only_hint = true)
5281 )]
5282 fn sm_query_evidence_refs(
5283 &self,
5284 Parameters(params): Parameters<ProjectionQueryParams>,
5285 ) -> Result<Json<StructuredOutput>, ErrorData> {
5286 let store = &self.bridge.store;
5287 let query = build_projection_query(params);
5288 let result = tokio::task::block_in_place(|| {
5289 Handle::current().block_on(store.query_evidence_refs(query))
5290 });
5291 match result {
5292 Ok(rows) => json_to_output(&serde_json::json!({
5293 "ok": true,
5294 "results": serde_json::to_value(&rows).unwrap_or(serde_json::json!([])),
5295 "count": rows.len(),
5296 })),
5297 Err(e) => Err(ErrorData::internal_error(
5298 format!("query_evidence_refs error: {e}"),
5299 None,
5300 )),
5301 }
5302 }
5303
5304 #[tool(
5307 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.",
5308 annotations(idempotent_hint = true)
5309 )]
5310 #[allow(deprecated)]
5311 fn sm_import_envelope(
5312 &self,
5313 Parameters(ImportEnvelopeParams { envelope_json }): Parameters<ImportEnvelopeParams>,
5314 ) -> Result<Json<StructuredOutput>, ErrorData> {
5315 let envelope: semantic_memory::projection_import::ImportEnvelope =
5316 serde_json::from_str(&envelope_json).map_err(|e| {
5317 ErrorData::invalid_params(format!("Failed to parse envelope JSON: {e}"), None)
5318 })?;
5319 envelope.validate().map_err(|e| {
5320 ErrorData::invalid_params(format!("Envelope validation failed: {e}"), None)
5321 })?;
5322 let store = &self.bridge.store;
5323 let result = tokio::task::block_in_place(|| {
5324 Handle::current().block_on(store.import_envelope(&envelope))
5325 });
5326 match result {
5327 Ok(receipt) => json_to_output(&serde_json::json!({
5328 "ok": true,
5329 "envelope_id": receipt.envelope_id,
5330 "was_duplicate": receipt.was_duplicate,
5331 "imported_count": receipt.record_count,
5332 "receipt_id": receipt.envelope_id,
5333 })),
5334 Err(e) => Err(ErrorData::internal_error(
5335 format!("import_envelope error: {e}"),
5336 None,
5337 )),
5338 }
5339 }
5340
5341 #[tool(
5342 description = "Check whether an envelope has already been imported. Returns import receipts for the given envelope ID.",
5343 annotations(read_only_hint = true)
5344 )]
5345 #[allow(deprecated)]
5346 fn sm_import_status(
5347 &self,
5348 Parameters(ImportStatusParams { envelope_id }): Parameters<ImportStatusParams>,
5349 ) -> Result<Json<StructuredOutput>, ErrorData> {
5350 use semantic_memory::projection_import::EnvelopeId;
5351 let store = &self.bridge.store;
5352 let env_id = EnvelopeId::new(&envelope_id);
5353 let result = tokio::task::block_in_place(|| {
5354 Handle::current().block_on(store.import_status(&env_id))
5355 });
5356 match result {
5357 Ok(receipts) => json_to_output(&serde_json::json!({
5358 "ok": true,
5359 "envelope_id": envelope_id,
5360 "receipts": serde_json::to_value(&receipts).unwrap_or(serde_json::json!([])),
5361 "count": receipts.len(),
5362 })),
5363 Err(e) => Err(ErrorData::internal_error(
5364 format!("import_status error: {e}"),
5365 None,
5366 )),
5367 }
5368 }
5369
5370 #[tool(
5371 description = "List recent imports, optionally filtered by namespace. Returns import receipt records.",
5372 annotations(read_only_hint = true)
5373 )]
5374 #[allow(deprecated)]
5375 fn sm_list_imports(
5376 &self,
5377 Parameters(ListImportsParams { namespace, limit }): Parameters<ListImportsParams>,
5378 ) -> Result<Json<StructuredOutput>, ErrorData> {
5379 let store = &self.bridge.store;
5380 let lim = limit.unwrap_or(20) as usize;
5381 let result = tokio::task::block_in_place(|| {
5382 Handle::current().block_on(store.list_imports(namespace.as_deref(), lim))
5383 });
5384 match result {
5385 Ok(receipts) => json_to_output(&serde_json::json!({
5386 "ok": true,
5387 "receipts": serde_json::to_value(&receipts).unwrap_or(serde_json::json!([])),
5388 "count": receipts.len(),
5389 })),
5390 Err(e) => Err(ErrorData::internal_error(
5391 format!("list_imports error: {e}"),
5392 None,
5393 )),
5394 }
5395 }
5396 #[cfg(feature = "llm-parser")]
5399 #[tool(
5400 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.",
5401 annotations(read_only_hint = true)
5402 )]
5403 fn sm_parse_json(
5404 &self,
5405 Parameters(ParseJsonParams { raw_output }): Parameters<ParseJsonParams>,
5406 ) -> Result<Json<StructuredOutput>, ErrorData> {
5407 match llm_output_parser::parse_json::<serde_json::Value>(&raw_output) {
5408 Ok(value) => Ok(structured_output(value)),
5409 Err(e) => Ok(json_to_output(&serde_json::json!({
5410 "ok": false,
5411 "error": e.to_string(),
5412 "input_preview": &raw_output.chars().take(200).collect::<String>(),
5413 }))?),
5414 }
5415 }
5416
5417 #[cfg(feature = "llm-parser")]
5418 #[tool(
5419 description = "Parse JSON from raw LLM output as an untyped serde_json::Value. Useful when the expected schema is unknown.",
5420 annotations(read_only_hint = true)
5421 )]
5422 fn sm_parse_json_value(
5423 &self,
5424 Parameters(ParseJsonValueParams { raw_output }): Parameters<ParseJsonValueParams>,
5425 ) -> Result<Json<StructuredOutput>, ErrorData> {
5426 match llm_output_parser::parse_json_value(&raw_output) {
5427 Ok(value) => Ok(structured_output(value)),
5428 Err(e) => Ok(json_to_output(&serde_json::json!({
5429 "ok": false,
5430 "error": e.to_string(),
5431 }))?),
5432 }
5433 }
5434
5435 #[cfg(feature = "llm-parser")]
5436 #[tool(
5437 description = "Strip </think> blocks from text. Removes chain-of-thought reasoning that some models emit. Returns cleaned text.",
5438 annotations(read_only_hint = true)
5439 )]
5440 fn sm_strip_think_tags(
5441 &self,
5442 Parameters(StripThinkTagsParams { text }): Parameters<StripThinkTagsParams>,
5443 ) -> Result<Json<StructuredOutput>, ErrorData> {
5444 Ok(structured_output(serde_json::json!({
5445 "ok": true,
5446 "text": llm_output_parser::strip_think_tags(&text),
5447 })))
5448 }
5449
5450 #[cfg(feature = "llm-parser")]
5451 #[tool(
5452 description = "Attempt to repair common LLM JSON errors: trailing commas, unquoted keys, single quotes, missing brackets. Returns the repaired JSON string or an error.",
5453 annotations(read_only_hint = true)
5454 )]
5455 fn sm_repair_json(
5456 &self,
5457 Parameters(RepairJsonParams { json_string }): Parameters<RepairJsonParams>,
5458 ) -> Result<Json<StructuredOutput>, ErrorData> {
5459 match llm_output_parser::try_repair_json(&json_string) {
5460 Some(repaired) => Ok(structured_output(serde_json::json!({
5461 "ok": true,
5462 "repaired_json": repaired,
5463 }))),
5464 None => Ok(json_to_output(&serde_json::json!({
5465 "ok": false,
5466 "error": "Could not repair JSON. The input may not be valid JSON even after common fixes.",
5467 }))?),
5468 }
5469 }
5470
5471 #[cfg(feature = "llm-parser")]
5472 #[tool(
5473 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.",
5474 annotations(read_only_hint = true)
5475 )]
5476 fn sm_parse_string_list(
5477 &self,
5478 Parameters(ParseStringListParams { raw_output }): Parameters<ParseStringListParams>,
5479 ) -> Result<Json<StructuredOutput>, ErrorData> {
5480 match llm_output_parser::parse_string_list(&raw_output) {
5481 Ok(list) => Ok(structured_output(serde_json::json!({
5482 "ok": true,
5483 "values": list,
5484 }))),
5485 Err(e) => Ok(json_to_output(&serde_json::json!({
5486 "ok": false,
5487 "error": e.to_string(),
5488 }))?),
5489 }
5490 }
5491
5492 #[cfg(feature = "llm-parser")]
5493 #[tool(
5494 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.",
5495 annotations(read_only_hint = true)
5496 )]
5497 fn sm_parse_choice(
5498 &self,
5499 Parameters(ParseChoiceParams {
5500 raw_output,
5501 options,
5502 }): Parameters<ParseChoiceParams>,
5503 ) -> Result<Json<StructuredOutput>, ErrorData> {
5504 let opt_refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
5505 match llm_output_parser::parse_choice(&raw_output, &opt_refs) {
5506 Ok(choice) => Ok(structured_output(serde_json::json!({
5507 "ok": true,
5508 "choice": choice,
5509 }))),
5510 Err(e) => Ok(json_to_output(&serde_json::json!({
5511 "ok": false,
5512 "error": e.to_string(),
5513 "options": options,
5514 }))?),
5515 }
5516 }
5517
5518 #[cfg(feature = "llm-parser")]
5519 #[tool(
5520 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.",
5521 annotations(read_only_hint = true)
5522 )]
5523 fn sm_parse_number(
5524 &self,
5525 Parameters(ParseNumberParams { raw_output }): Parameters<ParseNumberParams>,
5526 ) -> Result<Json<StructuredOutput>, ErrorData> {
5527 match llm_output_parser::parse_number::<f64>(&raw_output) {
5528 Ok(n) if n.is_finite() => Ok(structured_output(serde_json::json!({
5529 "ok": true,
5530 "number": n,
5531 }))),
5532 Ok(_) => Ok(structured_output(serde_json::json!({
5533 "ok": false,
5534 "error": "Parsed number must be finite",
5535 }))),
5536 Err(e) => Ok(json_to_output(&serde_json::json!({
5537 "ok": false,
5538 "error": e.to_string(),
5539 }))?),
5540 }
5541 }
5542}
5543
5544#[derive(Debug, Clone, PartialEq, Eq)]
5545pub enum GraphPathOutcome {
5546 Found(Vec<String>),
5547 NoPathWithinCompleteSearch,
5548 BudgetExceeded,
5549 InvalidEndpoint(String),
5550}
5551
5552fn typed_graph_path(
5556 graph: &dyn semantic_memory::GraphView,
5557 from: &str,
5558 to: &str,
5559 max_depth: usize,
5560) -> Result<GraphPathOutcome, semantic_memory::MemoryError> {
5561 use semantic_memory::GraphDirection;
5562 let from_edges = graph.neighbors(from, GraphDirection::Both, 1)?;
5563 if from_edges.is_empty() {
5564 return Ok(GraphPathOutcome::InvalidEndpoint(from.to_string()));
5565 }
5566 let to_edges = graph.neighbors(to, GraphDirection::Both, 1)?;
5567 if to_edges.is_empty() {
5568 return Ok(GraphPathOutcome::InvalidEndpoint(to.to_string()));
5569 }
5570 if from == to {
5571 return Ok(GraphPathOutcome::Found(vec![from.to_string()]));
5572 }
5573
5574 let mut visited = HashSet::from([from.to_string()]);
5575 let mut parents = HashMap::<String, String>::new();
5576 let mut queue = VecDeque::from([(from.to_string(), 0usize)]);
5577 let mut hit_depth_budget = false;
5578 while let Some((node, depth)) = queue.pop_front() {
5579 let edges = graph.neighbors(&node, GraphDirection::Both, 1)?;
5580 for edge in edges {
5581 let next = if edge.source == node {
5582 edge.target
5583 } else {
5584 edge.source
5585 };
5586 if visited.contains(&next) {
5587 continue;
5588 }
5589 if depth >= max_depth {
5590 hit_depth_budget = true;
5591 continue;
5592 }
5593 visited.insert(next.clone());
5594 parents.insert(next.clone(), node.clone());
5595 if next == to {
5596 let mut path = vec![to.to_string()];
5597 let mut cursor = to.to_string();
5598 while let Some(parent) = parents.get(&cursor) {
5599 path.push(parent.clone());
5600 if parent == from {
5601 break;
5602 }
5603 cursor = parent.clone();
5604 }
5605 path.reverse();
5606 return Ok(GraphPathOutcome::Found(path));
5607 }
5608 if visited.len() >= 500 {
5609 return Ok(GraphPathOutcome::BudgetExceeded);
5610 }
5611 queue.push_back((next, depth + 1));
5612 }
5613 }
5614 Ok(if hit_depth_budget {
5615 GraphPathOutcome::BudgetExceeded
5616 } else {
5617 GraphPathOutcome::NoPathWithinCompleteSearch
5618 })
5619}
5620
5621#[cfg(test)]
5622#[allow(clippy::items_after_test_module)]
5623mod correctness_contract_tests {
5624 use super::*;
5625 use crate::bridge::{BridgeConfig, EmbedderBackend};
5626
5627 fn structured_value(body: &Json<StructuredOutput>) -> serde_json::Value {
5628 serde_json::to_value(&body.0).expect("structured tool output JSON")
5629 }
5630
5631 #[test]
5632 fn structured_output_preserves_object_shape_and_populates_protocol_field() {
5633 use rmcp::handler::server::tool::IntoCallToolResult;
5634
5635 let output = structured_output(serde_json::json!({
5636 "ok": true,
5637 "fact_id": "fact-1",
5638 }));
5639 let result = output.into_call_tool_result().expect("tool result");
5640
5641 assert_eq!(
5642 result.structured_content,
5643 Some(serde_json::json!({"ok": true, "fact_id": "fact-1"}))
5644 );
5645 assert_eq!(
5646 result.content.len(),
5647 1,
5648 "rmcp emits compatibility text content"
5649 );
5650 assert_eq!(result.is_error, Some(false));
5651 }
5652
5653 #[test]
5654 fn structured_output_wraps_non_object_values() {
5655 assert_eq!(
5656 structured_value(&structured_output(serde_json::json!(["a", "b"]))),
5657 serde_json::json!({"value": ["a", "b"]})
5658 );
5659 }
5660
5661 #[cfg(feature = "llm-parser")]
5662 #[test]
5663 fn parse_number_rejects_non_finite_values_without_panicking() {
5664 let dir = tempfile::tempdir().unwrap();
5665 let server = SemanticMemoryServer::new(
5666 MemoryBridge::open(BridgeConfig {
5667 memory_dir: dir.path().to_path_buf(),
5668 embedder_backend: EmbedderBackend::Mock,
5669 embedding_url: String::new(),
5670 embedding_model: "mock".into(),
5671 embedding_dims: 768,
5672 turbo_quant_enabled: false,
5673 turbo_quant_bits: None,
5674 turbo_quant_projections: None,
5675 })
5676 .unwrap(),
5677 "full",
5678 );
5679
5680 let output = server
5681 .sm_parse_number(Parameters(ParseNumberParams {
5682 raw_output: "NaN".into(),
5683 }))
5684 .expect("non-finite parse must return a structured result");
5685 let value = structured_value(&output);
5686
5687 assert_eq!(value["ok"], false);
5688 assert!(value["error"]
5689 .as_str()
5690 .is_some_and(|error| error.contains("finite")));
5691 }
5692
5693 #[test]
5694 fn every_registered_tool_declares_object_output_schema() {
5695 let tools = SemanticMemoryServer::tool_router().list_all();
5696 assert!(!tools.is_empty(), "tool router must register tools");
5697
5698 let missing: Vec<_> = tools
5699 .iter()
5700 .filter_map(|tool| {
5701 let schema = tool.output_schema.as_deref()?;
5702 (schema.get("type").and_then(serde_json::Value::as_str) != Some("object"))
5703 .then(|| tool.name.to_string())
5704 })
5705 .chain(
5706 tools
5707 .iter()
5708 .filter(|tool| tool.output_schema.is_none())
5709 .map(|tool| tool.name.to_string()),
5710 )
5711 .collect();
5712
5713 assert!(
5714 missing.is_empty(),
5715 "every tool must expose a root object outputSchema; missing/invalid: {missing:?}"
5716 );
5717 }
5718 use semantic_memory::{GraphDirection, GraphEdge, GraphEdgeType, GraphView, MemoryError};
5719
5720 struct TestGraph(Vec<(&'static str, &'static str)>);
5721
5722 fn add_fact_params(content: &str, idempotency_key: Option<&str>) -> AddFactParams {
5723 AddFactParams {
5724 content: content.into(),
5725 namespace: "authority-test".into(),
5726 source: Some("tests/authority-source.md".into()),
5727 extract_entities: Some(false),
5728 memory_kind: Some("durable_fact".into()),
5729 sensitivity: Some("internal".into()),
5730 evidence_refs: Some(vec!["evidence:authority-test".into()]),
5731 idempotency_key: idempotency_key.map(str::to_string),
5732 }
5733 }
5734
5735 fn invoke_add_fact(
5736 runtime: &tokio::runtime::Runtime,
5737 server: &SemanticMemoryServer,
5738 params: AddFactParams,
5739 ) -> Result<Json<StructuredOutput>, ErrorData> {
5740 runtime.block_on(async {
5741 tokio::task::block_in_place(|| server.sm_add_fact(Parameters(params)))
5742 })
5743 }
5744
5745 fn invoke_record_outcome(
5746 runtime: &tokio::runtime::Runtime,
5747 server: &SemanticMemoryServer,
5748 query: &str,
5749 outcome: &str,
5750 ) -> serde_json::Value {
5751 let body = runtime
5752 .block_on(async {
5753 tokio::task::block_in_place(|| {
5754 server.sm_record_outcome(Parameters(RecordOutcomeParams {
5755 query: query.to_string(),
5756 outcome: outcome.to_string(),
5757 }))
5758 })
5759 })
5760 .expect("record routing outcome");
5761 structured_value(&body)
5762 }
5763
5764 #[test]
5765 fn routing_feedback_batch_persists_and_survives_restart() {
5766 use semantic_memory::rl_routing::{is_trained, route_with_policy};
5767 use semantic_memory::routing::{QueryProfile, RetrievalRouter};
5768
5769 let dir = tempfile::tempdir().unwrap();
5770 let memory_dir = dir.path().to_path_buf();
5771 let make_config = || BridgeConfig {
5772 memory_dir: memory_dir.clone(),
5773 embedder_backend: EmbedderBackend::Mock,
5774 embedding_url: String::new(),
5775 embedding_model: "mock".into(),
5776 embedding_dims: 768,
5777 turbo_quant_enabled: false,
5778 turbo_quant_bits: None,
5779 turbo_quant_projections: None,
5780 };
5781 let runtime = tokio::runtime::Runtime::new().unwrap();
5782 let server = SemanticMemoryServer::new(MemoryBridge::open(make_config()).unwrap(), "full");
5783 let query = "compare rust vs python performance";
5784
5785 for index in 0..ROUTING_POLICY_PERSIST_BATCH {
5786 let receipt = invoke_record_outcome(&runtime, &server, query, "bad");
5787 assert_eq!(
5788 receipt["policy_state"]["persisted"],
5789 index + 1 == ROUTING_POLICY_PERSIST_BATCH
5790 );
5791 }
5792
5793 let persisted = runtime
5794 .block_on(server.bridge.store.load_routing_policy())
5795 .unwrap()
5796 .expect("batched routing policy persisted");
5797 assert_eq!(persisted.trained_examples, ROUTING_POLICY_PERSIST_BATCH);
5798 assert!(persisted.last_updated.is_some());
5799 assert!(is_trained(&persisted));
5800
5801 let profile = QueryProfile::from_query(query);
5802 let heuristic = RetrievalRouter::default().route(&profile);
5803 let before_restart = route_with_policy(&persisted, &profile);
5804 assert_ne!(before_restart.rerank_fine, heuristic.rerank_fine);
5805
5806 let policy_json = runtime
5807 .block_on(async { tokio::task::block_in_place(|| server.sm_get_routing_policy()) })
5808 .unwrap();
5809 let policy_json: serde_json::Value = structured_value(&policy_json);
5810 assert_eq!(
5811 policy_json["policy"]["training_examples_count"],
5812 ROUTING_POLICY_PERSIST_BATCH
5813 );
5814 assert!(policy_json["policy"]["last_updated"].is_string());
5815
5816 drop(server);
5817 let restarted =
5818 SemanticMemoryServer::new(MemoryBridge::open(make_config()).unwrap(), "full");
5819 let loaded = runtime
5820 .block_on(restarted.bridge.store.load_routing_policy())
5821 .unwrap()
5822 .expect("routing policy loaded after restart");
5823 let after_restart = route_with_policy(&loaded, &profile);
5824 assert_eq!(after_restart.bm25_coarse, before_restart.bm25_coarse);
5825 assert_eq!(after_restart.vector_medium, before_restart.vector_medium);
5826 assert_eq!(after_restart.rerank_fine, before_restart.rerank_fine);
5827 assert_eq!(after_restart.decoder, before_restart.decoder);
5828 }
5829
5830 fn governed_decision_server(
5831 scopes: semantic_memory::AuthorityScopesV1,
5832 ) -> (SemanticMemoryServer, tokio::runtime::Runtime, String) {
5833 use semantic_memory::{
5834 AuthorityPermit, ElevationRequirementV1, NamespaceScopeV1, OriginAuthorityLabelV1,
5835 OriginClassV1, OriginRiskV1, RevocationStatusV1, SubjectPrincipalV1,
5836 };
5837
5838 let dir = tempfile::tempdir().unwrap();
5839 let bridge = MemoryBridge::open(BridgeConfig {
5840 memory_dir: dir.path().to_path_buf(),
5841 embedder_backend: EmbedderBackend::Mock,
5842 embedding_url: String::new(),
5843 embedding_model: "mock".into(),
5844 embedding_dims: 768,
5845 turbo_quant_enabled: false,
5846 turbo_quant_bits: None,
5847 turbo_quant_projections: None,
5848 })
5849 .unwrap();
5850 let runtime = tokio::runtime::Runtime::new().unwrap();
5851 let origin = OriginAuthorityLabelV1::new(
5852 OriginClassV1::UserStatement,
5853 "principal:writer",
5854 "governed-decision-test",
5855 "blake3:governed-decision-source",
5856 OriginRiskV1::Low,
5857 scopes,
5858 ElevationRequirementV1::ExplicitOperatorApproval,
5859 None,
5860 RevocationStatusV1::Active,
5861 vec!["principal:alice".into(), "team:medical".into()],
5862 )
5863 .unwrap()
5864 .with_subject_principal(SubjectPrincipalV1::new("principal:patient").unwrap())
5865 .with_resource_scope(NamespaceScopeV1::exact("medical"));
5866 let fact_id = runtime
5867 .block_on(
5868 bridge.store.authority().append(
5869 AuthorityPermit::with_evidence(
5870 "principal:writer",
5871 "governed-decision-test",
5872 AuthorityPermit::APPEND_CAPABILITY,
5873 vec!["evidence:test".into()],
5874 )
5875 .with_origin(origin),
5876 "governed-decision".into(),
5877 "medical".into(),
5878 "CONFIDENTIAL_MEMORY_SENTINEL".into(),
5879 None,
5880 ),
5881 )
5882 .unwrap()
5883 .affected_ids[0]
5884 .clone();
5885 (SemanticMemoryServer::new(bridge, "full"), runtime, fact_id)
5886 }
5887
5888 fn decision_params(fact_id: &str) -> GovernedDecisionParams {
5889 GovernedDecisionParams {
5890 fact_id: fact_id.into(),
5891 caller: "principal:alice".into(),
5892 subject: "principal:patient".into(),
5893 audiences: vec!["principal:alice".into(), "team:medical".into()],
5894 scope: GovernedNamespaceScopeParams {
5895 namespace: "medical".into(),
5896 domain: None,
5897 workspace_id: None,
5898 repo_id: None,
5899 },
5900 delegation_or_elevation: None,
5901 }
5902 }
5903
5904 fn invoke_decision(
5905 runtime: &tokio::runtime::Runtime,
5906 server: &SemanticMemoryServer,
5907 purpose: semantic_memory::GovernedAccessPurposeV1,
5908 params: GovernedDecisionParams,
5909 ) -> serde_json::Value {
5910 let body = runtime
5911 .block_on(async {
5912 tokio::task::block_in_place(|| match purpose {
5913 semantic_memory::GovernedAccessPurposeV1::Assertion => {
5914 server.sm_decide_assertion_authority(Parameters(params))
5915 }
5916 semantic_memory::GovernedAccessPurposeV1::Action => {
5917 server.sm_decide_action_authority(Parameters(params))
5918 }
5919 _ => unreachable!(),
5920 })
5921 })
5922 .unwrap();
5923 structured_value(&body)
5924 }
5925
5926 #[test]
5927 fn recall_authority_does_not_imply_assertion_or_action_authority_and_denials_omit_content() {
5928 use semantic_memory::{AuthorityScopeV1, AuthorityScopesV1, GovernedAccessPurposeV1};
5929 let (server, runtime, fact_id) = governed_decision_server(AuthorityScopesV1 {
5930 recall: AuthorityScopeV1::Audience,
5931 assertion: AuthorityScopeV1::Denied,
5932 action: AuthorityScopeV1::Denied,
5933 });
5934
5935 for purpose in [
5936 GovernedAccessPurposeV1::Assertion,
5937 GovernedAccessPurposeV1::Action,
5938 ] {
5939 let receipt = invoke_decision(&runtime, &server, purpose, decision_params(&fact_id));
5940 assert_eq!(receipt["schema_version"], "origin_authority_decision_v1");
5941 assert_eq!(receipt["allowed"], false);
5942 assert_eq!(
5943 receipt["purpose"],
5944 match purpose {
5945 GovernedAccessPurposeV1::Assertion => "assertion",
5946 GovernedAccessPurposeV1::Action => "action",
5947 _ => unreachable!(),
5948 }
5949 );
5950 assert!(receipt["reasons"]
5951 .as_array()
5952 .unwrap()
5953 .iter()
5954 .any(|reason| { reason == "scope_or_principal_denied" }));
5955 let serialized = receipt.to_string();
5956 assert!(!serialized.contains("CONFIDENTIAL_MEMORY_SENTINEL"));
5957 for forbidden in ["fact", "content", "origin", "memory"] {
5958 assert!(receipt.get(forbidden).is_none(), "leaked field {forbidden}");
5959 }
5960 }
5961 }
5962
5963 #[test]
5964 fn assertion_decision_honors_delegation_expiry_audience_and_namespace() {
5965 use semantic_memory::{AuthorityScopeV1, AuthorityScopesV1, GovernedAccessPurposeV1};
5966 let (server, runtime, fact_id) = governed_decision_server(AuthorityScopesV1 {
5967 recall: AuthorityScopeV1::Audience,
5968 assertion: AuthorityScopeV1::Audience,
5969 action: AuthorityScopeV1::Audience,
5970 });
5971 let mut params = decision_params(&fact_id);
5972 params.delegation_or_elevation = Some(GovernedLeaseParams {
5973 lease_id: "lease:assertion".into(),
5974 delegator: "principal:patient".into(),
5975 delegatee: "principal:alice".into(),
5976 purposes: vec![GovernedAccessPurposeParam::Assertion],
5977 scope: params.scope.clone(),
5978 audiences: vec!["team:medical".into()],
5979 expires_at: "2999-01-01T00:00:00Z".into(),
5980 revoked: false,
5981 elevation: false,
5982 });
5983 assert_eq!(
5984 invoke_decision(
5985 &runtime,
5986 &server,
5987 GovernedAccessPurposeV1::Assertion,
5988 params.clone()
5989 )["allowed"],
5990 true
5991 );
5992
5993 let mut expired = params.clone();
5994 expired.delegation_or_elevation.as_mut().unwrap().expires_at =
5995 "2000-01-01T00:00:00Z".into();
5996 let receipt = invoke_decision(
5997 &runtime,
5998 &server,
5999 GovernedAccessPurposeV1::Assertion,
6000 expired,
6001 );
6002 assert_eq!(receipt["allowed"], false);
6003 assert!(receipt["reasons"]
6004 .as_array()
6005 .unwrap()
6006 .contains(&serde_json::json!("delegation_expired")));
6007
6008 let mut wrong_audience = params.clone();
6009 wrong_audience.audiences = vec!["team:other".into()];
6010 let receipt = invoke_decision(
6011 &runtime,
6012 &server,
6013 GovernedAccessPurposeV1::Assertion,
6014 wrong_audience,
6015 );
6016 assert_eq!(receipt["allowed"], false);
6017 assert!(receipt["reasons"]
6018 .as_array()
6019 .unwrap()
6020 .contains(&serde_json::json!("audience_intersection_empty")));
6021
6022 let mut wrong_namespace = params;
6023 wrong_namespace.scope.namespace = "other".into();
6024 let receipt = invoke_decision(
6025 &runtime,
6026 &server,
6027 GovernedAccessPurposeV1::Assertion,
6028 wrong_namespace,
6029 );
6030 assert_eq!(receipt["allowed"], false);
6031 assert!(receipt["reasons"]
6032 .as_array()
6033 .unwrap()
6034 .contains(&serde_json::json!("namespace_scope_mismatch")));
6035 }
6036
6037 #[test]
6038 fn action_decision_honors_live_elevation_without_converting_it_to_admin_access() {
6039 use semantic_memory::{AuthorityScopeV1, AuthorityScopesV1, GovernedAccessPurposeV1};
6040 let (server, runtime, fact_id) = governed_decision_server(AuthorityScopesV1 {
6041 recall: AuthorityScopeV1::Audience,
6042 assertion: AuthorityScopeV1::Denied,
6043 action: AuthorityScopeV1::Audience,
6044 });
6045 let mut params = decision_params(&fact_id);
6046 params.delegation_or_elevation = Some(GovernedLeaseParams {
6047 lease_id: "lease:elevation".into(),
6048 delegator: "principal:patient".into(),
6049 delegatee: "principal:alice".into(),
6050 purposes: vec![GovernedAccessPurposeParam::Admin],
6051 scope: params.scope.clone(),
6052 audiences: vec![],
6053 expires_at: "2999-01-01T00:00:00Z".into(),
6054 revoked: false,
6055 elevation: true,
6056 });
6057 let receipt = invoke_decision(&runtime, &server, GovernedAccessPurposeV1::Action, params);
6058 assert_eq!(receipt["allowed"], true);
6059 assert_eq!(receipt["purpose"], "action");
6060 assert_eq!(receipt["lease_id"], "lease:elevation");
6061 }
6062 impl GraphView for TestGraph {
6063 fn neighbors(
6064 &self,
6065 node: &str,
6066 _: GraphDirection,
6067 _: usize,
6068 ) -> Result<Vec<GraphEdge>, MemoryError> {
6069 Ok(self
6070 .0
6071 .iter()
6072 .filter(|(a, b)| *a == node || *b == node)
6073 .map(|(a, b)| GraphEdge {
6074 source: (*a).into(),
6075 target: (*b).into(),
6076 edge_type: GraphEdgeType::Entity {
6077 relation: "test".into(),
6078 },
6079 weight: 1.0,
6080 metadata: None,
6081 })
6082 .collect())
6083 }
6084 fn path(&self, _: &str, _: &str, _: usize) -> Result<Option<Vec<String>>, MemoryError> {
6085 unreachable!()
6086 }
6087 }
6088
6089 #[test]
6090 fn graph_path_outcomes_do_not_conflate_exhaustion_and_budget() {
6091 let graph = TestGraph(vec![("a", "b"), ("b", "c"), ("x", "y")]);
6092 assert_eq!(
6093 typed_graph_path(&graph, "a", "c", 2).unwrap(),
6094 GraphPathOutcome::Found(vec!["a".into(), "b".into(), "c".into()])
6095 );
6096 assert_eq!(
6097 typed_graph_path(&graph, "a", "c", 1).unwrap(),
6098 GraphPathOutcome::BudgetExceeded
6099 );
6100 assert_eq!(
6101 typed_graph_path(&graph, "a", "x", 5).unwrap(),
6102 GraphPathOutcome::NoPathWithinCompleteSearch
6103 );
6104 assert_eq!(
6105 typed_graph_path(&graph, "missing", "x", 5).unwrap(),
6106 GraphPathOutcome::InvalidEndpoint("missing".into())
6107 );
6108 }
6109
6110 #[test]
6111 fn sm_add_fact_uses_authority_append_and_preserves_output_contract() {
6112 let dir = tempfile::tempdir().unwrap();
6113 let server = SemanticMemoryServer::new(
6114 MemoryBridge::open(BridgeConfig {
6115 memory_dir: dir.path().to_path_buf(),
6116 embedder_backend: EmbedderBackend::Mock,
6117 embedding_url: String::new(),
6118 embedding_model: "mock".into(),
6119 embedding_dims: 768,
6120 turbo_quant_enabled: false,
6121 turbo_quant_bits: None,
6122 turbo_quant_projections: None,
6123 })
6124 .unwrap(),
6125 "full",
6126 );
6127 let runtime = tokio::runtime::Runtime::new().unwrap();
6128 server
6129 .bridge
6130 .store
6131 .authority()
6132 .set_fault(Some(semantic_memory::AuthorityFaultStage::BeforeAppend));
6133
6134 let error = invoke_add_fact(
6135 &runtime,
6136 &server,
6137 add_fact_params("must pass through authority", Some("mcp-authority-fault")),
6138 )
6139 .err()
6140 .expect("expected tool error");
6141 assert!(error.message.contains("authority fault injected"));
6142 assert_eq!(
6143 runtime
6144 .block_on(server.bridge.store.stats())
6145 .unwrap()
6146 .total_facts,
6147 0
6148 );
6149
6150 let body = invoke_add_fact(
6151 &runtime,
6152 &server,
6153 add_fact_params("must pass through authority", Some("mcp-authority-success")),
6154 )
6155 .unwrap();
6156 let json: serde_json::Value = structured_value(&body);
6157 assert_eq!(json["ok"], true);
6158 assert_eq!(json["namespace"], "authority-test");
6159 assert_eq!(json["message"], "Fact added successfully");
6160 assert!(json["fact_id"].as_str().is_some());
6161 assert_eq!(json.as_object().unwrap().len(), 5);
6162 assert!(json["receipt"]["receipt_id"].as_str().is_some());
6163 assert!(json["receipt"]["recorded_at"].as_str().is_some());
6164 assert_eq!(json["receipt"]["tool"], "sm_add_fact");
6165
6166 let fact_id = json["fact_id"].as_str().expect("fact id");
6167 let fact = runtime
6168 .block_on(server.bridge.store.get_fact(fact_id))
6169 .expect("read stored fact")
6170 .expect("stored fact exists");
6171 let metadata = fact.metadata.expect("typed metadata must persist");
6172 assert_eq!(metadata["memory_kind"], "durable_fact");
6173 assert_eq!(metadata["sensitivity"], "internal");
6174 assert_eq!(
6175 metadata["evidence_refs"],
6176 serde_json::json!(["evidence:authority-test"])
6177 );
6178 assert!(metadata["authority"]["operation_id"].as_str().is_some());
6179 }
6180
6181 #[test]
6182 fn sm_add_fact_replays_explicit_key_but_unkeyed_identical_writes_remain_distinct() {
6183 let dir = tempfile::tempdir().unwrap();
6184 let server = SemanticMemoryServer::new(
6185 MemoryBridge::open(BridgeConfig {
6186 memory_dir: dir.path().to_path_buf(),
6187 embedder_backend: EmbedderBackend::Mock,
6188 embedding_url: String::new(),
6189 embedding_model: "mock".into(),
6190 embedding_dims: 768,
6191 turbo_quant_enabled: false,
6192 turbo_quant_bits: None,
6193 turbo_quant_projections: None,
6194 })
6195 .unwrap(),
6196 "full",
6197 );
6198 let runtime = tokio::runtime::Runtime::new().unwrap();
6199
6200 let first = invoke_add_fact(
6201 &runtime,
6202 &server,
6203 add_fact_params("retry-safe", Some("caller-retry-key")),
6204 )
6205 .unwrap();
6206 let retry = invoke_add_fact(
6207 &runtime,
6208 &server,
6209 add_fact_params("retry-safe", Some("caller-retry-key")),
6210 )
6211 .unwrap();
6212 let first_fact = structured_value(&first)["fact_id"]
6215 .as_str()
6216 .unwrap()
6217 .to_string();
6218 let retry_fact = structured_value(&retry)["fact_id"]
6219 .as_str()
6220 .unwrap()
6221 .to_string();
6222 assert_eq!(first_fact, retry_fact);
6223
6224 let unkeyed_a = invoke_add_fact(
6225 &runtime,
6226 &server,
6227 add_fact_params("legitimate duplicate", None),
6228 )
6229 .unwrap();
6230 let unkeyed_b = invoke_add_fact(
6231 &runtime,
6232 &server,
6233 add_fact_params("legitimate duplicate", None),
6234 )
6235 .unwrap();
6236 let fact_id = |body: &Json<StructuredOutput>| {
6237 structured_value(body)["fact_id"]
6238 .as_str()
6239 .unwrap()
6240 .to_string()
6241 };
6242 assert_ne!(fact_id(&unkeyed_a), fact_id(&unkeyed_b));
6243 assert_eq!(
6244 runtime
6245 .block_on(server.bridge.store.stats())
6246 .unwrap()
6247 .total_facts,
6248 3
6249 );
6250 }
6251
6252 #[test]
6253 fn sm_delete_fact_uses_governed_forgetting_closure() {
6254 let dir = tempfile::tempdir().unwrap();
6255 let server = SemanticMemoryServer::new(
6256 MemoryBridge::open(BridgeConfig {
6257 memory_dir: dir.path().to_path_buf(),
6258 embedder_backend: EmbedderBackend::Mock,
6259 embedding_url: String::new(),
6260 embedding_model: "mock".into(),
6261 embedding_dims: 768,
6262 turbo_quant_enabled: false,
6263 turbo_quant_bits: None,
6264 turbo_quant_projections: None,
6265 })
6266 .unwrap(),
6267 "full",
6268 );
6269 let runtime = tokio::runtime::Runtime::new().unwrap();
6270 let added = invoke_add_fact(
6271 &runtime,
6272 &server,
6273 add_fact_params("forget through MCP", Some("mcp-forget-source")),
6274 )
6275 .unwrap();
6276 let fact_id = structured_value(&added)["fact_id"]
6277 .as_str()
6278 .unwrap()
6279 .to_string();
6280 let response = runtime
6281 .block_on(async {
6282 tokio::task::block_in_place(|| {
6283 server.sm_delete_fact(Parameters(DeleteFactParams {
6284 fact_id: fact_id.clone(),
6285 }))
6286 })
6287 })
6288 .unwrap();
6289 let json: serde_json::Value = structured_value(&response);
6290 assert_eq!(json["forgotten"], true);
6291 assert_eq!(json["deleted"], false);
6292 assert_eq!(
6293 json["forgetting_receipt"]["schema_version"],
6294 "forgetting_closure_receipt_v1"
6295 );
6296 let raw = runtime
6297 .block_on(server.bridge.store.get_fact_raw_compat(&fact_id))
6298 .unwrap()
6299 .unwrap();
6300 assert_eq!(raw.content, "[FORGOTTEN]");
6301
6302 let http_origin = semantic_memory::OriginAuthorityLabelV1::operator_system(
6303 "principal:semantic-memory-http",
6304 "caller:http-add",
6305 );
6306 let http_fact = runtime
6307 .block_on(
6308 server.bridge.store.authority().append(
6309 semantic_memory::AuthorityPermit::operator_system(
6310 "principal:semantic-memory-http",
6311 "caller:http-add",
6312 semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
6313 )
6314 .with_origin(http_origin),
6315 "cross-adapter-forget".into(),
6316 "authority-test".into(),
6317 "cross adapter fact".into(),
6318 None,
6319 ),
6320 )
6321 .unwrap()
6322 .affected_ids[0]
6323 .clone();
6324 let response = runtime
6325 .block_on(async {
6326 tokio::task::block_in_place(|| {
6327 server.sm_delete_fact(Parameters(DeleteFactParams {
6328 fact_id: http_fact.clone(),
6329 }))
6330 })
6331 })
6332 .unwrap();
6333 let json: serde_json::Value = structured_value(&response);
6334 assert_eq!(json["forgotten"], true);
6335 assert_eq!(
6336 runtime
6337 .block_on(server.bridge.store.get_fact_raw_compat(&http_fact))
6338 .unwrap()
6339 .unwrap()
6340 .content,
6341 "[FORGOTTEN]"
6342 );
6343 }
6344
6345 #[test]
6346 fn sm_add_fact_preserves_ephemeral_evidence_refs_admission_rule() {
6347 let dir = tempfile::tempdir().unwrap();
6348 let server = SemanticMemoryServer::new(
6349 MemoryBridge::open(BridgeConfig {
6350 memory_dir: dir.path().to_path_buf(),
6351 embedder_backend: EmbedderBackend::Mock,
6352 embedding_url: String::new(),
6353 embedding_model: "mock".into(),
6354 embedding_dims: 768,
6355 turbo_quant_enabled: false,
6356 turbo_quant_bits: None,
6357 turbo_quant_projections: None,
6358 })
6359 .unwrap(),
6360 "full",
6361 );
6362 let runtime = tokio::runtime::Runtime::new().unwrap();
6363 let mut params = add_fact_params("unsupported inference", Some("ephemeral-source-only"));
6364 params.memory_kind = Some("ephemeral_inference".into());
6365 params.evidence_refs = None;
6366
6367 let error = invoke_add_fact(&runtime, &server, params)
6368 .err()
6369 .expect("expected tool error");
6370 assert!(error.message.contains("requires evidence_refs"));
6371 assert_eq!(
6372 runtime
6373 .block_on(server.bridge.store.stats())
6374 .unwrap()
6375 .total_facts,
6376 0
6377 );
6378 }
6379
6380 #[test]
6381 fn sm_search_tool_returns_only_current_supersession_head() {
6382 let dir = tempfile::tempdir().unwrap();
6383 let bridge = MemoryBridge::open(BridgeConfig {
6384 memory_dir: dir.path().to_path_buf(),
6385 embedder_backend: EmbedderBackend::Mock,
6386 embedding_url: String::new(),
6387 embedding_model: "mock".into(),
6388 embedding_dims: 768,
6389 turbo_quant_enabled: false,
6390 turbo_quant_bits: None,
6391 turbo_quant_projections: None,
6392 })
6393 .unwrap();
6394 let runtime = tokio::runtime::Runtime::new().unwrap();
6395 let old = runtime
6396 .block_on(
6397 bridge
6398 .store
6399 .add_fact("state", "runtime channel is violet", None, None),
6400 )
6401 .unwrap();
6402 let new = runtime
6403 .block_on(
6404 bridge
6405 .store
6406 .add_fact("state", "runtime channel is saffron", None, None),
6407 )
6408 .unwrap();
6409 runtime
6410 .block_on(bridge.store.add_graph_edge(
6411 &format!("fact:{new}"),
6412 &format!("fact:{old}"),
6413 GraphEdgeType::Entity {
6414 relation: "supersedes".into(),
6415 },
6416 1.0,
6417 None,
6418 ))
6419 .unwrap();
6420 let server = SemanticMemoryServer::new(bridge, "full");
6421 let body = runtime
6422 .block_on(async {
6423 tokio::task::block_in_place(|| {
6424 server.sm_search(Parameters(SearchParams {
6425 query: "runtime channel".into(),
6426 top_k: Some(10),
6427 namespaces: Some(vec!["state".into()]),
6428 }))
6429 })
6430 })
6431 .unwrap();
6432 let body = structured_value(&body).to_string();
6433 assert!(body.contains("saffron"));
6434 assert!(!body.contains("violet"));
6435 }
6436
6437 #[test]
6438 fn witnessed_search_hydrates_complete_honest_fact_provenance() {
6439 let dir = tempfile::tempdir().unwrap();
6440 let bridge = MemoryBridge::open(BridgeConfig {
6441 memory_dir: dir.path().to_path_buf(),
6442 embedder_backend: EmbedderBackend::Mock,
6443 embedding_url: String::new(),
6444 embedding_model: "mock".into(),
6445 embedding_dims: 768,
6446 turbo_quant_enabled: false,
6447 turbo_quant_bits: None,
6448 turbo_quant_projections: None,
6449 })
6450 .unwrap();
6451 let runtime = tokio::runtime::Runtime::new().unwrap();
6452 let fact_id = runtime
6453 .block_on(bridge.store.add_fact(
6454 "provenance-test",
6455 "witnessed facts must retain their source",
6456 Some("tests/witnessed-source.md"),
6457 None,
6458 ))
6459 .unwrap();
6460 let server = SemanticMemoryServer::new(bridge, "full");
6461 let body = runtime
6462 .block_on(async {
6463 tokio::task::block_in_place(|| {
6464 server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6465 query: "witnessed facts retain source".into(),
6466 top_k: Some(5),
6467 namespaces: Some(vec!["provenance-test".into()]),
6468 request_id: Some("witnessed-provenance-test".into()),
6469 retrieval_mode: None,
6470 replay_mode: None,
6471 }))
6472 })
6473 })
6474 .unwrap();
6475 let json: serde_json::Value = structured_value(&body);
6476 let hit = json["results"]
6477 .as_array()
6478 .unwrap()
6479 .iter()
6480 .find(|hit| hit["memory_id"] == format!("fact:{fact_id}"))
6481 .expect("the stored fact is an injectible witnessed result");
6482
6483 assert_eq!(hit["namespace"], "provenance-test");
6484 assert_eq!(hit["source"], "tests/witnessed-source.md");
6485 assert_eq!(hit["trust"], "persisted_unjudged");
6486 assert_eq!(hit["state"], "current");
6487 assert_eq!(
6488 hit["retrieval_receipt_ref"],
6489 format!("receipt:{}", json["receipt_id"].as_str().unwrap())
6490 );
6491 }
6492
6493 #[test]
6494 fn witnessed_search_omits_noninjectible_fact_without_source() {
6495 let dir = tempfile::tempdir().unwrap();
6496 let bridge = MemoryBridge::open(BridgeConfig {
6497 memory_dir: dir.path().to_path_buf(),
6498 embedder_backend: EmbedderBackend::Mock,
6499 embedding_url: String::new(),
6500 embedding_model: "mock".into(),
6501 embedding_dims: 768,
6502 turbo_quant_enabled: false,
6503 turbo_quant_bits: None,
6504 turbo_quant_projections: None,
6505 })
6506 .unwrap();
6507 let runtime = tokio::runtime::Runtime::new().unwrap();
6508 let _fact_id = runtime
6509 .block_on(bridge.store.add_fact(
6510 "provenance-test",
6511 "source-less facts cannot be injected autonomously",
6512 None,
6513 None,
6514 ))
6515 .unwrap();
6516 let server = SemanticMemoryServer::new(bridge, "full");
6517 let body = runtime
6518 .block_on(async {
6519 tokio::task::block_in_place(|| {
6520 server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6521 query: "source-less facts autonomous injection".into(),
6522 top_k: Some(5),
6523 namespaces: Some(vec!["provenance-test".into()]),
6524 request_id: Some("witnessed-source-less-test".into()),
6525 retrieval_mode: None,
6526 replay_mode: None,
6527 }))
6528 })
6529 })
6530 .unwrap();
6531 let json: serde_json::Value = structured_value(&body);
6532 assert!(json["results"].as_array().unwrap().is_empty());
6533 }
6534
6535 #[test]
6536 fn sm_search_witnessed_exposes_authority_state_and_vector_evidence() {
6537 let dir = tempfile::tempdir().unwrap();
6538 let bridge = MemoryBridge::open(BridgeConfig {
6539 memory_dir: dir.path().to_path_buf(),
6540 embedder_backend: EmbedderBackend::Mock,
6541 embedding_url: String::new(),
6542 embedding_model: "mock".into(),
6543 embedding_dims: 768,
6544 turbo_quant_enabled: false,
6545 turbo_quant_bits: None,
6546 turbo_quant_projections: None,
6547 })
6548 .unwrap();
6549 let runtime = tokio::runtime::Runtime::new().unwrap();
6550 let permit = semantic_memory::AuthorityPermit::operator_system(
6551 "witness-principal",
6552 "witness-caller",
6553 semantic_memory::AuthorityPermit::APPEND_CAPABILITY,
6554 );
6555 let receipt = runtime
6556 .block_on(bridge.store.authority().append(
6557 permit,
6558 "witnessed-state-1".into(),
6559 "stateful".into(),
6560 "governed witnessed state should expose".into(),
6561 Some("tests/witnessed-state.md".into()),
6562 ))
6563 .unwrap();
6564
6565 let server = SemanticMemoryServer::new(bridge, "lean");
6566 let body = runtime
6567 .block_on(async {
6568 tokio::task::block_in_place(|| {
6569 server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6570 query: "governed witnessed state should expose".into(),
6571 top_k: Some(5),
6572 namespaces: Some(vec!["stateful".into()]),
6573 request_id: Some("witnessed-state-request".into()),
6574 retrieval_mode: None,
6575 replay_mode: None,
6576 }))
6577 })
6578 })
6579 .unwrap();
6580 let json: serde_json::Value = structured_value(&body);
6581
6582 assert_eq!(json["retrieval_mode"], "hybrid");
6583 assert!(json["authority"]["snapshot_id"].as_str().is_some());
6584 assert!(
6585 json["authority"]["retrieval_epoch"].as_u64().is_some()
6586 || json["authority"]["retrieval_epoch"].as_str().is_some(),
6587 "authoritative retrieval epoch should be surfaced"
6588 );
6589 assert!(json["current_snapshot_id"].as_str().is_some());
6590 assert!(
6591 json["retrieval_epoch"].as_u64().is_some()
6592 || json["retrieval_epoch"].as_str().is_some(),
6593 "top-level retrieval epoch should be surfaced"
6594 );
6595
6596 assert_eq!(json["authority"]["status"], "Applied");
6597 assert_eq!(
6598 json["stage_outcomes"]["authority_snapshot"]["outcome"],
6599 "Applied"
6600 );
6601
6602 let hit = json["results"]
6603 .as_array()
6604 .unwrap()
6605 .iter()
6606 .find(|hit| {
6607 hit.get("content")
6608 .and_then(|value| value.as_str())
6609 .is_some_and(|content| content.contains("governed witnessed state"))
6610 })
6611 .expect("governed witnessed result should be returned");
6612
6613 assert!(hit["cosine_similarity"].as_f64().is_some());
6614 assert_eq!(
6615 hit["source"],
6616 serde_json::json!("tests/witnessed-state.md"),
6617 "witnessed path should retain source"
6618 );
6619 assert_eq!(
6620 json["ordered_results"][0]["result_id"],
6621 format!("fact:{}", receipt.affected_ids[0])
6622 );
6623 }
6624
6625 #[test]
6626 fn sm_search_witnessed_accepts_general_retrieval_modes() {
6627 let dir = tempfile::tempdir().unwrap();
6628 let bridge = MemoryBridge::open(BridgeConfig {
6629 memory_dir: dir.path().to_path_buf(),
6630 embedder_backend: EmbedderBackend::Mock,
6631 embedding_url: String::new(),
6632 embedding_model: "mock".into(),
6633 embedding_dims: 768,
6634 turbo_quant_enabled: false,
6635 turbo_quant_bits: None,
6636 turbo_quant_projections: None,
6637 })
6638 .unwrap();
6639 let rt = tokio::runtime::Runtime::new().unwrap();
6640 rt.block_on(
6641 bridge
6642 .store
6643 .add_fact("modes", "alpha beta gamma", Some("test"), None),
6644 )
6645 .unwrap();
6646 let server = SemanticMemoryServer::new(bridge, "full");
6647 for retrieval_mode in [
6648 RetrievalModeParam::Hybrid,
6649 RetrievalModeParam::FtsOnly,
6650 RetrievalModeParam::VectorOnly,
6651 ] {
6652 let body = rt
6653 .block_on(async {
6654 server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6655 query: "alpha beta".into(),
6656 top_k: Some(5),
6657 namespaces: Some(vec!["modes".into()]),
6658 request_id: None,
6659 retrieval_mode: Some(retrieval_mode),
6660 replay_mode: None,
6661 }))
6662 })
6663 .unwrap();
6664 let json: serde_json::Value = structured_value(&body);
6665 assert_eq!(json["ok"], true);
6666 assert!(json["receipt_id"].is_string());
6667 assert_eq!(
6668 json["retrieval_mode"],
6669 serde_json::to_value(retrieval_mode).unwrap()
6670 );
6671 }
6672 }
6673
6674 #[test]
6675 fn witnessed_search_opt_in_enables_complete_replay() {
6676 let dir = tempfile::tempdir().unwrap();
6677 let bridge = MemoryBridge::open(BridgeConfig {
6678 memory_dir: dir.path().to_path_buf(),
6679 embedder_backend: EmbedderBackend::Mock,
6680 embedding_url: String::new(),
6681 embedding_model: "mock".into(),
6682 embedding_dims: 768,
6683 turbo_quant_enabled: false,
6684 turbo_quant_bits: None,
6685 turbo_quant_projections: None,
6686 })
6687 .unwrap();
6688 let runtime = tokio::runtime::Runtime::new().unwrap();
6689 runtime
6690 .block_on(bridge.store.add_fact(
6691 "stored-replay",
6692 "complete replay uses explicitly retained inputs",
6693 Some("tests/stored-replay.md"),
6694 None,
6695 ))
6696 .unwrap();
6697 let server = SemanticMemoryServer::new(bridge, "full");
6698 let body = runtime
6699 .block_on(async {
6700 server.sm_search_witnessed(Parameters(SearchWitnessedParams {
6701 query: "complete replay explicitly retained inputs".into(),
6702 top_k: Some(1),
6703 namespaces: Some(vec!["stored-replay".into()]),
6704 request_id: Some("mcp-stored-replay".into()),
6705 retrieval_mode: None,
6706 replay_mode: Some(ReplayModeParam::StoreInputs),
6707 }))
6708 })
6709 .unwrap();
6710 let witnessed: serde_json::Value = structured_value(&body);
6711 assert_eq!(witnessed["complete_replay_available"], true);
6712 assert_eq!(witnessed["stage_outcomes"]["replay"]["outcome"], "Applied");
6713
6714 let replay = runtime
6715 .block_on(async {
6716 server.sm_replay_search(Parameters(ReplayStoredSearchParams {
6717 receipt_id: "mcp-stored-replay".into(),
6718 }))
6719 })
6720 .unwrap();
6721 let replay: serde_json::Value = structured_value(&replay);
6722 assert_eq!(replay["result_ids_match"], true);
6723 assert_eq!(replay["query_embedding_digest_matches"], true);
6724 }
6725
6726 #[cfg(feature = "claim-integration")]
6727 fn claim_test_entries() -> Vec<claim_ledger::LedgerEntry> {
6728 let first = claim_ledger::LedgerEntryBuilder::new(1, None)
6729 .add_claim(
6730 "claim-1",
6731 "semantic-memory:fact:fact-1",
6732 "full",
6733 "verified claim",
6734 )
6735 .unwrap();
6736 let second = claim_ledger::LedgerEntryBuilder::new(2, Some(first.entry_digest.clone()))
6737 .add_support_judgment(
6738 "judgment-1",
6739 "claim-1",
6740 "bundle-1",
6741 claim_ledger::SupportState::Supported,
6742 "test",
6743 )
6744 .unwrap();
6745 vec![first, second]
6746 }
6747
6748 #[cfg(feature = "claim-integration")]
6749 fn write_claim_jsonl(path: &std::path::Path, entries: &[claim_ledger::LedgerEntry]) {
6750 let mut contents = String::new();
6751 for entry in entries {
6752 contents.push_str(&claim_ledger::serialize_entry(entry).unwrap());
6753 contents.push('\n');
6754 }
6755 std::fs::write(path, contents).unwrap();
6756 }
6757
6758 #[cfg(feature = "claim-integration")]
6759 #[test]
6760 fn claim_ledger_startup_rebuilds_from_verified_snapshot_and_tail() {
6761 let dir = tempfile::tempdir().unwrap();
6762 let legacy = dir.path().join("claim_ledger.jsonl");
6763 let entries = claim_test_entries();
6764 write_claim_jsonl(&legacy, &entries);
6765 let mut store = ClaimLedgerStore::open(legacy.clone());
6766 let result = store
6767 .compact(ClaimLedgerCompactionConfig {
6768 dry_run: false,
6769 max_entries: 0,
6770 max_bytes: 0,
6771 retain_tail_entries: 1,
6772 max_backups: 2,
6773 })
6774 .unwrap();
6775 assert_eq!(result["compacted"], true);
6776
6777 let reopened = ClaimLedgerStore::open(legacy);
6778 assert!(reopened.trust_enabled);
6779 assert!(reopened.snapshot.is_some());
6780 assert_eq!(reopened.entries.len(), 1);
6781 let mut index = ClaimTrustIndex::default();
6782 index.load_snapshot(reopened.snapshot.as_ref().unwrap());
6783 index.rebuild_from_ledger_incremental(&reopened.entries);
6784 assert_eq!(index.trust_for_fact("fact-1"), "supported");
6785 assert_eq!(index.last_processed_sequence, 2);
6786 }
6787
6788 #[cfg(feature = "claim-integration")]
6789 #[test]
6790 fn interrupted_compaction_files_leave_old_ledger_usable() {
6791 let dir = tempfile::tempdir().unwrap();
6792 let legacy = dir.path().join("claim_ledger.jsonl");
6793 let entries = claim_test_entries();
6794 write_claim_jsonl(&legacy, &entries);
6795 let interrupted = dir.path().join("claim_ledger_generations/.tmp-interrupted");
6796 std::fs::create_dir_all(&interrupted).unwrap();
6797 std::fs::write(interrupted.join("snapshot.json"), b"incomplete").unwrap();
6798
6799 let reopened = ClaimLedgerStore::open(legacy);
6800 assert!(reopened.trust_enabled);
6801 assert!(reopened.snapshot.is_none());
6802 assert_eq!(reopened.entries.len(), entries.len());
6803 }
6804
6805 #[cfg(feature = "claim-integration")]
6806 #[test]
6807 fn tampered_active_snapshot_disables_claim_trust() {
6808 let dir = tempfile::tempdir().unwrap();
6809 let legacy = dir.path().join("claim_ledger.jsonl");
6810 write_claim_jsonl(&legacy, &claim_test_entries());
6811 let mut store = ClaimLedgerStore::open(legacy.clone());
6812 store
6813 .compact(ClaimLedgerCompactionConfig {
6814 dry_run: false,
6815 max_entries: 0,
6816 max_bytes: 0,
6817 retain_tail_entries: 1,
6818 max_backups: 2,
6819 })
6820 .unwrap();
6821 let snapshot_path = store.path.parent().unwrap().join("snapshot.json");
6822 let mut snapshot: serde_json::Value =
6823 serde_json::from_slice(&std::fs::read(&snapshot_path).unwrap()).unwrap();
6824 snapshot["claims"][0]["normalized_claim"] = serde_json::json!("tampered");
6825 std::fs::write(&snapshot_path, serde_json::to_vec(&snapshot).unwrap()).unwrap();
6826
6827 let reopened = ClaimLedgerStore::open(legacy);
6828 assert!(!reopened.trust_enabled);
6829 assert!(reopened.entries.is_empty());
6830 }
6831
6832 #[cfg(feature = "claim-integration")]
6833 #[test]
6834 fn compact_claim_ledger_defaults_to_dry_run_without_writes() {
6835 let dir = tempfile::tempdir().unwrap();
6836 let bridge = MemoryBridge::open(BridgeConfig {
6837 memory_dir: dir.path().to_path_buf(),
6838 embedder_backend: EmbedderBackend::Mock,
6839 embedding_url: String::new(),
6840 embedding_model: "mock".into(),
6841 embedding_dims: 768,
6842 turbo_quant_enabled: false,
6843 turbo_quant_bits: None,
6844 turbo_quant_projections: None,
6845 })
6846 .unwrap();
6847 let server = SemanticMemoryServer::new(bridge, "full");
6848 assert!(server.exposes_tool("sm_compact_claim_ledger"));
6849 {
6850 let mut store = server.claim_ledger_store.lock().unwrap();
6851 for entry in claim_test_entries() {
6852 store.append(entry).unwrap();
6853 }
6854 }
6855 let before = std::fs::read(dir.path().join("claim_ledger.jsonl")).unwrap();
6856 let response = server
6857 .sm_compact_claim_ledger(Parameters(CompactClaimLedgerParams {
6858 dry_run: None,
6859 max_entries: Some(0),
6860 max_bytes: Some(0),
6861 retain_tail_entries: Some(1),
6862 max_backups: Some(2),
6863 }))
6864 .unwrap();
6865 let response: serde_json::Value = structured_value(&response);
6866 assert_eq!(response["dry_run"], true);
6867 assert_eq!(response["compacted"], false);
6868 assert_eq!(
6869 before,
6870 std::fs::read(dir.path().join("claim_ledger.jsonl")).unwrap()
6871 );
6872 assert!(!dir
6873 .path()
6874 .join("claim_ledger.active_compaction.json")
6875 .exists());
6876 assert!(!dir.path().join("claim_ledger_generations").exists());
6877 }
6878}
6879
6880fn build_path_segments(
6883 store: &semantic_memory::MemoryStore,
6884 path: &[String],
6885) -> Vec<serde_json::Value> {
6886 let mut segments = Vec::new();
6887 if path.len() < 2 {
6888 return segments;
6889 }
6890
6891 for i in 0..path.len() - 1 {
6892 let from = &path[i];
6893 let to = &path[i + 1];
6894
6895 let g = store.graph_view();
6897 match g.neighbors(from, semantic_memory::GraphDirection::Both, 1) {
6898 Ok(edges) => {
6899 let connecting = edges.iter().find(|e| {
6901 (e.source == *from && e.target == *to) || (e.source == *to && e.target == *from)
6902 });
6903
6904 if let Some(edge) = connecting {
6905 let edge_type_str = match &edge.edge_type {
6906 semantic_memory::GraphEdgeType::Semantic { cosine_similarity } => {
6907 serde_json::json!({
6908 "type": "semantic",
6909 "cosine_similarity": cosine_similarity,
6910 })
6911 }
6912 semantic_memory::GraphEdgeType::Temporal { delta_secs } => {
6913 serde_json::json!({
6914 "type": "temporal",
6915 "delta_secs": delta_secs,
6916 })
6917 }
6918 semantic_memory::GraphEdgeType::Causal {
6919 confidence,
6920 evidence_ids,
6921 } => {
6922 serde_json::json!({
6923 "type": "causal",
6924 "confidence": confidence,
6925 "evidence_ids": evidence_ids,
6926 })
6927 }
6928 semantic_memory::GraphEdgeType::Entity { relation } => {
6929 serde_json::json!({
6930 "type": "entity",
6931 "relation": relation,
6932 })
6933 }
6934 };
6935
6936 segments.push(serde_json::json!({
6937 "source": from,
6938 "target": to,
6939 "edge_type": edge_type_str,
6940 "weight": edge.weight,
6941 "metadata": edge.metadata,
6942 }));
6943 } else {
6944 segments.push(serde_json::json!({
6947 "source": from,
6948 "target": to,
6949 "edge_type": null,
6950 "weight": null,
6951 "metadata": null,
6952 }));
6953 }
6954 }
6955 Err(_) => {
6956 segments.push(serde_json::json!({
6957 "source": from,
6958 "target": to,
6959 "edge_type": null,
6960 "weight": null,
6961 "metadata": null,
6962 }));
6963 }
6964 }
6965 }
6966
6967 segments
6968}
6969
6970#[tool_handler(
6971 router = self.tool_router,
6972 name = "semantic-memory-mcp",
6973 version = "0.5.4",
6974 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."
6975)]
6976impl ServerHandler for SemanticMemoryServer {}