diff --git a/crates/zclaw-memory/src/schema.rs b/crates/zclaw-memory/src/schema.rs index 05fe709..abc71d9 100644 --- a/crates/zclaw-memory/src/schema.rs +++ b/crates/zclaw-memory/src/schema.rs @@ -71,4 +71,19 @@ CREATE INDEX IF NOT EXISTS idx_kv_agent ON kv_store(agent_id); CREATE INDEX IF NOT EXISTS idx_hand_runs_hand ON hand_runs(hand_name); CREATE INDEX IF NOT EXISTS idx_hand_runs_status ON hand_runs(status); CREATE INDEX IF NOT EXISTS idx_hand_runs_created ON hand_runs(created_at); + +-- Structured facts table (extracted from conversations) +CREATE TABLE IF NOT EXISTS facts ( + id TEXT PRIMARY KEY, + agent_id TEXT NOT NULL, + content TEXT NOT NULL, + category TEXT NOT NULL, + confidence REAL NOT NULL, + source_session TEXT, + created_at INTEGER NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_facts_agent ON facts(agent_id); +CREATE INDEX IF NOT EXISTS idx_facts_category ON facts(agent_id, category); +CREATE INDEX IF NOT EXISTS idx_facts_confidence ON facts(agent_id, confidence DESC); "#; diff --git a/crates/zclaw-memory/src/store.rs b/crates/zclaw-memory/src/store.rs index d506670..bbfe065 100644 --- a/crates/zclaw-memory/src/store.rs +++ b/crates/zclaw-memory/src/store.rs @@ -482,6 +482,76 @@ impl MemoryStore { Ok(count as u32) } + // === Fact CRUD === + + /// Store extracted facts for an agent (upsert by id). + pub async fn store_facts(&self, agent_id: &str, facts: &[crate::fact::Fact]) -> Result<()> { + for fact in facts { + let category_str = serde_json::to_string(&fact.category) + .map_err(|e| ZclawError::StorageError(e.to_string()))?; + // Trim the JSON quotes from serialized enum variant + let category_clean = category_str.trim_matches('"'); + + sqlx::query( + r#" + INSERT INTO facts (id, agent_id, content, category, confidence, source_session, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + content = excluded.content, + category = excluded.category, + confidence = excluded.confidence, + source_session = excluded.source_session + "#, + ) + .bind(&fact.id) + .bind(agent_id) + .bind(&fact.content) + .bind(category_clean) + .bind(fact.confidence) + .bind(&fact.source) + .bind(fact.created_at as i64) + .execute(&self.pool) + .await + .map_err(|e| ZclawError::StorageError(e.to_string()))?; + } + Ok(()) + } + + /// Get top facts for an agent, ordered by confidence descending. + pub async fn get_top_facts(&self, agent_id: &str, limit: usize) -> Result> { + let rows = sqlx::query_as::<_, (String, String, String, f64, Option, i64)>( + r#" + SELECT id, content, category, confidence, source_session, created_at + FROM facts + WHERE agent_id = ? + ORDER BY confidence DESC + LIMIT ? + "#, + ) + .bind(agent_id) + .bind(limit as i64) + .fetch_all(&self.pool) + .await + .map_err(|e| ZclawError::StorageError(e.to_string()))?; + + let mut facts = Vec::with_capacity(rows.len()); + for (id, content, category_str, confidence, source, created_at) in rows { + let category: crate::fact::FactCategory = serde_json::from_value( + serde_json::Value::String(category_str) + ).map_err(|e| ZclawError::StorageError(format!("Invalid category: {}", e)))?; + + facts.push(crate::fact::Fact { + id, + content, + category, + confidence, + created_at: created_at as u64, + source, + }); + } + Ok(facts) + } + fn row_to_hand_run( row: (String, String, String, String, String, Option, Option, Option, String, Option, Option), ) -> Result { @@ -527,10 +597,13 @@ mod tests { description: None, model: ModelConfig::default(), system_prompt: None, + soul: None, capabilities: vec![], tools: vec![], max_tokens: None, temperature: None, + workspace: None, + compaction_threshold: None, enabled: true, } }