Skip to main content

Tutorial: Creating Your First Skill

Overview

In this tutorial, we’ll create a Memory Query Skill that allows Souls to search and retrieve information from their memory graph. This is a practical skill that will be immediately useful for the Soul Kernel v0.1 demo.

Prerequisites

  • Completed the Setup Guide
  • Soul CLI installed (soul --version)
  • Basic Rust knowledge
  • Understanding of the Memory Graph

What We’ll Build

A Memory Query Skill that:
  • Searches the Soul’s memory graph
  • Filters memories by type, time, or content
  • Returns relevant memories with context
  • Demonstrates async operations
  • Shows proper error handling
This skill will enable conversations like:
  • “What do you remember about our last conversation?”
  • “When did we talk about robotics?”
  • “Show me all memories tagged with ‘important‘“

Step 1: Create the Project

# Create a new skill using the cognitive template
soul new skill memory-query --template cognitive
cd memory-query-skill
This creates a project structure optimized for cognitive skills:
memory-query-skill/
├── Cargo.toml
├── src/
│   ├── lib.rs
│   ├── query_parser.rs
│   └── memory_search.rs
├── tests/
│   └── integration_test.rs
└── examples/
    └── demo.rs

Step 2: Update Dependencies

Edit Cargo.toml to add memory graph dependencies:
[package]
name = "memory-query-skill"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
soul-kernel-skill-abi = "0.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
chrono = { version = "0.4", features = ["serde"] }
regex = "1.10"
thiserror = "1.0"

# For async support (future)
tokio = { version = "1", features = ["sync"], optional = true }

[dev-dependencies]
mockall = "0.12"

Step 3: Define Query Types

Create src/query_parser.rs:
use chrono::{DateTime, Utc};
use regex::Regex;
use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryQuery {
    pub query_type: QueryType,
    pub filters: Vec<Filter>,
    pub limit: Option<usize>,
    pub sort_by: SortOrder,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum QueryType {
    /// Natural language search
    Semantic(String),
    /// Exact keyword match
    Keyword(Vec<String>),
    /// Time-based query
    Temporal(TimeRange),
    /// Tag-based search
    Tagged(Vec<String>),
    /// Combined query
    Combined(Box<MemoryQuery>, Box<MemoryQuery>),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TimeRange {
    pub start: Option<DateTime<Utc>>,
    pub end: Option<DateTime<Utc>>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Filter {
    MemoryType(String),
    MinImportance(f32),
    MaxAge(chrono::Duration),
    HasTag(String),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum SortOrder {
    Relevance,
    Recency,
    Importance,
}

#[derive(Debug, Error)]
pub enum ParseError {
    #[error("Invalid query syntax: {0}")]
    InvalidSyntax(String),
    
    #[error("Unknown time expression: {0}")]
    InvalidTimeExpression(String),
    
    #[error("Empty query")]
    EmptyQuery,
}

pub struct QueryParser {
    time_regex: Regex,
    tag_regex: Regex,
}

impl QueryParser {
    pub fn new() -> Self {
        Self {
            time_regex: Regex::new(r"(yesterday|today|last week|last month)").unwrap(),
            tag_regex: Regex::new(r"#(\w+)").unwrap(),
        }
    }
    
    pub fn parse(&self, input: &str) -> Result<MemoryQuery, ParseError> {
        if input.trim().is_empty() {
            return Err(ParseError::EmptyQuery);
        }
        
        let mut filters = Vec::new();
        let mut query_text = input.to_string();
        
        // Extract tags
        let tags: Vec<String> = self.tag_regex
            .captures_iter(input)
            .map(|cap| cap[1].to_string())
            .collect();
        
        if !tags.is_empty() {
            for tag in &tags {
                filters.push(Filter::HasTag(tag.clone()));
                query_text = query_text.replace(&format!("#{}", tag), "");
            }
        }
        
        // Parse time expressions
        if let Some(time_match) = self.time_regex.find(input) {
            let time_range = self.parse_time_expression(time_match.as_str())?;
            query_text = query_text.replace(time_match.as_str(), "");
            
            return Ok(MemoryQuery {
                query_type: QueryType::Temporal(time_range),
                filters,
                limit: Some(10),
                sort_by: SortOrder::Recency,
            });
        }
        
        // Default to semantic search
        Ok(MemoryQuery {
            query_type: QueryType::Semantic(query_text.trim().to_string()),
            filters,
            limit: Some(10),
            sort_by: SortOrder::Relevance,
        })
    }
    
    fn parse_time_expression(&self, expr: &str) -> Result<TimeRange, ParseError> {
        let now = Utc::now();
        
        match expr {
            "today" => Ok(TimeRange {
                start: Some(now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc()),
                end: Some(now),
            }),
            "yesterday" => Ok(TimeRange {
                start: Some((now - chrono::Duration::days(1)).date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc()),
                end: Some(now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc()),
            }),
            "last week" => Ok(TimeRange {
                start: Some(now - chrono::Duration::weeks(1)),
                end: Some(now),
            }),
            "last month" => Ok(TimeRange {
                start: Some(now - chrono::Duration::days(30)),
                end: Some(now),
            }),
            _ => Err(ParseError::InvalidTimeExpression(expr.to_string())),
        }
    }
}
Create src/memory_search.rs:
use crate::query_parser::{MemoryQuery, QueryType, Filter, SortOrder};
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memory {
    pub id: String,
    pub content: String,
    pub memory_type: String,
    pub timestamp: DateTime<Utc>,
    pub importance: f32,
    pub tags: Vec<String>,
    pub metadata: serde_json::Value,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
    pub memories: Vec<Memory>,
    pub total_count: usize,
    pub query_time_ms: u64,
}

pub struct MemorySearcher;

impl MemorySearcher {
    pub fn new() -> Self {
        Self
    }
    
    pub fn search(&self, query: &MemoryQuery, memories: &[Memory]) -> SearchResult {
        let start_time = std::time::Instant::now();
        
        // Filter memories
        let mut filtered: Vec<Memory> = memories
            .iter()
            .filter(|mem| self.apply_filters(mem, &query.filters))
            .cloned()
            .collect();
        
        // Apply query type specific logic
        filtered = match &query.query_type {
            QueryType::Semantic(text) => self.semantic_search(&filtered, text),
            QueryType::Keyword(keywords) => self.keyword_search(&filtered, keywords),
            QueryType::Temporal(range) => self.temporal_search(&filtered, range),
            QueryType::Tagged(tags) => self.tag_search(&filtered, tags),
            QueryType::Combined(q1, q2) => {
                let r1 = self.search(q1, memories);
                let r2 = self.search(q2, memories);
                self.combine_results(r1.memories, r2.memories)
            }
        };
        
        // Sort results
        self.sort_memories(&mut filtered, &query.sort_by);
        
        // Apply limit
        let total_count = filtered.len();
        if let Some(limit) = query.limit {
            filtered.truncate(limit);
        }
        
        SearchResult {
            memories: filtered,
            total_count,
            query_time_ms: start_time.elapsed().as_millis() as u64,
        }
    }
    
    fn apply_filters(&self, memory: &Memory, filters: &[Filter]) -> bool {
        filters.iter().all(|filter| match filter {
            Filter::MemoryType(t) => memory.memory_type == *t,
            Filter::MinImportance(min) => memory.importance >= *min,
            Filter::MaxAge(duration) => {
                let age = Utc::now() - memory.timestamp;
                age <= *duration
            }
            Filter::HasTag(tag) => memory.tags.contains(tag),
        })
    }
    
    fn semantic_search(&self, memories: &[Memory], query: &str) -> Vec<Memory> {
        // Simple keyword matching for now
        // In production, this would use embeddings and vector similarity
        let query_lower = query.to_lowercase();
        memories
            .iter()
            .filter(|mem| mem.content.to_lowercase().contains(&query_lower))
            .cloned()
            .collect()
    }
    
    fn keyword_search(&self, memories: &[Memory], keywords: &[String]) -> Vec<Memory> {
        memories
            .iter()
            .filter(|mem| {
                let content_lower = mem.content.to_lowercase();
                keywords.iter().any(|kw| content_lower.contains(&kw.to_lowercase()))
            })
            .cloned()
            .collect()
    }
    
    fn temporal_search(&self, memories: &[Memory], range: &crate::query_parser::TimeRange) -> Vec<Memory> {
        memories
            .iter()
            .filter(|mem| {
                if let Some(start) = range.start {
                    if mem.timestamp < start {
                        return false;
                    }
                }
                if let Some(end) = range.end {
                    if mem.timestamp > end {
                        return false;
                    }
                }
                true
            })
            .cloned()
            .collect()
    }
    
    fn tag_search(&self, memories: &[Memory], tags: &[String]) -> Vec<Memory> {
        memories
            .iter()
            .filter(|mem| tags.iter().any(|tag| mem.tags.contains(tag)))
            .cloned()
            .collect()
    }
    
    fn sort_memories(&self, memories: &mut [Memory], order: &SortOrder) {
        match order {
            SortOrder::Relevance => {
                // Already sorted by relevance from search
            }
            SortOrder::Recency => {
                memories.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
            }
            SortOrder::Importance => {
                memories.sort_by(|a, b| b.importance.partial_cmp(&a.importance).unwrap());
            }
        }
    }
    
    fn combine_results(&self, r1: Vec<Memory>, r2: Vec<Memory>) -> Vec<Memory> {
        let mut combined = r1;
        for mem in r2 {
            if !combined.iter().any(|m| m.id == mem.id) {
                combined.push(mem);
            }
        }
        combined
    }
}

Step 5: Implement the Skill

Update src/lib.rs:
mod query_parser;
mod memory_search;

use soul_kernel_skill_abi::{
    export_skill, Capability, Skill, SkillError, SkillInput, SkillOutput,
};
use query_parser::{QueryParser, MemoryQuery};
use memory_search::{MemorySearcher, Memory, SearchResult};
use serde_json::json;

pub struct MemoryQuerySkill {
    parser: QueryParser,
    searcher: MemorySearcher,
}

impl MemoryQuerySkill {
    pub fn new() -> Self {
        Self {
            parser: QueryParser::new(),
            searcher: MemorySearcher::new(),
        }
    }
    
    fn format_search_results(&self, results: &SearchResult) -> String {
        if results.memories.is_empty() {
            return "I don't have any memories matching your query.".to_string();
        }
        
        let mut output = format!(
            "Found {} memories (showing {}):\n\n",
            results.total_count,
            results.memories.len()
        );
        
        for (i, memory) in results.memories.iter().enumerate() {
            output.push_str(&format!(
                "{}. [{}] {}\n   Tags: {}\n   Importance: {:.1}\n\n",
                i + 1,
                memory.timestamp.format("%Y-%m-%d %H:%M"),
                memory.content,
                memory.tags.join(", "),
                memory.importance
            ));
        }
        
        output.push_str(&format!("Query completed in {}ms", results.query_time_ms));
        output
    }
}

impl Skill for MemoryQuerySkill {
    fn name(&self) -> &str {
        "memory-query"
    }
    
    fn version(&self) -> &str {
        env!("CARGO_PKG_VERSION")
    }
    
    fn capabilities(&self) -> Vec<Capability> {
        vec![
            Capability::TextProcessing,
            Capability::DataStorage,
            Capability::Custom("memory-search".to_string()),
        ]
    }
    
    fn execute(&self, input: SkillInput) -> Result<SkillOutput, SkillError> {
        match input {
            SkillInput::Text(query_text) => {
                // Parse the query
                let query = self.parser.parse(&query_text)
                    .map_err(|e| SkillError::InvalidInput(e.to_string()))?;
                
                // In a real implementation, this would fetch from the actual memory graph
                // For now, we'll return a mock response
                let mock_memories = vec![
                    Memory {
                        id: "mem_001".to_string(),
                        content: "Discussed the architecture of Soul Kernel and its plugin system".to_string(),
                        memory_type: "conversation".to_string(),
                        timestamp: chrono::Utc::now() - chrono::Duration::hours(2),
                        importance: 0.8,
                        tags: vec!["technical".to_string(), "architecture".to_string()],
                        metadata: json!({}),
                    },
                    Memory {
                        id: "mem_002".to_string(),
                        content: "User mentioned they're building a robotics project".to_string(),
                        memory_type: "user_info".to_string(),
                        timestamp: chrono::Utc::now() - chrono::Duration::days(1),
                        importance: 0.9,
                        tags: vec!["robotics".to_string(), "project".to_string()],
                        metadata: json!({"project_type": "autonomous_robot"}),
                    },
                ];
                
                let results = self.searcher.search(&query, &mock_memories);
                let formatted = self.format_search_results(&results);
                
                Ok(SkillOutput::Text(formatted))
            }
            SkillInput::Json(json_input) => {
                // Support structured queries
                let query: MemoryQuery = serde_json::from_value(json_input)
                    .map_err(|e| SkillError::InvalidInput(e.to_string()))?;
                
                // Mock search (same as above)
                let mock_memories = vec![];
                let results = self.searcher.search(&query, &mock_memories);
                
                Ok(SkillOutput::Json(serde_json::to_value(results).unwrap()))
            }
            _ => Err(SkillError::InvalidInput(
                "Memory query skill expects text or JSON input".to_string()
            ))
        }
    }
}

// Export the skill
export_skill!(MemoryQuerySkill);

Step 6: Write Tests

Create comprehensive tests in tests/integration_test.rs:
use memory_query_skill::MemoryQuerySkill;
use soul_kernel_skill_abi::{Skill, SkillInput, SkillOutput};

#[test]
fn test_basic_query() {
    let skill = MemoryQuerySkill::new();
    
    let input = SkillInput::Text("What do you remember about robotics?".to_string());
    let output = skill.execute(input).unwrap();
    
    match output {
        SkillOutput::Text(text) => {
            assert!(text.contains("memories") || text.contains("don't have any"));
        }
        _ => panic!("Expected text output"),
    }
}

#[test]
fn test_temporal_query() {
    let skill = MemoryQuerySkill::new();
    
    let input = SkillInput::Text("What happened yesterday?".to_string());
    let output = skill.execute(input).unwrap();
    
    assert!(matches!(output, SkillOutput::Text(_)));
}

#[test]
fn test_tag_query() {
    let skill = MemoryQuerySkill::new();
    
    let input = SkillInput::Text("Show me memories tagged with #important".to_string());
    let output = skill.execute(input).unwrap();
    
    assert!(matches!(output, SkillOutput::Text(_)));
}

#[test]
fn test_structured_query() {
    let skill = MemoryQuerySkill::new();
    
    let query = serde_json::json!({
        "query_type": {
            "Semantic": "robotics"
        },
        "filters": [
            {
                "MinImportance": 0.7
            }
        ],
        "limit": 5,
        "sort_by": "Importance"
    });
    
    let input = SkillInput::Json(query);
    let output = skill.execute(input).unwrap();
    
    match output {
        SkillOutput::Json(result) => {
            assert!(result.get("memories").is_some());
            assert!(result.get("total_count").is_some());
        }
        _ => panic!("Expected JSON output"),
    }
}

Step 7: Create a Demo

Create examples/demo.rs:
use memory_query_skill::MemoryQuerySkill;
use soul_kernel_skill_abi::{Skill, SkillInput, SkillOutput};

fn main() {
    let skill = MemoryQuerySkill::new();
    
    println!("Memory Query Skill Demo");
    println!("======================\n");
    
    // Example queries
    let queries = vec![
        "What do you remember about our conversations?",
        "Show me memories from yesterday",
        "Find memories tagged with #important",
        "What did we discuss about robotics?",
    ];
    
    for query in queries {
        println!("Query: {}", query);
        println!("---");
        
        let input = SkillInput::Text(query.to_string());
        match skill.execute(input) {
            Ok(SkillOutput::Text(response)) => {
                println!("{}\n", response);
            }
            Ok(_) => println!("Unexpected output format\n"),
            Err(e) => println!("Error: {}\n", e),
        }
    }
}
Run the demo:
cargo run --example demo

Step 8: Build and Test

# Run all tests
soul test skill

# Build the skill
soul build skill --release

# Test in REPL
soul repl --skill ./target/release/libmemory_query_skill.dylib
In the REPL:
> load memory-query
Loaded skill: memory-query v0.1.0

> exec memory-query "What happened yesterday?"
Found 1 memories (showing 1):

1. [2025-06-11 14:30] User mentioned they're building a robotics project
   Tags: robotics, project
   Importance: 0.9

Query completed in 2ms

> exec memory-query "Show me #technical memories"
Found 1 memories (showing 1):

1. [2025-06-12 13:00] Discussed the architecture of Soul Kernel and its plugin system
   Tags: technical, architecture
   Importance: 0.8

Query completed in 1ms

Step 9: Package and Deploy

# Package the skill
soul package skill

# Install locally
soul install skill ./memory-query-skill-0.1.0.skill --dev

# Verify installation
soul info skill memory-query

Next Steps

Now that you have a working Memory Query Skill:
  1. Enhance the Parser: Add support for more natural language patterns
  2. Implement Vector Search: Use embeddings for semantic similarity
  3. Add Memory Graph Integration: Connect to the actual Soul memory storage
  4. Create Companion Skills: Build skills that write memories, not just read them
  5. Add Async Support: Make database queries non-blocking

Advanced Features to Explore

  • Conversation Context: Track conversation flow in memories
  • Memory Importance: Auto-calculate importance based on usage
  • Privacy Filters: Respect user privacy preferences
  • Export Formats: Support different output formats (Markdown, JSON, etc.)
  • Memory Visualization: Create visual representations of memory connections

Conclusion

You’ve created a practical skill that demonstrates:
  • ✅ Natural language query parsing
  • ✅ Multiple search strategies
  • ✅ Structured data handling
  • ✅ Error handling
  • ✅ Performance considerations
  • ✅ Real-world utility
This Memory Query Skill can be immediately integrated into any Soul to provide memory search capabilities, making it a perfect addition to the Soul Kernel v0.1 demo!

Change Log

  • 2025-06-12: Created complete Memory Query Skill tutorial
    • Practical example for v0.1 demo
    • Full implementation with query parsing
    • Comprehensive testing approach
    • Integration with Soul CLI