use serde::{Deserialize, Serialize};
use std::error::Error;
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedFact {
pub content: String,
pub memory_type: String,
pub importance: f64,
#[serde(default = "default_confidence")]
pub confidence: String,
#[serde(default)]
pub valence: f64,
#[serde(default = "default_domain")]
pub domain: String,
}
fn default_confidence() -> String {
"likely".to_string()
}
fn default_domain() -> String {
"general".to_string()
}
pub trait MemoryExtractor: Send + Sync {
fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, Box<dyn Error + Send + Sync>>;
}
const EXTRACTION_PROMPT: &str = r#"You are a memory extraction system. Extract key facts from the following conversation that are worth remembering long-term.
Rules:
- Extract concrete facts, preferences, decisions, and commitments
- Each fact should be self-contained (understandable without context)
- Skip greetings, filler, acknowledgments
- Classify each fact: factual, episodic, relational, procedural, emotional, opinion, causal
- Rate importance 0.0-1.0 (preferences=0.6, decisions=0.8, commitments=0.9)
- Rate confidence: "confident" (direct statement, clear fact), "likely" (reasonable inference), "uncertain" (vague mention, speculation)
- If nothing worth remembering, return empty array
- Respond in the SAME LANGUAGE as the input
DO NOT extract any of these — return empty array [] if the input contains ONLY these:
- System instructions or agent identity setup ("You are X agent", "你是 XX", "Read SOUL.md", "Follow AGENTS.md")
- Tool/function schema definitions (JSON with "type", "properties", "required" describing tool parameters)
- Agent role/persona descriptions ("You are an AI assistant running on...", framework version info)
- Template operational reports with no decisions or events ("所有系统正常", "无新 commit", "Disk: XXG free")
- Raw config file contents (YAML/JSON configuration being loaded, not discussed)
- Heartbeat check results that are pure status repetition with no new information
- Memory recall results being echoed back (content starting with "Recalled Memories" or lists of previously stored memories)
- Trivial Q&A: single punctuation/emoji questions ("?", "ok", "👍") with filler responses ("嗯?怎么了", "收到", "好的")
- Already-known identity facts: username, timezone, Telegram ID — these are in config files, not memories
- Pure acknowledgments with no new information: "好的", "收到", "了解", "ok got it"
- Repetitive status pings: "还在跑吗" → "还在跑" (no new state change)
STILL extract from these (they contain real information):
- Conversations about system instructions (e.g., "let's update SOUL.md to add X") — the discussion IS worth remembering
- Heartbeat reports that discover actual issues (test failures, disk critical, new commits)
- Status reports with decisions or action items
- Any user preferences, requests, commitments, or decisions
- Short messages that contain actual decisions: "ok 那就用方案B" — extract the decision, not the "ok"
Respond with ONLY a JSON array (no markdown, no explanation):
[{"content": "...", "memory_type": "...", "importance": 0.X, "confidence": "confident|likely|uncertain", "valence": 0.X, "domain": "..."}]
Additional required fields:
- valence (REQUIRED): empathy valence of this specific fact, from -1.0 (very negative) to 1.0 (very positive). 0.0 = purely neutral/informational. Consider the speaker's emotional state and context, not just keywords. Examples: frustration with a bug = -0.5, excitement about a working feature = 0.7, neutral status report = 0.0, mixed feelings = use the dominant emotion for this specific fact.
- domain (REQUIRED): which domain this fact belongs to. One of: "coding", "trading", "research", "communication", "general". Choose the most specific applicable domain.
Conversation:
"#;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicExtractorConfig {
pub model: String,
pub api_url: String,
pub max_tokens: usize,
pub timeout_secs: u64,
}
impl Default for AnthropicExtractorConfig {
fn default() -> Self {
Self {
model: "claude-haiku-4-5-20251001".to_string(),
api_url: "https://api.anthropic.com".to_string(),
max_tokens: 1024,
timeout_secs: 30,
}
}
}
pub trait TokenProvider: Send + Sync {
fn get_token(&self) -> Result<String, Box<dyn Error + Send + Sync>>;
}
struct StaticToken(String);
impl TokenProvider for StaticToken {
fn get_token(&self) -> Result<String, Box<dyn Error + Send + Sync>> {
Ok(self.0.clone())
}
}
pub struct AnthropicExtractor {
config: AnthropicExtractorConfig,
token_provider: Box<dyn TokenProvider>,
is_oauth: bool,
client: reqwest::blocking::Client,
}
impl AnthropicExtractor {
pub fn new(auth_token: &str, is_oauth: bool) -> Self {
Self::with_config(auth_token, is_oauth, AnthropicExtractorConfig::default())
}
pub fn with_config(auth_token: &str, is_oauth: bool, config: AnthropicExtractorConfig) -> Self {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.expect("failed to create HTTP client");
Self {
config,
token_provider: Box::new(StaticToken(auth_token.to_string())),
is_oauth,
client,
}
}
pub fn with_token_provider(
provider: Box<dyn TokenProvider>,
is_oauth: bool,
config: AnthropicExtractorConfig,
) -> Self {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.expect("failed to create HTTP client");
Self {
config,
token_provider: provider,
is_oauth,
client,
}
}
fn build_headers(&self) -> Result<reqwest::header::HeaderMap, Box<dyn Error + Send + Sync>> {
let mut headers = reqwest::header::HeaderMap::new();
let token = self.token_provider.get_token()?;
headers.insert("anthropic-version", "2023-06-01".parse().unwrap());
headers.insert("content-type", "application/json".parse().unwrap());
if self.is_oauth {
headers.insert(
"anthropic-beta",
"claude-code-20250219,oauth-2025-04-20".parse().unwrap(),
);
headers.insert(
reqwest::header::AUTHORIZATION,
format!("Bearer {}", token).parse().unwrap(),
);
headers.insert(
reqwest::header::USER_AGENT,
"claude-cli/2.1.39 (external, cli)".parse().unwrap(),
);
headers.insert("x-app", "cli".parse().unwrap());
headers.insert(
"anthropic-dangerous-direct-browser-access",
"true".parse().unwrap(),
);
} else {
headers.insert("x-api-key", token.parse().unwrap());
}
Ok(headers)
}
}
impl MemoryExtractor for AnthropicExtractor {
fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, Box<dyn Error + Send + Sync>> {
let prompt = format!("{}{}", EXTRACTION_PROMPT, text);
let body = serde_json::json!({
"model": self.config.model,
"max_tokens": self.config.max_tokens,
"messages": [
{
"role": "user",
"content": prompt
}
]
});
let url = format!("{}/v1/messages", self.config.api_url);
let response = self.client
.post(&url)
.headers(self.build_headers()?)
.json(&body)
.send()?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().unwrap_or_default();
return Err(format!("Anthropic API error {}: {}", status, body).into());
}
let response_json: serde_json::Value = response.json()?;
let content_text = response_json
.get("content")
.and_then(|c| c.as_array())
.and_then(|arr| arr.first())
.and_then(|item| item.get("text"))
.and_then(|t| t.as_str())
.ok_or("Invalid response structure from Anthropic API")?;
parse_extraction_response(content_text)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaExtractorConfig {
pub host: String,
pub model: String,
pub timeout_secs: u64,
}
impl Default for OllamaExtractorConfig {
fn default() -> Self {
Self {
host: "http://localhost:11434".to_string(),
model: "llama3.2:3b".to_string(),
timeout_secs: 60,
}
}
}
pub struct OllamaExtractor {
config: OllamaExtractorConfig,
client: reqwest::blocking::Client,
}
impl OllamaExtractor {
pub fn new(model: &str) -> Self {
Self::with_config(OllamaExtractorConfig {
model: model.to_string(),
..Default::default()
})
}
pub fn with_host(model: &str, host: &str) -> Self {
Self::with_config(OllamaExtractorConfig {
host: host.to_string(),
model: model.to_string(),
..Default::default()
})
}
pub fn with_config(config: OllamaExtractorConfig) -> Self {
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(config.timeout_secs))
.build()
.expect("failed to create HTTP client");
Self { config, client }
}
}
impl MemoryExtractor for OllamaExtractor {
fn extract(&self, text: &str) -> Result<Vec<ExtractedFact>, Box<dyn Error + Send + Sync>> {
let prompt = format!("{}{}", EXTRACTION_PROMPT, text);
let body = serde_json::json!({
"model": self.config.model,
"messages": [
{
"role": "user",
"content": prompt
}
],
"stream": false
});
let url = format!("{}/api/chat", self.config.host);
let response = self.client
.post(&url)
.header("content-type", "application/json")
.json(&body)
.send()?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().unwrap_or_default();
return Err(format!("Ollama API error {}: {}", status, body).into());
}
let response_json: serde_json::Value = response.json()?;
let content_text = response_json
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.ok_or("Invalid response structure from Ollama API")?;
parse_extraction_response(content_text)
}
}
fn parse_extraction_response(content: &str) -> Result<Vec<ExtractedFact>, Box<dyn Error + Send + Sync>> {
let json_str = content
.trim()
.strip_prefix("```json")
.or_else(|| content.trim().strip_prefix("```"))
.map(|s| s.strip_suffix("```").unwrap_or(s))
.unwrap_or(content)
.trim();
if json_str == "[]" {
return Ok(vec![]);
}
let json_start = json_str.find('[');
let json_end = json_str.rfind(']');
let json_to_parse = match (json_start, json_end) {
(Some(start), Some(end)) if start < end => &json_str[start..=end],
_ => {
log::warn!("No JSON array found in extraction response: {}", json_str);
return Ok(vec![]);
}
};
match serde_json::from_str::<Vec<ExtractedFact>>(json_to_parse) {
Ok(facts) => {
let valid_facts: Vec<ExtractedFact> = facts
.into_iter()
.map(|mut f| {
f.memory_type = f.memory_type.to_lowercase();
f.importance = f.importance.clamp(0.0, 1.0);
f
})
.filter(|f| {
!f.content.is_empty()
})
.collect();
Ok(valid_facts)
}
Err(e) => {
log::warn!("Failed to parse extraction JSON: {} - content: {}", e, json_to_parse);
Ok(vec![])
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_clean_json() {
let response = r#"[{"content": "User prefers tea over coffee", "memory_type": "relational", "importance": 0.6}]"#;
let facts = parse_extraction_response(response).unwrap();
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].content, "User prefers tea over coffee");
assert_eq!(facts[0].memory_type, "relational");
assert!((facts[0].importance - 0.6).abs() < 0.001);
}
#[test]
fn test_parse_markdown_wrapped() {
let response = r#"```json
[{"content": "Meeting scheduled for Friday", "memory_type": "episodic", "importance": 0.8}]
```"#;
let facts = parse_extraction_response(response).unwrap();
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].content, "Meeting scheduled for Friday");
}
#[test]
fn test_parse_empty_array() {
let response = "[]";
let facts = parse_extraction_response(response).unwrap();
assert!(facts.is_empty());
}
#[test]
fn test_parse_invalid_json() {
let response = "This is not JSON at all";
let facts = parse_extraction_response(response).unwrap();
assert!(facts.is_empty()); }
#[test]
fn test_parse_with_surrounding_text() {
let response = r#"Here are the extracted facts:
[{"content": "Project deadline is next week", "memory_type": "factual", "importance": 0.9}]
Hope this helps!"#;
let facts = parse_extraction_response(response).unwrap();
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].content, "Project deadline is next week");
}
#[test]
fn test_parse_normalizes_memory_type() {
let response = r#"[{"content": "Test fact", "memory_type": "FACTUAL", "importance": 0.5}]"#;
let facts = parse_extraction_response(response).unwrap();
assert_eq!(facts[0].memory_type, "factual"); }
#[test]
fn test_parse_clamps_importance() {
let response = r#"[
{"content": "Low", "memory_type": "factual", "importance": -0.5},
{"content": "High", "memory_type": "factual", "importance": 1.5}
]"#;
let facts = parse_extraction_response(response).unwrap();
assert_eq!(facts.len(), 2);
assert_eq!(facts[0].importance, 0.0); assert_eq!(facts[1].importance, 1.0); }
#[test]
fn test_parse_filters_empty_content() {
let response = r#"[
{"content": "", "memory_type": "factual", "importance": 0.5},
{"content": "Valid fact", "memory_type": "factual", "importance": 0.5}
]"#;
let facts = parse_extraction_response(response).unwrap();
assert_eq!(facts.len(), 1);
assert_eq!(facts[0].content, "Valid fact");
}
#[test]
fn test_parse_multiple_facts() {
let response = r#"[
{"content": "Fact 1", "memory_type": "factual", "importance": 0.3},
{"content": "Fact 2", "memory_type": "episodic", "importance": 0.7},
{"content": "Fact 3", "memory_type": "relational", "importance": 0.9}
]"#;
let facts = parse_extraction_response(response).unwrap();
assert_eq!(facts.len(), 3);
}
#[test]
fn test_extraction_prompt_format() {
assert!(EXTRACTION_PROMPT.contains("JSON array"));
assert!(EXTRACTION_PROMPT.contains("SAME LANGUAGE"));
assert!(EXTRACTION_PROMPT.contains("importance"));
}
#[test]
#[ignore] fn test_ollama_extraction() {
let extractor = OllamaExtractor::new("llama3.2:3b");
let facts = extractor.extract("I really love pizza, especially pepperoni. My favorite restaurant is Mario's.").unwrap();
println!("Extracted facts: {:?}", facts);
}
#[test]
#[ignore] fn test_anthropic_extraction() {
let api_key = std::env::var("ANTHROPIC_API_KEY").expect("ANTHROPIC_API_KEY not set");
let extractor = AnthropicExtractor::new(&api_key, false);
let facts = extractor.extract("我昨天和小明一起去吃了火锅,很好吃。小明说他下周要去上海出差。").unwrap();
println!("Extracted facts: {:?}", facts);
}
}