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
- “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
Copy
# Create a new skill using the cognitive template
soul new skill memory-query --template cognitive
cd memory-query-skill
Copy
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
EditCargo.toml to add memory graph dependencies:
Copy
[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
Createsrc/query_parser.rs:
Copy
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())),
}
}
}
Step 4: Implement Memory Search
Createsrc/memory_search.rs:
Copy
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
Updatesrc/lib.rs:
Copy
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 intests/integration_test.rs:
Copy
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
Createexamples/demo.rs:
Copy
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),
}
}
}
Copy
cargo run --example demo
Step 8: Build and Test
Copy
# 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
Copy
> 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
Copy
# 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:- Enhance the Parser: Add support for more natural language patterns
- Implement Vector Search: Use embeddings for semantic similarity
- Add Memory Graph Integration: Connect to the actual Soul memory storage
- Create Companion Skills: Build skills that write memories, not just read them
- 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
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