1use super::help_content;
73use crate::Namespace;
74use crate::models::SearchMode;
75use crate::services::{PromptService, RecallService, TopicIndexService};
76use crate::storage::index::DomainScope;
77use crate::{Error, Result, SearchFilter};
78use serde::{Deserialize, Serialize};
79use std::collections::HashMap;
80
81pub struct ResourceHandler {
83 help_content: HashMap<String, HelpCategory>,
85 recall_service: Option<RecallService>,
87 topic_index: Option<TopicIndexService>,
89 prompt_service: Option<PromptService>,
91}
92
93impl ResourceHandler {
94 #[must_use]
96 pub fn new() -> Self {
97 let mut help_content = HashMap::new();
98
99 help_content.insert(
101 "setup".to_string(),
102 HelpCategory {
103 name: "setup".to_string(),
104 title: "Getting Started with Subcog".to_string(),
105 description: "Installation and initial configuration guide".to_string(),
106 content: help_content::SETUP.to_string(),
107 },
108 );
109
110 help_content.insert(
112 "concepts".to_string(),
113 HelpCategory {
114 name: "concepts".to_string(),
115 title: "Core Concepts".to_string(),
116 description: "Understanding namespaces, domains, URNs, and memory lifecycle"
117 .to_string(),
118 content: help_content::CONCEPTS.to_string(),
119 },
120 );
121
122 help_content.insert(
124 "capture".to_string(),
125 HelpCategory {
126 name: "capture".to_string(),
127 title: "Capturing Memories".to_string(),
128 description: "How to capture and store memories effectively".to_string(),
129 content: help_content::CAPTURE.to_string(),
130 },
131 );
132
133 help_content.insert(
135 "search".to_string(),
136 HelpCategory {
137 name: "search".to_string(),
138 title: "Searching Memories".to_string(),
139 description: "Using hybrid search to find relevant memories".to_string(),
140 content: help_content::SEARCH.to_string(),
141 },
142 );
143
144 help_content.insert(
146 "workflows".to_string(),
147 HelpCategory {
148 name: "workflows".to_string(),
149 title: "Integration Workflows".to_string(),
150 description: "Hooks, MCP server, and IDE integration".to_string(),
151 content: help_content::WORKFLOWS.to_string(),
152 },
153 );
154
155 help_content.insert(
157 "troubleshooting".to_string(),
158 HelpCategory {
159 name: "troubleshooting".to_string(),
160 title: "Troubleshooting".to_string(),
161 description: "Common issues and solutions".to_string(),
162 content: help_content::TROUBLESHOOTING.to_string(),
163 },
164 );
165
166 help_content.insert(
168 "advanced".to_string(),
169 HelpCategory {
170 name: "advanced".to_string(),
171 title: "Advanced Features".to_string(),
172 description: "LLM integration, consolidation, and optimization".to_string(),
173 content: help_content::ADVANCED.to_string(),
174 },
175 );
176
177 help_content.insert(
179 "prompts".to_string(),
180 HelpCategory {
181 name: "prompts".to_string(),
182 title: "User-Defined Prompts".to_string(),
183 description: "Save, manage, and run prompt templates with variables".to_string(),
184 content: help_content::PROMPTS.to_string(),
185 },
186 );
187
188 Self {
189 help_content,
190 recall_service: None,
191 topic_index: None,
192 prompt_service: None,
193 }
194 }
195
196 #[must_use]
198 pub fn with_prompt_service(mut self, prompt_service: PromptService) -> Self {
199 self.prompt_service = Some(prompt_service);
200 self
201 }
202
203 #[must_use]
205 pub fn with_recall_service(mut self, recall_service: RecallService) -> Self {
206 self.recall_service = Some(recall_service);
207 self
208 }
209
210 #[must_use]
212 pub fn with_topic_index(mut self, topic_index: TopicIndexService) -> Self {
213 self.topic_index = Some(topic_index);
214 self
215 }
216
217 pub fn refresh_topic_index(&mut self) -> Result<()> {
223 let recall = self.recall_service.as_ref().ok_or_else(|| {
224 Error::InvalidInput("Topic indexing requires RecallService".to_string())
225 })?;
226
227 let topic_index = self.topic_index.get_or_insert_with(TopicIndexService::new);
228 topic_index.build_index(recall)
229 }
230
231 #[must_use]
239 pub fn list_resources(&self) -> Vec<ResourceDefinition> {
240 let mut resources = Vec::new();
241 resources.extend(self.help_resource_definitions());
242 resources.extend(self.memory_resource_definitions());
243 resources.extend(Self::domain_resource_definitions());
244 resources.extend(Self::search_topic_resource_definitions());
245 resources.extend(Self::summary_resource_definitions());
246 resources.extend(Self::prompt_resource_definitions());
247 resources
248 }
249
250 fn build_resource(
251 uri: &str,
252 name: &str,
253 description: &str,
254 mime_type: &str,
255 ) -> ResourceDefinition {
256 ResourceDefinition {
257 uri: uri.to_string(),
258 name: name.to_string(),
259 description: Some(description.to_string()),
260 mime_type: Some(mime_type.to_string()),
261 }
262 }
263
264 fn help_resource_definitions(&self) -> Vec<ResourceDefinition> {
265 let mut resources = vec![Self::build_resource(
266 "subcog://help",
267 "Help Index",
268 "Help index with all available topics",
269 "text/markdown",
270 )];
271
272 resources.extend(self.help_content.values().map(|cat| ResourceDefinition {
273 uri: format!("subcog://help/{}", cat.name),
274 name: cat.title.clone(),
275 description: Some(cat.description.clone()),
276 mime_type: Some("text/markdown".to_string()),
277 }));
278
279 resources
280 }
281
282 fn memory_resource_definitions(&self) -> Vec<ResourceDefinition> {
283 let mut resources = vec![Self::build_resource(
284 "subcog://_",
285 "All Memories",
286 "All memories across all domains",
287 "application/json",
288 )];
289
290 for ns in Namespace::user_namespaces() {
291 let ns_str = ns.as_str();
292 resources.push(Self::build_resource(
293 &format!("subcog://_/{ns_str}"),
294 &format!("{ns_str} memories"),
295 &format!("All memories in {ns_str} namespace"),
296 "application/json",
297 ));
298 }
299
300 resources.push(Self::build_resource(
301 "subcog://memory/{id}",
302 "Memory by ID",
303 "Fetch a specific memory by ID",
304 "application/json",
305 ));
306
307 resources
308 }
309
310 fn domain_resource_definitions() -> Vec<ResourceDefinition> {
311 let items = [
312 (
313 "subcog://project",
314 "Project Memories",
315 "Project-scoped memories",
316 ),
317 (
318 "subcog://project/_",
319 "Project Memories (All Namespaces)",
320 "Project memories, all namespaces",
321 ),
322 (
323 "subcog://project/{namespace}",
324 "Project Namespace",
325 "Project memories by namespace",
326 ),
327 (
328 "subcog://project/{namespace}/{id}",
329 "Project Memory",
330 "Fetch a project memory by ID",
331 ),
332 ("subcog://user", "User Memories", "User-scoped memories"),
333 (
334 "subcog://user/_",
335 "User Memories (All Namespaces)",
336 "User memories, all namespaces",
337 ),
338 (
339 "subcog://user/{namespace}",
340 "User Namespace",
341 "User memories by namespace",
342 ),
343 (
344 "subcog://user/{namespace}/{id}",
345 "User Memory",
346 "Fetch a user memory by ID",
347 ),
348 (
349 "subcog://org",
350 "Org Memories",
351 "Organization-scoped memories",
352 ),
353 (
354 "subcog://org/_",
355 "Org Memories (All Namespaces)",
356 "Org memories, all namespaces",
357 ),
358 (
359 "subcog://org/{namespace}",
360 "Org Namespace",
361 "Org memories by namespace",
362 ),
363 (
364 "subcog://org/{namespace}/{id}",
365 "Org Memory",
366 "Fetch an org memory by ID",
367 ),
368 ];
369
370 items
371 .iter()
372 .map(|(uri, name, desc)| Self::build_resource(uri, name, desc, "application/json"))
373 .collect()
374 }
375
376 fn search_topic_resource_definitions() -> Vec<ResourceDefinition> {
377 let mut resources = vec![
378 Self::build_resource(
379 "subcog://search/{query}",
380 "Search Memories",
381 "Search memories with a query (replace {query})",
382 "application/json",
383 ),
384 Self::build_resource(
385 "subcog://topics",
386 "All Topics",
387 "List all indexed topics with memory counts",
388 "application/json",
389 ),
390 Self::build_resource(
391 "subcog://topics/{topic}",
392 "Topic Memories",
393 "Get memories for a specific topic (replace {topic})",
394 "application/json",
395 ),
396 ];
397
398 resources.push(Self::build_resource(
399 "subcog://namespaces",
400 "All Namespaces",
401 "List all memory namespaces with descriptions and signal words",
402 "application/json",
403 ));
404
405 resources
406 }
407
408 fn summary_resource_definitions() -> Vec<ResourceDefinition> {
409 vec![
410 Self::build_resource(
411 "subcog://summaries",
412 "All Summaries",
413 "List all consolidated memory summaries",
414 "application/json",
415 ),
416 Self::build_resource(
417 "subcog://summaries/{id}",
418 "Summary by ID",
419 "Get a specific summary node with its source memories (replace {id})",
420 "application/json",
421 ),
422 ]
423 }
424
425 fn prompt_resource_definitions() -> Vec<ResourceDefinition> {
426 let items = [
427 (
428 "subcog://_prompts",
429 "All Prompts",
430 "Aggregate prompts from all domains (project, user, org)",
431 ),
432 (
433 "subcog://project/_prompts",
434 "Project Prompts",
435 "List all prompts in the project scope",
436 ),
437 (
438 "subcog://user/_prompts",
439 "User Prompts",
440 "List all prompts in the user scope",
441 ),
442 (
443 "subcog://project/_prompts/{name}",
444 "Project Prompt",
445 "Get a specific prompt by name from project scope",
446 ),
447 (
448 "subcog://user/_prompts/{name}",
449 "User Prompt",
450 "Get a specific prompt by name from user scope",
451 ),
452 (
453 "subcog://org/_prompts",
454 "Org Prompts",
455 "List all prompts in the org scope",
456 ),
457 (
458 "subcog://org/_prompts/{name}",
459 "Org Prompt",
460 "Get a specific prompt by name from org scope",
461 ),
462 ];
463
464 items
465 .iter()
466 .map(|(uri, name, desc)| Self::build_resource(uri, name, desc, "application/json"))
467 .collect()
468 }
469
470 pub fn get_resource(&mut self, uri: &str) -> Result<ResourceContent> {
491 let uri = uri.trim();
492
493 if !uri.starts_with("subcog://") {
494 return Err(Error::InvalidInput(format!("Invalid URI scheme: {uri}")));
495 }
496
497 let path = &uri["subcog://".len()..];
498 let parts: Vec<&str> = path.split('/').collect();
499
500 if parts.is_empty() {
501 return Err(Error::InvalidInput("Empty resource path".to_string()));
502 }
503
504 match parts[0] {
505 "help" => self.get_help_resource(uri, &parts),
506 "_" => self.get_all_memories_resource(uri, &parts),
507 "project" => self.get_domain_scoped_resource(uri, &parts, DomainScope::Project),
508 "user" => self.get_domain_scoped_resource(uri, &parts, DomainScope::User),
509 "org" => self.get_domain_scoped_resource(uri, &parts, DomainScope::Org),
510 "memory" => self.get_memory_resource(uri, &parts),
511 "search" => self.get_search_resource(uri, &parts),
512 "topics" => self.get_topics_resource(uri, &parts),
513 "namespaces" => self.get_namespaces_resource(uri),
514 "summaries" => self.get_summaries_resource(uri, &parts),
515 "_prompts" => self.get_aggregate_prompts_resource(uri),
516 _ => Err(Error::InvalidInput(format!(
517 "Unknown resource type: {}. Valid: _, help, memory, project, user, org, search, topics, namespaces, summaries, _prompts",
518 parts[0]
519 ))),
520 }
521 }
522
523 fn get_help_resource(&self, uri: &str, parts: &[&str]) -> Result<ResourceContent> {
525 if parts.len() == 1 {
526 return Ok(ResourceContent {
528 uri: uri.to_string(),
529 mime_type: Some("text/markdown".to_string()),
530 text: Some(self.get_help_index()),
531 blob: None,
532 });
533 }
534
535 let category = parts[1];
536 let content = self
537 .help_content
538 .get(category)
539 .ok_or_else(|| Error::InvalidInput(format!("Unknown help category: {category}")))?;
540
541 Ok(ResourceContent {
542 uri: uri.to_string(),
543 mime_type: Some("text/markdown".to_string()),
544 text: Some(format!("# {}\n\n{}", content.title, content.content)),
545 blob: None,
546 })
547 }
548
549 fn get_all_memories_resource(&self, uri: &str, parts: &[&str]) -> Result<ResourceContent> {
558 let namespace_filter = if parts[0] == "_" && parts.len() >= 2 {
563 Some(parts[1])
564 } else {
565 None
566 };
567
568 let mut filter = SearchFilter::new();
570 if let Some(ns_str) = namespace_filter {
571 let ns = Namespace::parse(ns_str)
572 .ok_or_else(|| Error::InvalidInput(format!("Unknown namespace: {ns_str}")))?;
573 filter = filter.with_namespace(ns);
574 }
575 self.list_memories(uri, &filter)
576 }
577
578 fn get_memory_resource(&self, uri: &str, parts: &[&str]) -> Result<ResourceContent> {
583 use crate::models::MemoryId;
584
585 if parts.len() < 2 {
586 return Err(Error::InvalidInput(
587 "Memory ID required: subcog://memory/{id}".to_string(),
588 ));
589 }
590
591 let memory_id = parts[1];
592 let recall = self.recall_service.as_ref().ok_or_else(|| {
593 Error::InvalidInput("Memory browsing requires RecallService".to_string())
594 })?;
595
596 let memory = recall
598 .get_by_id(&MemoryId::new(memory_id))?
599 .ok_or_else(|| Error::InvalidInput(format!("Memory not found: {memory_id}")))?;
600
601 self.format_memory_response(uri, &memory)
602 }
603
604 fn get_namespace_memories_resource(
606 &self,
607 uri: &str,
608 namespace: &str,
609 ) -> Result<ResourceContent> {
610 let ns = Namespace::parse(namespace)
611 .ok_or_else(|| Error::InvalidInput(format!("Unknown namespace: {namespace}")))?;
612 let filter = SearchFilter::new().with_namespace(ns);
613 self.list_memories(uri, &filter)
614 }
615
616 fn get_scoped_memory_resource(
618 &self,
619 uri: &str,
620 namespace: &str,
621 memory_id: &str,
622 ) -> Result<ResourceContent> {
623 use crate::models::MemoryId;
624
625 let recall = self.recall_service.as_ref().ok_or_else(|| {
626 Error::InvalidInput("Memory browsing requires RecallService".to_string())
627 })?;
628
629 let memory = recall
630 .get_by_id(&MemoryId::new(memory_id))?
631 .ok_or_else(|| Error::InvalidInput(format!("Memory not found: {memory_id}")))?;
632
633 if memory.namespace.as_str() != namespace {
634 return Err(Error::InvalidInput(format!(
635 "Memory {memory_id} is in namespace {}, not {namespace}",
636 memory.namespace.as_str()
637 )));
638 }
639
640 self.format_memory_response(uri, &memory)
641 }
642
643 fn format_memory_response(
645 &self,
646 uri: &str,
647 memory: &crate::models::Memory,
648 ) -> Result<ResourceContent> {
649 let response = serde_json::json!({
650 "id": memory.id.as_str(),
651 "namespace": memory.namespace.as_str(),
652 "domain": memory.domain.to_string(),
653 "content": memory.content,
654 "tags": memory.tags,
655 "source": memory.source,
656 "status": memory.status.as_str(),
657 "created_at": memory.created_at,
658 "updated_at": memory.updated_at,
659 });
660
661 Ok(ResourceContent {
662 uri: uri.to_string(),
663 mime_type: Some("application/json".to_string()),
664 text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
665 blob: None,
666 })
667 }
668
669 fn list_memories(&self, uri: &str, filter: &SearchFilter) -> Result<ResourceContent> {
670 let recall = self.recall_service.as_ref().ok_or_else(|| {
671 Error::InvalidInput("Memory browsing requires RecallService".to_string())
672 })?;
673
674 let results = recall.list_all(filter, 500)?;
675
676 let memories: Vec<serde_json::Value> = results
678 .memories
679 .iter()
680 .map(|hit| {
681 serde_json::json!({
682 "id": hit.memory.id.as_str(),
683 "ns": hit.memory.namespace.as_str(),
684 "tags": hit.memory.tags,
685 "uri": format!("subcog://memory/{}", hit.memory.id.as_str()),
686 })
687 })
688 .collect();
689
690 let response = serde_json::json!({
691 "count": memories.len(),
692 "memories": memories,
693 });
694
695 Ok(ResourceContent {
696 uri: uri.to_string(),
697 mime_type: Some("application/json".to_string()),
698 text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
699 blob: None,
700 })
701 }
702
703 fn get_search_resource(&self, uri: &str, parts: &[&str]) -> Result<ResourceContent> {
707 if parts.len() < 2 {
708 return Err(Error::InvalidInput(
709 "Search query required: subcog://search/<query>".to_string(),
710 ));
711 }
712
713 let recall = self
714 .recall_service
715 .as_ref()
716 .ok_or_else(|| Error::InvalidInput("Search requires RecallService".to_string()))?;
717
718 let query = parts[1..].join("/");
720 let query = decode_uri_component(&query);
721
722 let filter = SearchFilter::new();
724 let results = recall.search(&query, SearchMode::Hybrid, &filter, 20)?;
725
726 let memories: Vec<serde_json::Value> = results
728 .memories
729 .iter()
730 .map(|hit| {
731 serde_json::json!({
732 "id": hit.memory.id.as_str(),
733 "namespace": hit.memory.namespace.as_str(),
734 "score": hit.score,
735 "tags": hit.memory.tags,
736 "content_preview": truncate_content(&hit.memory.content, 200),
737 "uri": format!("subcog://memory/{}", hit.memory.id.as_str()),
738 })
739 })
740 .collect();
741
742 let response = serde_json::json!({
743 "query": query,
744 "count": memories.len(),
745 "mode": "hybrid",
746 "memories": memories,
747 });
748
749 Ok(ResourceContent {
750 uri: uri.to_string(),
751 mime_type: Some("application/json".to_string()),
752 text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
753 blob: None,
754 })
755 }
756
757 fn get_topics_resource(&self, uri: &str, parts: &[&str]) -> Result<ResourceContent> {
763 let topic_index = self
764 .topic_index
765 .as_ref()
766 .ok_or_else(|| Error::InvalidInput("Topic index not initialized".to_string()))?;
767
768 if parts.len() == 1 {
769 let topics = topic_index.list_topics()?;
771
772 let topics_json: Vec<serde_json::Value> = topics
773 .iter()
774 .map(|t| {
775 serde_json::json!({
776 "name": t.name,
777 "memory_count": t.memory_count,
778 "namespaces": t.namespaces.iter().map(Namespace::as_str).collect::<Vec<_>>(),
779 "uri": format!("subcog://topics/{}", t.name),
780 })
781 })
782 .collect();
783
784 let response = serde_json::json!({
785 "count": topics_json.len(),
786 "topics": topics_json,
787 });
788
789 Ok(ResourceContent {
790 uri: uri.to_string(),
791 mime_type: Some("application/json".to_string()),
792 text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
793 blob: None,
794 })
795 } else {
796 let topic = parts[1..].join("/");
798 let topic = decode_uri_component(&topic);
799
800 let memory_ids = topic_index.get_topic_memories(&topic)?;
801
802 if memory_ids.is_empty() {
803 return Err(Error::InvalidInput(format!("Topic not found: {topic}")));
804 }
805
806 let topic_info = topic_index.get_topic_info(&topic)?;
808
809 let memories: Vec<serde_json::Value> = if let Some(recall) = &self.recall_service {
811 memory_ids
812 .iter()
813 .filter_map(|id| recall.get_by_id(id).ok().flatten())
814 .map(|m| format_memory_preview(&m))
815 .collect()
816 } else {
817 memory_ids.iter().map(format_memory_id_only).collect()
819 };
820
821 let response = serde_json::json!({
822 "topic": topic,
823 "memory_count": topic_info.as_ref().map_or(memory_ids.len(), |i| i.memory_count),
824 "namespaces": topic_info.as_ref().map_or_else(Vec::new, |i| {
825 i.namespaces.iter().map(Namespace::as_str).collect::<Vec<_>>()
826 }),
827 "memories": memories,
828 });
829
830 Ok(ResourceContent {
831 uri: uri.to_string(),
832 mime_type: Some("application/json".to_string()),
833 text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
834 blob: None,
835 })
836 }
837 }
838
839 fn get_namespaces_resource(&self, uri: &str) -> Result<ResourceContent> {
845 use crate::cli::get_all_namespaces;
846
847 let namespaces = get_all_namespaces();
848
849 let namespaces_json: Vec<serde_json::Value> = namespaces
850 .iter()
851 .map(|ns| {
852 serde_json::json!({
853 "namespace": ns.namespace,
854 "description": ns.description,
855 "signal_words": ns.signal_words,
856 })
857 })
858 .collect();
859
860 let response = serde_json::json!({
861 "count": namespaces_json.len(),
862 "namespaces": namespaces_json,
863 });
864
865 Ok(ResourceContent {
866 uri: uri.to_string(),
867 mime_type: Some("application/json".to_string()),
868 text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
869 blob: None,
870 })
871 }
872
873 fn get_summaries_resource(&self, uri: &str, parts: &[&str]) -> Result<ResourceContent> {
879 let recall = self.recall_service.as_ref().ok_or_else(|| {
880 Error::InvalidInput("Summary browsing requires RecallService".to_string())
881 })?;
882
883 if parts.len() == 1 {
884 let filter = SearchFilter::new();
886 let results = recall.list_all(&filter, 1000)?;
887
888 let summaries: Vec<serde_json::Value> = results
890 .memories
891 .iter()
892 .filter(|hit| hit.memory.is_summary)
893 .map(|hit| {
894 serde_json::json!({
895 "id": hit.memory.id.as_str(),
896 "namespace": hit.memory.namespace.as_str(),
897 "tags": hit.memory.tags,
898 "content_preview": truncate_content(&hit.memory.content, 200),
899 "source_count": hit.memory.source_memory_ids.as_ref().map_or(0, Vec::len),
900 "consolidation_timestamp": hit.memory.consolidation_timestamp,
901 "uri": format!("subcog://summaries/{}", hit.memory.id.as_str()),
902 })
903 })
904 .collect();
905
906 let response = serde_json::json!({
907 "count": summaries.len(),
908 "summaries": summaries,
909 });
910
911 Ok(ResourceContent {
912 uri: uri.to_string(),
913 mime_type: Some("application/json".to_string()),
914 text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
915 blob: None,
916 })
917 } else {
918 let summary_id = parts[1..].join("/");
920 let summary_id = decode_uri_component(&summary_id);
921
922 self.get_specific_summary_resource(uri, recall, &summary_id)
923 }
924 }
925
926 fn get_specific_summary_resource(
930 &self,
931 uri: &str,
932 recall: &RecallService,
933 summary_id: &str,
934 ) -> Result<ResourceContent> {
935 use crate::models::MemoryId;
936
937 let summary = recall
939 .get_by_id(&MemoryId::new(summary_id))?
940 .ok_or_else(|| Error::InvalidInput(format!("Summary not found: {summary_id}")))?;
941
942 if !summary.is_summary {
944 return Err(Error::InvalidInput(format!(
945 "Memory {summary_id} is not a summary node"
946 )));
947 }
948
949 let source_ids = summary
951 .source_memory_ids
952 .as_ref()
953 .map_or_else(Vec::new, Clone::clone);
954
955 let source_memories: Vec<serde_json::Value> = source_ids
957 .iter()
958 .filter_map(|id| recall.get_by_id(id).ok().flatten())
959 .map(|m| {
960 serde_json::json!({
961 "id": m.id.as_str(),
962 "namespace": m.namespace.as_str(),
963 "tags": m.tags,
964 "content_preview": truncate_content(&m.content, 150),
965 "uri": format!("subcog://memory/{}", m.id.as_str()),
966 })
967 })
968 .collect();
969
970 let response = serde_json::json!({
971 "id": summary.id.as_str(),
972 "namespace": summary.namespace.as_str(),
973 "domain": summary.domain.to_string(),
974 "content": summary.content,
975 "tags": summary.tags,
976 "consolidation_timestamp": summary.consolidation_timestamp,
977 "source_count": source_ids.len(),
978 "source_memories": source_memories,
979 "created_at": summary.created_at,
980 "updated_at": summary.updated_at,
981 });
982
983 Ok(ResourceContent {
984 uri: uri.to_string(),
985 mime_type: Some("application/json".to_string()),
986 text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
987 blob: None,
988 })
989 }
990
991 fn get_aggregate_prompts_resource(&mut self, uri: &str) -> Result<ResourceContent> {
998 use crate::services::PromptFilter;
999 use std::collections::HashSet;
1000
1001 let prompt_service = self.prompt_service.as_mut().ok_or_else(|| {
1002 Error::InvalidInput("Prompt browsing requires PromptService".to_string())
1003 })?;
1004
1005 let mut seen_names: HashSet<String> = HashSet::new();
1008 let mut prompts_json: Vec<serde_json::Value> = Vec::new();
1009
1010 let domains = [
1011 (DomainScope::Project, "project"),
1012 (DomainScope::User, "user"),
1013 (DomainScope::Org, "org"),
1014 ];
1015
1016 for (domain, domain_name) in domains {
1017 let filter = PromptFilter::new().with_domain(domain);
1018 let prompts = prompt_service.list(&filter).unwrap_or_default();
1019 Self::collect_unique_prompts(&mut seen_names, &mut prompts_json, prompts, domain_name);
1020 }
1021
1022 let response = serde_json::json!({
1023 "count": prompts_json.len(),
1024 "prompts": prompts_json,
1025 });
1026
1027 Ok(ResourceContent {
1028 uri: uri.to_string(),
1029 mime_type: Some("application/json".to_string()),
1030 text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
1031 blob: None,
1032 })
1033 }
1034
1035 fn collect_unique_prompts(
1037 seen: &mut std::collections::HashSet<String>,
1038 output: &mut Vec<serde_json::Value>,
1039 prompts: Vec<crate::models::PromptTemplate>,
1040 domain_name: &str,
1041 ) {
1042 for p in prompts {
1043 if seen.contains(&p.name) {
1044 continue;
1045 }
1046 seen.insert(p.name.clone());
1047 output.push(serde_json::json!({
1048 "name": p.name,
1049 "description": p.description,
1050 "domain": domain_name,
1051 "tags": p.tags,
1052 "usage_count": p.usage_count,
1053 "variables": p.variables.iter().map(|v| v.name.clone()).collect::<Vec<_>>(),
1054 }));
1055 }
1056 }
1057
1058 fn get_domain_scoped_resource(
1065 &mut self,
1066 uri: &str,
1067 parts: &[&str],
1068 domain: DomainScope,
1069 ) -> Result<ResourceContent> {
1070 if parts.len() >= 2 && parts[1] == "_prompts" {
1072 if parts.len() >= 3 {
1074 let name = parts[2..].join("/");
1075 let name = decode_uri_component(&name);
1076 return self.get_single_prompt_resource(uri, domain, &name);
1077 }
1078 return self.get_prompts_resource(uri, domain);
1079 }
1080
1081 if parts.len() == 1 {
1082 return self.get_all_memories_resource(uri, parts);
1083 }
1084
1085 let namespace = decode_uri_component(parts[1]);
1086 if namespace == "_" {
1087 return self.get_all_memories_resource(uri, parts);
1088 }
1089
1090 if parts.len() >= 3 {
1091 let memory_id = decode_uri_component(&parts[2..].join("/"));
1092 return self.get_scoped_memory_resource(uri, &namespace, &memory_id);
1093 }
1094
1095 self.get_namespace_memories_resource(uri, &namespace)
1096 }
1097
1098 fn get_prompts_resource(&mut self, uri: &str, domain: DomainScope) -> Result<ResourceContent> {
1100 use crate::services::PromptFilter;
1101
1102 let prompt_service = self.prompt_service.as_mut().ok_or_else(|| {
1103 Error::InvalidInput("Prompt browsing requires PromptService".to_string())
1104 })?;
1105
1106 let filter = PromptFilter::new().with_domain(domain);
1108 let prompts = prompt_service.list(&filter)?;
1109
1110 let prompts_json: Vec<serde_json::Value> = prompts
1111 .iter()
1112 .map(|p| {
1113 serde_json::json!({
1114 "name": p.name,
1115 "description": p.description,
1116 "tags": p.tags,
1117 "usage_count": p.usage_count,
1118 "variables": p.variables.iter().map(|v| v.name.clone()).collect::<Vec<_>>(),
1119 })
1120 })
1121 .collect();
1122
1123 let response = serde_json::json!({
1124 "domain": domain.as_str(),
1125 "count": prompts_json.len(),
1126 "prompts": prompts_json,
1127 });
1128
1129 Ok(ResourceContent {
1130 uri: uri.to_string(),
1131 mime_type: Some("application/json".to_string()),
1132 text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
1133 blob: None,
1134 })
1135 }
1136
1137 fn get_single_prompt_resource(
1139 &mut self,
1140 uri: &str,
1141 domain: DomainScope,
1142 name: &str,
1143 ) -> Result<ResourceContent> {
1144 let prompt_service = self.prompt_service.as_mut().ok_or_else(|| {
1145 Error::InvalidInput("Prompt browsing requires PromptService".to_string())
1146 })?;
1147
1148 let prompt = prompt_service.get(name, Some(domain))?.ok_or_else(|| {
1150 Error::InvalidInput(format!(
1151 "Prompt not found: {} in {} scope",
1152 name,
1153 domain.as_str()
1154 ))
1155 })?;
1156
1157 let variables_json: Vec<serde_json::Value> = prompt
1159 .variables
1160 .iter()
1161 .map(|v| {
1162 serde_json::json!({
1163 "name": v.name,
1164 "description": v.description,
1165 "required": v.required,
1166 "default": v.default,
1167 })
1168 })
1169 .collect();
1170
1171 let response = serde_json::json!({
1172 "name": prompt.name,
1173 "description": prompt.description,
1174 "content": prompt.content,
1175 "domain": domain.as_str(),
1176 "tags": prompt.tags,
1177 "usage_count": prompt.usage_count,
1178 "variables": variables_json,
1179 "created_at": prompt.created_at,
1180 "updated_at": prompt.updated_at,
1181 });
1182
1183 Ok(ResourceContent {
1184 uri: uri.to_string(),
1185 mime_type: Some("application/json".to_string()),
1186 text: Some(serde_json::to_string_pretty(&response).unwrap_or_default()),
1187 blob: None,
1188 })
1189 }
1190
1191 fn get_help_index(&self) -> String {
1193 let mut index = "# Subcog Help\n\nWelcome to Subcog, the persistent memory system for AI coding assistants.\n\n## Available Topics\n\n".to_string();
1194
1195 for cat in self.help_content.values() {
1196 index.push_str(&format!(
1197 "- **[{}](subcog://help/{})**: {}\n",
1198 cat.title, cat.name, cat.description
1199 ));
1200 }
1201
1202 index.push_str("\n## Quick Start (MCP Tools)\n\n");
1203 index
1204 .push_str("1. **Capture**: Use `subcog_capture` tool with `namespace` and `content`\n");
1205 index.push_str("2. **Search**: Use `subcog_recall` tool with `query` parameter\n");
1206 index.push_str("3. **Status**: Use `subcog_status` tool\n");
1207 index.push_str(
1208 "4. **Browse**: Use `subcog prompt run subcog_browse` or `subcog://project/_` resource\n",
1209 );
1210
1211 index
1212 }
1213
1214 #[must_use]
1216 pub fn list_categories(&self) -> Vec<&HelpCategory> {
1217 self.help_content.values().collect()
1218 }
1219}
1220
1221impl Default for ResourceHandler {
1222 fn default() -> Self {
1223 Self::new()
1224 }
1225}
1226
1227#[derive(Debug, Clone, Serialize, Deserialize)]
1229pub struct ResourceDefinition {
1230 pub uri: String,
1232 pub name: String,
1234 pub description: Option<String>,
1236 pub mime_type: Option<String>,
1238}
1239
1240#[derive(Debug, Clone, Serialize, Deserialize)]
1242pub struct ResourceContent {
1243 pub uri: String,
1245 pub mime_type: Option<String>,
1247 pub text: Option<String>,
1249 pub blob: Option<String>,
1251}
1252
1253#[derive(Debug, Clone)]
1255pub struct HelpCategory {
1256 pub name: String,
1258 pub title: String,
1260 pub description: String,
1262 pub content: String,
1264}
1265
1266fn format_memory_preview(m: &crate::models::Memory) -> serde_json::Value {
1268 serde_json::json!({
1269 "id": m.id.as_str(),
1270 "namespace": m.namespace.as_str(),
1271 "content_preview": truncate_content(&m.content, 200),
1272 "tags": m.tags,
1273 "uri": format!("subcog://memory/{}", m.id.as_str()),
1274 })
1275}
1276
1277fn format_memory_id_only(id: &crate::models::MemoryId) -> serde_json::Value {
1279 serde_json::json!({
1280 "id": id.as_str(),
1281 "uri": format!("subcog://memory/{}", id.as_str()),
1282 })
1283}
1284
1285fn decode_uri_component(s: &str) -> String {
1289 let mut result = String::with_capacity(s.len());
1290 let mut chars = s.chars();
1291
1292 while let Some(c) = chars.next() {
1293 match c {
1294 '%' => {
1295 let hex: String = chars.by_ref().take(2).collect();
1296 let decoded = (hex.len() == 2)
1297 .then(|| u8::from_str_radix(&hex, 16).ok())
1298 .flatten()
1299 .map(char::from);
1300
1301 if let Some(ch) = decoded {
1302 result.push(ch);
1303 } else {
1304 result.push('%');
1305 result.push_str(&hex);
1306 }
1307 },
1308 '+' => result.push(' '),
1309 _ => result.push(c),
1310 }
1311 }
1312
1313 result
1314}
1315
1316fn truncate_content(content: &str, max_len: usize) -> String {
1318 if content.len() <= max_len {
1319 return content.to_string();
1320 }
1321
1322 let truncated = &content[..max_len];
1323 truncated.rfind(' ').map_or_else(
1324 || format!("{truncated}..."),
1325 |last_space| format!("{}...", &truncated[..last_space]),
1326 )
1327}
1328
1329#[cfg(test)]
1330mod tests {
1331 use super::*;
1332 use crate::models::{Domain, Memory, MemoryId, MemoryStatus};
1333 use crate::services::RecallService;
1334 use crate::storage::index::SqliteBackend;
1335 use crate::storage::traits::IndexBackend;
1336
1337 fn build_handler_with_memories() -> ResourceHandler {
1338 let index = SqliteBackend::in_memory().expect("in-memory index");
1339 let now = 1_700_000_000;
1340 let memory = Memory {
1341 id: MemoryId::new("decisions-1"),
1342 content: "Decision content".to_string(),
1343 namespace: Namespace::Decisions,
1344 domain: Domain::new(),
1345 project_id: None,
1346 branch: None,
1347 file_path: None,
1348 status: MemoryStatus::Active,
1349 created_at: now,
1350 updated_at: now,
1351 tombstoned_at: None,
1352 expires_at: None,
1353 embedding: None,
1354 tags: vec!["alpha".to_string()],
1355 #[cfg(feature = "group-scope")]
1356 group_id: None,
1357 source: None,
1358 is_summary: false,
1359 source_memory_ids: None,
1360 consolidation_timestamp: None,
1361 };
1362 let other = Memory {
1363 id: MemoryId::new("patterns-1"),
1364 content: "Pattern content".to_string(),
1365 namespace: Namespace::Patterns,
1366 domain: Domain::new(),
1367 project_id: None,
1368 branch: None,
1369 file_path: None,
1370 status: MemoryStatus::Active,
1371 created_at: now,
1372 updated_at: now,
1373 tombstoned_at: None,
1374 expires_at: None,
1375 embedding: None,
1376 tags: vec!["beta".to_string()],
1377 #[cfg(feature = "group-scope")]
1378 group_id: None,
1379 source: None,
1380 is_summary: false,
1381 source_memory_ids: None,
1382 consolidation_timestamp: None,
1383 };
1384
1385 index.index(&memory).expect("index memory");
1386 index.index(&other).expect("index memory");
1387
1388 let recall = RecallService::with_index(index);
1389 ResourceHandler::new().with_recall_service(recall)
1390 }
1391
1392 #[test]
1393 fn test_resource_handler_creation() {
1394 let handler = ResourceHandler::new();
1395 let resources = handler.list_resources();
1396
1397 assert!(!resources.is_empty());
1398 assert!(resources.iter().any(|r| r.uri.contains("setup")));
1399 assert!(resources.iter().any(|r| r.uri.contains("concepts")));
1400 }
1401
1402 #[test]
1403 fn test_get_help_index() {
1404 let mut handler = ResourceHandler::new();
1405 let result = handler.get_resource("subcog://help").unwrap();
1406
1407 assert!(result.text.is_some());
1408 let text = result.text.unwrap();
1409 assert!(text.contains("Subcog Help"));
1410 assert!(text.contains("Quick Start"));
1411 }
1412
1413 #[test]
1414 fn test_get_help_category() {
1415 let mut handler = ResourceHandler::new();
1416
1417 let result = handler.get_resource("subcog://help/setup").unwrap();
1418 assert!(result.text.is_some());
1419 assert!(result.text.unwrap().contains("MCP Server Configuration"));
1420
1421 let result = handler.get_resource("subcog://help/concepts").unwrap();
1422 assert!(result.text.is_some());
1423 assert!(result.text.unwrap().contains("Namespaces"));
1424 }
1425
1426 #[test]
1427 fn test_invalid_uri() {
1428 let mut handler = ResourceHandler::new();
1429
1430 let result = handler.get_resource("http://example.com");
1431 assert!(result.is_err());
1432
1433 let result = handler.get_resource("subcog://unknown");
1434 assert!(result.is_err());
1435 }
1436
1437 #[test]
1438 fn test_unknown_category() {
1439 let mut handler = ResourceHandler::new();
1440
1441 let result = handler.get_resource("subcog://help/nonexistent");
1442 assert!(result.is_err());
1443 }
1444
1445 #[test]
1446 fn test_list_categories() {
1447 let handler = ResourceHandler::new();
1448 let categories = handler.list_categories();
1449
1450 assert_eq!(categories.len(), 8); }
1452
1453 #[test]
1454 fn test_prompts_help_category() {
1455 let mut handler = ResourceHandler::new();
1456
1457 let result = handler.get_resource("subcog://help/prompts");
1459 assert!(result.is_ok());
1460
1461 let content = result.unwrap();
1462 assert!(content.text.is_some());
1463 let text = content.text.unwrap();
1464 assert!(text.contains("User-Defined Prompts"));
1465 assert!(text.contains("prompt_save"));
1466 assert!(text.contains("Variable Syntax"));
1467 }
1468
1469 #[test]
1470 fn test_decode_uri_component() {
1471 assert_eq!(decode_uri_component("hello%20world"), "hello world");
1472 assert_eq!(decode_uri_component("hello+world"), "hello world");
1473 assert_eq!(decode_uri_component("rust%2Ferror"), "rust/error");
1474 assert_eq!(decode_uri_component("no%change"), "no%change"); assert_eq!(decode_uri_component("plain"), "plain");
1476 }
1477
1478 #[test]
1479 fn test_truncate_content() {
1480 assert_eq!(truncate_content("short", 100), "short");
1481 assert_eq!(
1482 truncate_content("this is a longer sentence with words", 20),
1483 "this is a longer..."
1484 );
1485 assert_eq!(truncate_content("nospaces", 4), "nosp...");
1486 }
1487
1488 #[test]
1489 fn test_list_resources_includes_search_and_topics() {
1490 let handler = ResourceHandler::new();
1491 let resources = handler.list_resources();
1492
1493 assert!(resources.iter().any(|r| r.uri.contains("search")));
1494 assert!(resources.iter().any(|r| r.uri.contains("topics")));
1495 }
1496
1497 #[test]
1498 fn test_list_resources_includes_help_and_domain_templates() {
1499 let handler = ResourceHandler::new();
1500 let resources = handler.list_resources();
1501
1502 assert!(resources.iter().any(|r| r.uri == "subcog://help"));
1503 assert!(resources.iter().any(|r| r.uri == "subcog://memory/{id}"));
1504 assert!(
1505 resources
1506 .iter()
1507 .any(|r| r.uri == "subcog://project/{namespace}")
1508 );
1509 assert!(resources.iter().any(|r| r.uri == "subcog://org/_prompts"));
1510 }
1511
1512 #[test]
1513 fn test_search_resource_requires_recall_service() {
1514 let mut handler = ResourceHandler::new();
1515 let result = handler.get_resource("subcog://search/test");
1516 assert!(result.is_err());
1517 assert!(result.unwrap_err().to_string().contains("RecallService"));
1518 }
1519
1520 #[test]
1521 fn test_topics_resource_requires_topic_index() {
1522 let mut handler = ResourceHandler::new();
1523 let result = handler.get_resource("subcog://topics");
1524 assert!(result.is_err());
1525 assert!(
1526 result
1527 .unwrap_err()
1528 .to_string()
1529 .contains("Topic index not initialized")
1530 );
1531 }
1532
1533 #[test]
1534 fn test_search_resource_requires_query() {
1535 let mut handler = ResourceHandler::new();
1536 let result = handler.get_resource("subcog://search/");
1537 assert!(result.is_err());
1540 }
1541
1542 #[test]
1543 fn test_project_namespace_listing_filters() {
1544 let mut handler = build_handler_with_memories();
1545 let result = handler.get_resource("subcog://project/decisions").unwrap();
1546 let body = result.text.unwrap();
1547 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
1548 assert_eq!(value["count"].as_u64(), Some(1));
1549 assert_eq!(value["memories"][0]["id"], "decisions-1");
1550 assert_eq!(value["memories"][0]["ns"], "decisions");
1551 }
1552
1553 #[test]
1554 fn test_project_namespace_memory_fetch() {
1555 let mut handler = build_handler_with_memories();
1556 let result = handler
1557 .get_resource("subcog://project/decisions/decisions-1")
1558 .unwrap();
1559 let body = result.text.unwrap();
1560 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
1561 assert_eq!(value["id"], "decisions-1");
1562 assert_eq!(value["namespace"], "decisions");
1563 }
1564
1565 #[test]
1566 fn test_project_namespace_memory_fetch_rejects_mismatch() {
1567 let mut handler = build_handler_with_memories();
1568 let result = handler.get_resource("subcog://project/decisions/patterns-1");
1569 assert!(result.is_err());
1570 }
1571
1572 #[test]
1573 fn test_namespaces_resource() {
1574 let mut handler = ResourceHandler::new();
1575 let result = handler.get_resource("subcog://namespaces").unwrap();
1576
1577 assert!(result.text.is_some());
1578 let text = result.text.unwrap();
1579 let value: serde_json::Value = serde_json::from_str(&text).unwrap();
1580
1581 assert_eq!(value["count"].as_u64(), Some(11));
1582 assert!(value["namespaces"].is_array());
1583
1584 let namespaces = value["namespaces"].as_array().unwrap();
1585 assert!(namespaces.iter().any(|ns| ns["namespace"] == "decisions"));
1586 assert!(namespaces.iter().any(|ns| ns["namespace"] == "patterns"));
1587 assert!(namespaces.iter().any(|ns| ns["namespace"] == "learnings"));
1588
1589 let decisions = namespaces
1591 .iter()
1592 .find(|ns| ns["namespace"] == "decisions")
1593 .unwrap();
1594 assert!(decisions["signal_words"].is_array());
1595 assert!(!decisions["signal_words"].as_array().unwrap().is_empty());
1596 }
1597
1598 #[test]
1599 fn test_list_resources_includes_namespaces() {
1600 let handler = ResourceHandler::new();
1601 let resources = handler.list_resources();
1602
1603 assert!(resources.iter().any(|r| r.uri == "subcog://namespaces"));
1604 }
1605
1606 #[test]
1607 fn test_list_resources_includes_aggregate_prompts() {
1608 let handler = ResourceHandler::new();
1609 let resources = handler.list_resources();
1610
1611 assert!(resources.iter().any(|r| r.uri == "subcog://_prompts"));
1612 }
1613
1614 #[test]
1615 fn test_aggregate_prompts_requires_prompt_service() {
1616 let mut handler = ResourceHandler::new();
1617 let result = handler.get_resource("subcog://_prompts");
1618 assert!(result.is_err());
1619 assert!(result.unwrap_err().to_string().contains("PromptService"));
1620 }
1621
1622 #[test]
1623 fn test_list_resources_includes_summaries() {
1624 let handler = ResourceHandler::new();
1625 let resources = handler.list_resources();
1626
1627 assert!(resources.iter().any(|r| r.uri == "subcog://summaries"));
1628 assert!(resources.iter().any(|r| r.uri == "subcog://summaries/{id}"));
1629 }
1630
1631 #[test]
1632 fn test_summaries_resource_requires_recall_service() {
1633 let mut handler = ResourceHandler::new();
1634 let result = handler.get_resource("subcog://summaries");
1635 assert!(result.is_err());
1636 assert!(result.unwrap_err().to_string().contains("RecallService"));
1637 }
1638
1639 #[test]
1640 fn test_summaries_list_filters_only_summary_nodes() {
1641 let index = SqliteBackend::in_memory().expect("in-memory index");
1642 let now = 1_700_000_000;
1643
1644 let regular = Memory {
1646 id: MemoryId::new("regular-1"),
1647 content: "Regular memory content".to_string(),
1648 namespace: Namespace::Decisions,
1649 domain: Domain::new(),
1650 project_id: None,
1651 branch: None,
1652 file_path: None,
1653 status: MemoryStatus::Active,
1654 created_at: now,
1655 updated_at: now,
1656 tombstoned_at: None,
1657 expires_at: None,
1658 embedding: None,
1659 tags: vec!["tag1".to_string()],
1660 #[cfg(feature = "group-scope")]
1661 group_id: None,
1662 source: None,
1663 is_summary: false,
1664 source_memory_ids: None,
1665 consolidation_timestamp: None,
1666 };
1667
1668 let summary = Memory {
1670 id: MemoryId::new("summary-1"),
1671 content: "Summary of related memories".to_string(),
1672 namespace: Namespace::Decisions,
1673 domain: Domain::new(),
1674 project_id: None,
1675 branch: None,
1676 file_path: None,
1677 status: MemoryStatus::Active,
1678 created_at: now,
1679 updated_at: now,
1680 tombstoned_at: None,
1681 expires_at: None,
1682 embedding: None,
1683 tags: vec!["tag1".to_string()],
1684 #[cfg(feature = "group-scope")]
1685 group_id: None,
1686 source: Some("consolidation".to_string()),
1687 is_summary: true,
1688 source_memory_ids: Some(vec![MemoryId::new("regular-1")]),
1689 consolidation_timestamp: Some(now),
1690 };
1691
1692 index.index(®ular).expect("index regular memory");
1693 index.index(&summary).expect("index summary memory");
1694
1695 let recall = RecallService::with_index(index);
1696 let mut handler = ResourceHandler::new().with_recall_service(recall);
1697
1698 let result = handler.get_resource("subcog://summaries").unwrap();
1699 let body = result.text.unwrap();
1700 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
1701
1702 assert_eq!(value["count"].as_u64(), Some(1));
1704 assert_eq!(value["summaries"][0]["id"], "summary-1");
1705 assert_eq!(value["summaries"][0]["source_count"], 1);
1706 assert!(value["summaries"][0]["consolidation_timestamp"].is_u64());
1707 }
1708
1709 #[test]
1710 fn test_get_specific_summary_with_sources() {
1711 let index = SqliteBackend::in_memory().expect("in-memory index");
1712 let now = 1_700_000_000;
1713
1714 let source1 = Memory {
1716 id: MemoryId::new("source-1"),
1717 content: "First source memory with detailed content".to_string(),
1718 namespace: Namespace::Decisions,
1719 domain: Domain::new(),
1720 project_id: None,
1721 branch: None,
1722 file_path: None,
1723 status: MemoryStatus::Active,
1724 created_at: now,
1725 updated_at: now,
1726 tombstoned_at: None,
1727 expires_at: None,
1728 embedding: None,
1729 tags: vec!["db".to_string()],
1730 #[cfg(feature = "group-scope")]
1731 group_id: None,
1732 source: None,
1733 is_summary: false,
1734 source_memory_ids: None,
1735 consolidation_timestamp: None,
1736 };
1737
1738 let source2 = Memory {
1739 id: MemoryId::new("source-2"),
1740 content: "Second source memory with more details".to_string(),
1741 namespace: Namespace::Decisions,
1742 domain: Domain::new(),
1743 project_id: None,
1744 branch: None,
1745 file_path: None,
1746 status: MemoryStatus::Active,
1747 created_at: now,
1748 updated_at: now,
1749 tombstoned_at: None,
1750 expires_at: None,
1751 embedding: None,
1752 tags: vec!["cache".to_string()],
1753 #[cfg(feature = "group-scope")]
1754 group_id: None,
1755 source: None,
1756 is_summary: false,
1757 source_memory_ids: None,
1758 consolidation_timestamp: None,
1759 };
1760
1761 let summary = Memory {
1763 id: MemoryId::new("summary-1"),
1764 content: "Consolidated summary of database and caching decisions".to_string(),
1765 namespace: Namespace::Decisions,
1766 domain: Domain::new(),
1767 project_id: None,
1768 branch: None,
1769 file_path: None,
1770 status: MemoryStatus::Active,
1771 created_at: now,
1772 updated_at: now,
1773 tombstoned_at: None,
1774 expires_at: None,
1775 embedding: None,
1776 tags: vec!["db".to_string(), "cache".to_string()],
1777 #[cfg(feature = "group-scope")]
1778 group_id: None,
1779 source: Some("consolidation".to_string()),
1780 is_summary: true,
1781 source_memory_ids: Some(vec![MemoryId::new("source-1"), MemoryId::new("source-2")]),
1782 consolidation_timestamp: Some(now),
1783 };
1784
1785 index.index(&source1).expect("index source1");
1786 index.index(&source2).expect("index source2");
1787 index.index(&summary).expect("index summary");
1788
1789 let recall = RecallService::with_index(index);
1790 let mut handler = ResourceHandler::new().with_recall_service(recall);
1791
1792 let result = handler
1793 .get_resource("subcog://summaries/summary-1")
1794 .unwrap();
1795 let body = result.text.unwrap();
1796 let value: serde_json::Value = serde_json::from_str(&body).unwrap();
1797
1798 assert_eq!(value["id"], "summary-1");
1799 assert_eq!(value["namespace"], "decisions");
1800 assert_eq!(value["source_count"], 2);
1801 assert_eq!(
1802 value["content"],
1803 "Consolidated summary of database and caching decisions"
1804 );
1805
1806 let source_arr = value["source_memories"].as_array().unwrap();
1808 assert_eq!(source_arr.len(), 2);
1809 assert!(source_arr.iter().any(|s| s["id"] == "source-1"));
1810 assert!(source_arr.iter().any(|s| s["id"] == "source-2"));
1811 }
1812
1813 #[test]
1814 fn test_get_summary_rejects_non_summary() {
1815 let index = SqliteBackend::in_memory().expect("in-memory index");
1816 let now = 1_700_000_000;
1817
1818 let regular = Memory {
1820 id: MemoryId::new("regular-1"),
1821 content: "Regular memory".to_string(),
1822 namespace: Namespace::Decisions,
1823 domain: Domain::new(),
1824 project_id: None,
1825 branch: None,
1826 file_path: None,
1827 status: MemoryStatus::Active,
1828 created_at: now,
1829 updated_at: now,
1830 tombstoned_at: None,
1831 expires_at: None,
1832 embedding: None,
1833 tags: vec![],
1834 #[cfg(feature = "group-scope")]
1835 group_id: None,
1836 source: None,
1837 is_summary: false,
1838 source_memory_ids: None,
1839 consolidation_timestamp: None,
1840 };
1841
1842 index.index(®ular).expect("index memory");
1843
1844 let recall = RecallService::with_index(index);
1845 let mut handler = ResourceHandler::new().with_recall_service(recall);
1846
1847 let result = handler.get_resource("subcog://summaries/regular-1");
1848 assert!(result.is_err());
1849 assert!(
1850 result
1851 .unwrap_err()
1852 .to_string()
1853 .contains("not a summary node")
1854 );
1855 }
1856
1857 #[test]
1858 fn test_get_summary_not_found() {
1859 let index = SqliteBackend::in_memory().expect("in-memory index");
1860 let recall = RecallService::with_index(index);
1861 let mut handler = ResourceHandler::new().with_recall_service(recall);
1862
1863 let result = handler.get_resource("subcog://summaries/nonexistent");
1864 assert!(result.is_err());
1865 assert!(result.unwrap_err().to_string().contains("not found"));
1866 }
1867}