use rusqlite::{params, Connection};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct MemoryEntry {
pub id: i64,
pub target: String,
pub content: String,
pub category: String,
pub importance: i32,
pub access_count: i32,
pub tags: Vec<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillEntry {
pub id: i64,
pub name: String,
pub description: String,
pub content: String,
pub category: String,
pub version: i32,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LessonEntry {
pub id: i64,
pub trigger: String,
pub fix: String,
pub context: String,
pub resolved: bool,
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EphemeralEntry {
pub topic: String,
pub detail: String,
pub turn: u64,
}
pub fn auto_category(content: &str, target: &str) -> &'static str {
let lower = content.to_lowercase();
if target == "user" {
if lower.contains("name") || lower.contains("call me") || lower.contains("i'm ") {
return "identity";
}
if lower.contains("prefer") || lower.contains("like") || lower.contains("favorite")
|| lower.contains("love") || lower.contains("hate") || lower.contains("dislike")
|| lower.contains("use ") || lower.contains("using ")
{
return "preference";
}
if lower.contains("project") || lower.contains("work") || lower.contains("job")
|| lower.contains("company") || lower.contains("startup")
{
return "work";
}
return "personal";
}
if lower.contains("error") || lower.contains("bug") || lower.contains("fix")
|| lower.contains("crash") || lower.contains("fail")
{
return "error";
}
if lower.contains("api") || lower.contains("endpoint") || lower.contains("route")
|| lower.contains("http") || lower.contains("rest")
{
return "api";
}
if lower.contains("config") || lower.contains("setup") || lower.contains("install")
|| lower.contains("deploy") || lower.contains("docker")
{
return "configuration";
}
if lower.contains("project") || lower.contains("repo") || lower.contains("code")
|| lower.contains("function") || lower.contains("class") || lower.contains("module")
|| lower.contains("rust") || lower.contains("python") || lower.contains("javascript")
{
return "code";
}
if lower.contains("session") || lower.contains("today") || lower.contains("meeting")
|| lower.contains("discuss")
{
return "session";
}
"general"
}
pub fn extract_preferences(text: &str) -> Vec<(String, String, i32)> {
let lower = text.to_lowercase();
let mut prefs = Vec::new();
if let Some(pos) = lower.find("i use ") {
let after = &lower[pos + 6..];
if let Some(end) = after.find(|c: char| c == '.' || c == ',' || c == ';') {
let tool = after[..end].trim();
if !tool.is_empty() && tool.len() < 40 {
prefs.push(("preference".into(), format!("User uses {}", tool), 2));
}
}
}
for marker in &["i prefer ", "i like ", "i love ", "my favorite "] {
if let Some(pos) = lower.find(marker) {
let after = &lower[pos + marker.len()..];
if let Some(end) = after.find(|c: char| c == '.' || c == ',' || c == ';' || c == ' ') {
let thing = after[..end].trim();
if !thing.is_empty() && thing.len() < 30 && thing != "to" && thing != "it" {
let imp = if marker.contains("love") || marker.contains("favorite") { 4 } else { 2 };
prefs.push(("preference".into(), format!("User prefers {}", thing), imp));
}
}
}
}
for marker in &["i work on ", "my project is ", "i'm building "] {
if let Some(pos) = lower.find(marker) {
let after = &lower[pos + marker.len()..];
if let Some(end) = after.find(|c: char| c == '.' || c == ',' || c == ';') {
let thing = after[..end].trim();
if !thing.is_empty() && thing.len() < 40 {
prefs.push(("work".into(), format!("User project: {}", thing), 3));
}
}
}
}
prefs
}
pub fn keyword_overlap(a: &str, b: &str) -> f64 {
let words_a: HashSet<String> = a
.to_lowercase()
.split_whitespace()
.filter(|w| w.len() > 2)
.map(|w| {
w.trim_matches(|c: char| !c.is_alphanumeric())
.to_string()
})
.filter(|w| !w.is_empty())
.collect();
let words_b: HashSet<String> = b
.to_lowercase()
.split_whitespace()
.filter(|w| w.len() > 2)
.map(|w| {
w.trim_matches(|c: char| !c.is_alphanumeric())
.to_string()
})
.filter(|w| !w.is_empty())
.collect();
if words_a.is_empty() || words_b.is_empty() {
return 0.0;
}
let intersection: usize = words_a.intersection(&words_b).count();
let min_len = words_a.len().min(words_b.len());
intersection as f64 / min_len as f64
}
pub struct MemoryStore {
pub db_path: String,
}
impl MemoryStore {
pub fn new(db_dir: &str, db_name: &str) -> anyhow::Result<Self> {
let dir = Path::new(db_dir);
std::fs::create_dir_all(dir)?;
let db_path = dir.join(db_name).to_string_lossy().to_string();
let store = Self { db_path };
store.init_schema()?;
let _ = store.seed_default_skills();
Ok(store)
}
fn conn(&self) -> anyhow::Result<Connection> {
let conn = Connection::open(&self.db_path)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;")?;
Ok(conn)
}
fn init_schema(&self) -> anyhow::Result<()> {
let conn = self.conn()?;
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS memories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
target TEXT NOT NULL DEFAULT 'memory',
content TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
importance INTEGER NOT NULL DEFAULT 1,
access_count INTEGER NOT NULL DEFAULT 1,
tags TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS skills (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
version INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS lessons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
trigger TEXT NOT NULL,
fix TEXT NOT NULL DEFAULT '',
context TEXT NOT NULL DEFAULT '',
resolved INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_memories_target ON memories(target);
CREATE INDEX IF NOT EXISTS idx_memories_cat ON memories(category);
CREATE INDEX IF NOT EXISTS idx_memories_content ON memories(content);
CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name);
CREATE INDEX IF NOT EXISTS idx_skills_cat ON skills(category);
CREATE INDEX IF NOT EXISTS idx_lessons_trigger ON lessons(trigger);
CREATE TABLE IF NOT EXISTS embeddings (
memory_id INTEGER PRIMARY KEY,
vector BLOB NOT NULL,
model TEXT NOT NULL DEFAULT '',
FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE CASCADE
);
",
)?;
let has_access: bool = conn
.prepare("SELECT access_count FROM memories LIMIT 0")
.is_ok();
if !has_access {
conn.execute(
"ALTER TABLE memories ADD COLUMN access_count INTEGER NOT NULL DEFAULT 1",
[],
)?;
}
let has_updated: bool = conn
.prepare("SELECT updated_at FROM memories LIMIT 0")
.is_ok();
if !has_updated {
conn.execute(
"ALTER TABLE memories ADD COLUMN updated_at TEXT NOT NULL DEFAULT ''",
[],
)?;
conn.execute(
"UPDATE memories SET updated_at = created_at WHERE updated_at = ''",
[],
)?;
}
Ok(())
}
pub fn seed_default_skills(&self) -> anyhow::Result<()> {
let count = self.list_skills(None)?.len();
if count > 0 {
return Ok(()); }
let skills = vec![
(
"cortex-superpower",
"Power-user guide for Cortex CLI — commands, tools, workflows",
r#"# Cortex Superpower Guide
## Quick Start
Run `cortex` to start an interactive session. Type `/help` to see all commands.
## Essential Commands
- `/help <cmd>` — Detailed help for any command
- `/tools` — See all available tools the agent can use
- `/memory list|tree|timeline|stats` — Browse saved memories
- `/skills add` — Create a reusable skill interactively
- `/theme mocha|latte` — Switch between dark/light themes
- `/plugin list|install <url>` — Manage plugins
- `/provider` — Interactive provider/model picker
- `/stats` — Session token/cost summary
## Agent Tools (the LLM can use these)
- `web_search` — Search the web via DuckDuckGo
- `web_fetch` — Fetch a URL's content
- `execute_python` — Run Python code in a subprocess
- `execute_bash` — Run shell commands
- `read_file` / `write_file` — File operations
- `git_status` / `git_diff` — Git integration
- `save_memory` / `search_memory` — Persistent memory
- `create_skill` / `update_skill` — Manage skills
## Plugins
Drop `.yaml` files in `~/.cortex/plugins/` to add custom tools.
Or install from GitHub: `/plugin install https://github.com/user/repo`
## Tips
- Cortex learns your preferences automatically
- Use `/memory` to see what Cortex remembers about you
- Memories auto-decay if unused for 7+ days
- Ctrl+C interrupts the current response gracefully
"#,
"getting-started",
),
(
"code-review",
"Code review workflow — inspect, analyze, suggest improvements",
r#"# Code Review Workflow
## When to use
When asked to review code, follow this workflow:
1. Read the file: `read_file(path=<file>)`
2. Check git diff: `git_diff(path=<repo>)`
3. Analyze for:
- Logic errors
- Performance issues
- Security concerns
- Style violations
4. Provide actionable feedback
5. Offer to fix with patch if appropriate
## Example
1. `read_file(path="src/main.rs", offset=1, limit=50)`
2. `git_diff(path=".")`
3. Summarize findings with line references
"#,
"workflow",
),
];
for (name, description, content, category) in skills {
if self.get_skill(name)?.is_none() {
self.save_skill(name, description, content, category)?;
}
}
Ok(())
}
pub fn save_memory(
&self,
target: &str,
content: &str,
category: &str,
importance: i32,
tags: &[String],
) -> anyhow::Result<i64> {
let conn = self.conn()?;
let tags_json = serde_json::to_string(tags)?;
let effective_cat = if category.is_empty() || category == "general" {
auto_category(content, target)
} else {
category
};
let existing: Option<(i64, i32)> = conn
.query_row(
"SELECT id, access_count FROM memories WHERE content = ?1",
params![content],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
if let Some((eid, count)) = existing {
conn.execute(
"UPDATE memories SET access_count = ?1, importance = MAX(importance, ?2), updated_at = datetime('now') WHERE id = ?3",
params![count + 1, importance, eid],
)?;
return Ok(eid);
}
let all = self.list_memories(Some(target), 50)?;
for existing_entry in &all {
let score = keyword_overlap(&existing_entry.content, content);
if score > 0.6 {
let merged_content = if content.len() > existing_entry.content.len() {
content.to_string()
} else {
existing_entry.content.clone()
};
let merged_imp = existing_entry.importance.max(importance) + 1;
let mut merged_tags: Vec<String> = existing_entry.tags.clone();
for t in tags {
if !merged_tags.contains(t) {
merged_tags.push(t.clone());
}
}
let merged_tags_json = serde_json::to_string(&merged_tags)?;
conn.execute(
"UPDATE memories SET content = ?1, importance = ?2, tags = ?3, access_count = access_count + 1, updated_at = datetime('now') WHERE id = ?4",
params![merged_content, merged_imp, merged_tags_json, existing_entry.id],
)?;
return Ok(existing_entry.id);
}
}
conn.execute(
"INSERT INTO memories (target, content, category, importance, access_count, tags)
VALUES (?1, ?2, ?3, ?4, 1, ?5)",
params![target, content, effective_cat, importance.clamp(1, 5), tags_json],
)?;
Ok(conn.last_insert_rowid())
}
pub fn search_memories(&self, query: &str, limit: i64) -> anyhow::Result<Vec<MemoryEntry>> {
let conn = self.conn()?;
let pattern = format!("%{}%", query);
let mut stmt = conn.prepare(
"SELECT id, target, content, category, importance, access_count, tags, created_at, updated_at
FROM memories
WHERE content LIKE ?1 OR tags LIKE ?1 OR category LIKE ?1
ORDER BY importance DESC, access_count DESC, created_at DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![pattern, limit * 3], |row| {
let tags_str: String = row.get(6)?;
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(MemoryEntry {
id: row.get(0)?,
target: row.get(1)?,
content: row.get(2)?,
category: row.get(3)?,
importance: row.get(4)?,
access_count: row.get(5)?,
tags,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
})?;
let mut candidates: Vec<MemoryEntry> = Vec::new();
for row in rows {
candidates.push(row?);
}
let query_keywords: HashSet<String> = query
.to_lowercase()
.split_whitespace()
.filter(|w| w.len() > 2)
.map(|w| w.to_string())
.collect();
let mut scored: Vec<(f64, usize)> = candidates
.iter()
.enumerate()
.map(|(idx, entry)| {
let content_lower = entry.content.to_lowercase();
let match_count = query_keywords
.iter()
.filter(|kw| content_lower.contains(kw.as_str()))
.count();
let score = if query_keywords.is_empty() {
0.0
} else {
match_count as f64 / query_keywords.len() as f64
};
let final_score = score * 10.0
+ (entry.importance as f64 * 0.5)
+ (entry.access_count as f64 * 0.05);
(final_score, idx)
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let mut result = Vec::new();
for (_, idx) in scored.iter().take(limit as usize) {
let mut entry = candidates[*idx].clone();
let _ = conn.execute(
"UPDATE memories SET access_count = access_count + 1, updated_at = datetime('now') WHERE id = ?1",
params![entry.id],
);
entry.access_count += 1;
result.push(entry);
}
Ok(result)
}
pub fn list_memories(
&self,
target: Option<&str>,
limit: i64,
) -> anyhow::Result<Vec<MemoryEntry>> {
let conn = self.conn()?;
let (sql, param_values): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
if let Some(t) = target {
(
"SELECT id, target, content, category, importance, access_count, tags, created_at, updated_at
FROM memories WHERE target = ?1
ORDER BY importance DESC, access_count DESC, created_at DESC LIMIT ?2"
.into(),
vec![Box::new(t.to_string()), Box::new(limit)],
)
} else {
(
"SELECT id, target, content, category, importance, access_count, tags, created_at, updated_at
FROM memories
ORDER BY importance DESC, access_count DESC, created_at DESC LIMIT ?1"
.into(),
vec![Box::new(limit)],
)
};
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
param_values.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(params_refs.as_slice(), |row| {
let tags_str: String = row.get(6)?;
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(MemoryEntry {
id: row.get(0)?,
target: row.get(1)?,
content: row.get(2)?,
category: row.get(3)?,
importance: row.get(4)?,
access_count: row.get(5)?,
tags,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
}
pub fn memory_stats(&self) -> anyhow::Result<HashMap<String, usize>> {
let conn = self.conn()?;
let mut stats = HashMap::new();
let total: i64 =
conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))?;
stats.insert("total".into(), total as usize);
let mut stmt =
conn.prepare("SELECT target, COUNT(*) FROM memories GROUP BY target ORDER BY 2 DESC")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for row in rows {
let (target, count) = row?;
stats.insert(format!("target:{}", target), count as usize);
}
let mut stmt =
conn.prepare("SELECT category, COUNT(*) FROM memories GROUP BY category ORDER BY 2 DESC")?;
let rows = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for row in rows {
let (cat, count) = row?;
stats.insert(format!("cat:{}", cat), count as usize);
}
for imp in 1..=5 {
let count: i64 = conn.query_row(
"SELECT COUNT(*) FROM memories WHERE importance = ?1",
params![imp],
|row| row.get(0),
)?;
if count > 0 {
stats.insert(format!("imp:{}", imp), count as usize);
}
}
Ok(stats)
}
pub fn delete_memory(&self, memory_id: i64) -> anyhow::Result<bool> {
let conn = self.conn()?;
let affected = conn.execute("DELETE FROM memories WHERE id = ?1", params![memory_id])?;
Ok(affected > 0)
}
pub fn save_embedding(&self, memory_id: i64, vector: &[f32], model: &str) -> anyhow::Result<()> {
let conn = self.conn()?;
let bytes: Vec<u8> = vector
.iter()
.flat_map(|f| f.to_le_bytes())
.collect();
conn.execute(
"INSERT OR REPLACE INTO embeddings (memory_id, vector, model) VALUES (?1, ?2, ?3)",
params![memory_id, bytes, model],
)?;
Ok(())
}
pub fn vector_search(&self, query_vec: &[f32], limit: i64) -> anyhow::Result<Vec<MemoryEntry>> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT m.id, m.target, m.content, m.category, m.importance, m.access_count,
m.tags, m.created_at, m.updated_at, e.vector
FROM memories m
INNER JOIN embeddings e ON e.memory_id = m.id
WHERE LENGTH(e.vector) = ?1",
)?;
let expected_len = (query_vec.len() * 4) as i64;
let rows = stmt.query_map(params![expected_len], |row| {
let tags_str: String = row.get(6)?;
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
let vec_blob: Vec<u8> = row.get(9)?;
let stored: Vec<f32> = vec_blob
.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect();
Ok((MemoryEntry {
id: row.get(0)?,
target: row.get(1)?,
content: row.get(2)?,
category: row.get(3)?,
importance: row.get(4)?,
access_count: row.get(5)?,
tags,
created_at: row.get(7)?,
updated_at: row.get(8)?,
}, stored))
})?;
let mut scored: Vec<(f64, MemoryEntry)> = rows
.filter_map(|r| r.ok())
.map(|(entry, stored_vec)| {
let sim = cosine_similarity(query_vec, &stored_vec);
(sim, entry)
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
Ok(scored.into_iter().take(limit as usize).map(|(_, e)| e).collect())
}
pub fn count_memories(&self) -> anyhow::Result<i64> {
let conn = self.conn()?;
conn.query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))
.map_err(Into::into)
}
pub fn decay_old_memories(&self) -> anyhow::Result<usize> {
let conn = self.conn()?;
let decayed = conn.execute(
"UPDATE memories SET importance = MAX(1, importance - 1)
WHERE updated_at < datetime('now', '-7 days') AND importance > 1",
[],
)?;
let purged = conn.execute(
"DELETE FROM memories WHERE importance <= 1 AND updated_at < datetime('now', '-30 days')",
[],
)?;
Ok(decayed + purged)
}
pub fn recent_memories(&self, limit: i64) -> anyhow::Result<Vec<MemoryEntry>> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT id, target, content, category, importance, access_count, tags, created_at, updated_at
FROM memories
ORDER BY created_at DESC
LIMIT ?1",
)?;
let rows = stmt.query_map(params![limit], |row| {
let tags_str: String = row.get(6)?;
let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
Ok(MemoryEntry {
id: row.get(0)?,
target: row.get(1)?,
content: row.get(2)?,
category: row.get(3)?,
importance: row.get(4)?,
access_count: row.get(5)?,
tags,
created_at: row.get(7)?,
updated_at: row.get(8)?,
})
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
}
pub fn save_skill(
&self,
name: &str,
description: &str,
content: &str,
category: &str,
) -> anyhow::Result<i64> {
let conn = self.conn()?;
conn.execute(
"INSERT INTO skills (name, description, content, category) VALUES (?1, ?2, ?3, ?4)",
params![name, description, content, category],
)?;
Ok(conn.last_insert_rowid())
}
pub fn get_skill(&self, name: &str) -> anyhow::Result<Option<SkillEntry>> {
let conn = self.conn()?;
let mut stmt = conn.prepare(
"SELECT id, name, description, content, category, version, created_at, updated_at
FROM skills WHERE name = ?1",
)?;
let mut rows = stmt.query_map(params![name], |row| {
Ok(SkillEntry {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
content: row.get(3)?,
category: row.get(4)?,
version: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
})?;
match rows.next() {
Some(Ok(entry)) => Ok(Some(entry)),
Some(Err(e)) => Err(e.into()),
None => Ok(None),
}
}
pub fn update_skill(
&self,
name: &str,
description: Option<&str>,
content: Option<&str>,
category: Option<&str>,
) -> anyhow::Result<bool> {
let conn = self.conn()?;
let mut updates = Vec::new();
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
if let Some(d) = description {
updates.push("description = ?");
param_values.push(Box::new(d.to_string()));
}
if let Some(c) = content {
updates.push("content = ?");
param_values.push(Box::new(c.to_string()));
updates.push("version = version + 1");
}
if let Some(c) = category {
updates.push("category = ?");
param_values.push(Box::new(c.to_string()));
}
if updates.is_empty() {
return Ok(false);
}
updates.push("updated_at = datetime('now')");
param_values.push(Box::new(name.to_string()));
let sql = format!("UPDATE skills SET {} WHERE name = ?", updates.join(", "));
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
param_values.iter().map(|p| p.as_ref()).collect();
let affected = conn.execute(&sql, params_refs.as_slice())?;
Ok(affected > 0)
}
pub fn delete_skill(&self, name: &str) -> anyhow::Result<bool> {
let conn = self.conn()?;
let affected = conn.execute("DELETE FROM skills WHERE name = ?1", params![name])?;
Ok(affected > 0)
}
pub fn list_skills(&self, category: Option<&str>) -> anyhow::Result<Vec<SkillEntry>> {
let conn = self.conn()?;
let (sql, param_values): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
if let Some(cat) = category {
(
"SELECT id, name, description, content, category, version, created_at, updated_at
FROM skills WHERE category = ?1 ORDER BY name"
.into(),
vec![Box::new(cat.to_string())],
)
} else {
(
"SELECT id, name, description, content, category, version, created_at, updated_at
FROM skills ORDER BY name"
.into(),
vec![],
)
};
let mut stmt = conn.prepare(&sql)?;
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
param_values.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(params_refs.as_slice(), |row| {
Ok(SkillEntry {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
content: row.get(3)?,
category: row.get(4)?,
version: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
}
pub fn search_skills(&self, query: &str, limit: i64) -> anyhow::Result<Vec<SkillEntry>> {
let conn = self.conn()?;
let pattern = format!("%{}%", query);
let mut stmt = conn.prepare(
"SELECT id, name, description, content, category, version, created_at, updated_at
FROM skills
WHERE name LIKE ?1 OR description LIKE ?1 OR content LIKE ?1
ORDER BY updated_at DESC LIMIT ?2",
)?;
let rows = stmt.query_map(params![pattern, limit], |row| {
Ok(SkillEntry {
id: row.get(0)?,
name: row.get(1)?,
description: row.get(2)?,
content: row.get(3)?,
category: row.get(4)?,
version: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
}
pub fn save_lesson(
&self,
trigger: &str,
fix: &str,
context: &str,
resolved: bool,
) -> anyhow::Result<i64> {
let conn = self.conn()?;
conn.execute(
"INSERT INTO lessons (trigger, fix, context, resolved) VALUES (?1, ?2, ?3, ?4)",
params![trigger, fix, context, resolved as i32],
)?;
Ok(conn.last_insert_rowid())
}
pub fn search_lessons(&self, query: &str, limit: i64) -> anyhow::Result<Vec<LessonEntry>> {
let conn = self.conn()?;
let pattern = format!("%{}%", query);
let mut stmt = conn.prepare(
"SELECT id, trigger, fix, context, resolved, created_at
FROM lessons
WHERE trigger LIKE ?1 OR fix LIKE ?1 OR context LIKE ?1
ORDER BY resolved DESC, created_at DESC LIMIT ?2",
)?;
let rows = stmt.query_map(params![pattern, limit], |row| {
let resolved_int: i32 = row.get(4)?;
Ok(LessonEntry {
id: row.get(0)?,
trigger: row.get(1)?,
fix: row.get(2)?,
context: row.get(3)?,
resolved: resolved_int != 0,
created_at: row.get(5)?,
})
})?;
let mut result = Vec::new();
for row in rows {
result.push(row?);
}
Ok(result)
}
pub fn resolve_lesson(&self, lesson_id: i64, fix: &str) -> anyhow::Result<bool> {
let conn = self.conn()?;
let affected = conn.execute(
"UPDATE lessons SET resolved = 1, fix = ?1 WHERE id = ?2",
params![fix, lesson_id],
)?;
Ok(affected > 0)
}
}
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f64 {
if a.len() != b.len() || a.is_empty() {
return 0.0;
}
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm_a == 0.0 || norm_b == 0.0 {
return 0.0;
}
(dot / (norm_a * norm_b)) as f64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_memory_store_basics() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("cortex_test_memory");
let _ = std::fs::remove_dir_all(&dir);
let store = MemoryStore::new(dir.to_str().unwrap(), "test.db")?;
let id = store.save_memory("user", "likes Rust", "preference", 3, &[])?;
assert!(id > 0);
let results = store.search_memories("Rust", 10)?;
assert!(!results.is_empty());
assert_eq!(results[0].content, "likes Rust");
let list = store.list_memories(None, 10)?;
assert_eq!(list.len(), 1);
store.delete_memory(id)?;
assert_eq!(store.list_memories(None, 10)?.len(), 0);
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_skills() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("cortex_test_skills");
let _ = std::fs::remove_dir_all(&dir);
let store = MemoryStore::new(dir.to_str().unwrap(), "test.db")?;
store.save_skill("test-skill", "A test", "do something", "testing")?;
let s = store.get_skill("test-skill")?.unwrap();
assert_eq!(s.version, 1);
store.update_skill("test-skill", Some("Updated desc"), Some("do something else"), None)?;
let s = store.get_skill("test-skill")?.unwrap();
assert_eq!(s.version, 2);
assert_eq!(s.description, "Updated desc");
let list = store.list_skills(None)?;
assert!(list.iter().any(|s| s.name == "test-skill"), "test-skill should be in list");
store.delete_skill("test-skill")?;
assert!(store.get_skill("test-skill")?.is_none());
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_auto_category() {
assert_eq!(auto_category("my name is Shafiq", "user"), "identity");
assert_eq!(auto_category("I prefer dark mode", "user"), "preference");
assert_eq!(auto_category("working on startup project", "user"), "work");
assert_eq!(auto_category("this is an error bug", "memory"), "error");
assert_eq!(auto_category("REST API endpoint", "memory"), "api");
assert_eq!(auto_category("docker config setup", "memory"), "configuration");
assert_eq!(auto_category("just some code in Python", "memory"), "code");
assert_eq!(auto_category("hello world", "memory"), "general");
assert_eq!(auto_category("today's session was about", "memory"), "session");
}
#[test]
fn test_access_count() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("cortex_test_access");
let _ = std::fs::remove_dir_all(&dir);
let store = MemoryStore::new(dir.to_str().unwrap(), "test.db")?;
let id = store.save_memory("memory", "important fact", "general", 3, &[])?;
store.save_memory("memory", "important fact", "general", 3, &[])?;
let list = store.list_memories(None, 10)?;
assert_eq!(list[0].access_count, 2);
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_dedup_save_on_content() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("cortex_test_dedup");
let _ = std::fs::remove_dir_all(&dir);
let store = MemoryStore::new(dir.to_str().unwrap(), "test.db")?;
let id1 = store.save_memory("memory", "unique fact", "general", 3, &[])?;
let id2 = store.save_memory("memory", "unique fact", "general", 3, &[])?;
assert_eq!(id1, id2);
assert_eq!(store.list_memories(None, 10)?.len(), 1);
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_consolidation() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("cortex_test_consol");
let _ = std::fs::remove_dir_all(&dir);
let store = MemoryStore::new(dir.to_str().unwrap(), "test.db")?;
let id1 = store.save_memory("user", "user likes Python for coding", "preference", 3, &[])?;
let id2 = store.save_memory("user", "user likes Python language", "preference", 3, &[])?;
assert_eq!(id1, id2, "consolidation should return original id");
let list = store.list_memories(None, 10)?;
assert_eq!(list.len(), 1);
assert!(list[0].importance >= 4, "consolidated importance should be boosted");
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
#[test]
fn test_extract_preferences() {
let prefs = extract_preferences("I use Neovim for coding. I prefer dark mode.");
assert!(!prefs.is_empty(), "should find preferences");
assert!(prefs.iter().any(|(_, c, _)| c.to_lowercase().contains("neovim")), "should detect Neovim usage");
assert!(prefs.iter().any(|(_, c, _)| c.to_lowercase().contains("dark")), "should detect dark mode preference");
}
#[test]
fn test_keyword_overlap() {
let score = keyword_overlap(
"user likes Python for coding",
"user likes Python language",
);
assert!(score > 0.6, "overlap should be high: {}", score);
let low = keyword_overlap("hello world", "completely different topic");
assert!(low < 0.3, "overlap should be low: {}", low);
}
#[test]
fn test_memory_stats() -> anyhow::Result<()> {
let dir = std::env::temp_dir().join("cortex_test_stats");
let _ = std::fs::remove_dir_all(&dir);
let store = MemoryStore::new(dir.to_str().unwrap(), "test.db")?;
store.save_memory("user", "test preference", "preference", 3, &[])?;
store.save_memory("memory", "test error", "error", 2, &[])?;
let stats = store.memory_stats()?;
assert_eq!(stats.get("total"), Some(&2));
assert_eq!(stats.get("target:user"), Some(&1));
assert_eq!(stats.get("target:memory"), Some(&1));
assert_eq!(stats.get("cat:preference"), Some(&1));
assert_eq!(stats.get("cat:error"), Some(&1));
let _ = std::fs::remove_dir_all(&dir);
Ok(())
}
}