feat(memory): implement FactStore SQLite persistence
Add `facts` table to schema with columns for id, agent_id, content, category, confidence, source_session, and created_at. Implement store_facts() and get_top_facts() on MemoryStore using upsert-by-id and confidence-desc ordering. Facts extracted from conversations are now durable across sessions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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_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_status ON hand_runs(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_hand_runs_created ON hand_runs(created_at);
|
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);
|
||||||
"#;
|
"#;
|
||||||
|
|||||||
@@ -482,6 +482,76 @@ impl MemoryStore {
|
|||||||
Ok(count as u32)
|
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<Vec<crate::fact::Fact>> {
|
||||||
|
let rows = sqlx::query_as::<_, (String, String, String, f64, Option<String>, 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(
|
fn row_to_hand_run(
|
||||||
row: (String, String, String, String, String, Option<String>, Option<String>, Option<i64>, String, Option<String>, Option<String>),
|
row: (String, String, String, String, String, Option<String>, Option<String>, Option<i64>, String, Option<String>, Option<String>),
|
||||||
) -> Result<HandRun> {
|
) -> Result<HandRun> {
|
||||||
@@ -527,10 +597,13 @@ mod tests {
|
|||||||
description: None,
|
description: None,
|
||||||
model: ModelConfig::default(),
|
model: ModelConfig::default(),
|
||||||
system_prompt: None,
|
system_prompt: None,
|
||||||
|
soul: None,
|
||||||
capabilities: vec![],
|
capabilities: vec![],
|
||||||
tools: vec![],
|
tools: vec![],
|
||||||
max_tokens: None,
|
max_tokens: None,
|
||||||
temperature: None,
|
temperature: None,
|
||||||
|
workspace: None,
|
||||||
|
compaction_threshold: None,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user