use std::collections::HashMap;
use std::path::PathBuf;
use chrono::{DateTime, Duration, Utc};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use crate::error::{KtoError, Result};
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct InterestProfile {
#[serde(default)]
pub profile: ProfileDescription,
#[serde(default)]
pub interests: Vec<Interest>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProfileDescription {
#[serde(default)]
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Interest {
pub name: String,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default = "default_weight")]
pub weight: f64,
#[serde(default = "default_scope")]
pub scope: InterestScope,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<String>,
}
fn default_weight() -> f64 {
0.5
}
fn default_scope() -> InterestScope {
InterestScope::Broad
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
#[serde(rename_all = "lowercase")]
pub enum InterestScope {
#[default]
Broad,
Narrow,
}
impl InterestProfile {
pub fn load() -> Result<Self> {
let path = Self::profile_path()?;
if path.exists() {
let content = std::fs::read_to_string(&path)?;
Ok(toml::from_str(&content)?)
} else {
Ok(Self::default())
}
}
pub fn save(&self) -> Result<()> {
let path = Self::profile_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = toml::to_string_pretty(self)
.map_err(|e| KtoError::ConfigError(e.to_string()))?;
std::fs::write(&path, content)?;
Ok(())
}
pub fn profile_path() -> Result<PathBuf> {
let dirs = ProjectDirs::from("", "", "kto")
.ok_or_else(|| KtoError::ConfigError("Could not determine config directory".into()))?;
Ok(dirs.config_dir().join("interests.toml"))
}
pub fn is_empty(&self) -> bool {
self.profile.description.trim().is_empty() && self.interests.is_empty()
}
pub fn to_prompt_section(&self) -> String {
let mut sections = Vec::new();
if !self.profile.description.trim().is_empty() {
sections.push(format!(
"=== USER PROFILE (background context) ===\n{}",
self.profile.description.trim()
));
}
if !self.interests.is_empty() {
let mut interest_lines = Vec::new();
for interest in &self.interests {
let keywords = interest.keywords.join(", ");
let scope = match interest.scope {
InterestScope::Broad => "broad",
InterestScope::Narrow => "narrow",
};
interest_lines.push(format!(
"- {} (weight: {:.1}, {}): {}",
interest.name, interest.weight, scope, keywords
));
}
sections.push(format!(
"=== INTEREST KEYWORDS (relevance hints) ===\n{}",
interest_lines.join("\n")
));
}
sections.join("\n\n")
}
pub fn template() -> Self {
Self {
profile: ProfileDescription {
description: r#"# Describe what you're interested in here.
# This helps kto's AI understand what changes matter to you.
#
# Example:
# I'm a software engineer interested in:
# - Rust and systems programming
# - AI/ML developments, especially Claude and LLMs
# - Startup news and funding rounds
"#.to_string(),
},
interests: vec![
Interest {
name: "Example Interest".to_string(),
keywords: vec!["keyword1".to_string(), "keyword2".to_string()],
weight: 0.7,
scope: InterestScope::Broad,
sources: vec![],
},
],
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalMemory {
#[serde(default)]
pub observations: Vec<Observation>,
#[serde(default)]
pub interest_signals: HashMap<String, f64>,
#[serde(default)]
pub last_updated_by_watch: Option<String>,
#[serde(default)]
pub observation_count_by_watch: HashMap<String, u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Observation {
pub text: String,
pub source_watch: String,
pub created_at: DateTime<Utc>,
#[serde(default = "default_confidence")]
pub confidence: f64,
}
fn default_confidence() -> f64 {
0.5
}
impl GlobalMemory {
pub const MAX_SIZE: usize = 32 * 1024;
pub const MAX_OBSERVATION_AGE_DAYS: i64 = 30;
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(self).map_err(|e| KtoError::ConfigError(e.to_string()))
}
pub fn from_json(json: &str) -> Result<Self> {
serde_json::from_str(json).map_err(|e| KtoError::ConfigError(e.to_string()))
}
pub fn is_empty(&self) -> bool {
self.observations.is_empty() && self.interest_signals.is_empty()
}
pub fn add_observation(&mut self, text: String, source_watch: String, confidence: f64) {
self.observations.push(Observation {
text,
source_watch: source_watch.clone(),
created_at: Utc::now(),
confidence,
});
*self.observation_count_by_watch.entry(source_watch.clone()).or_insert(0) += 1;
self.last_updated_by_watch = Some(source_watch);
}
pub fn apply_decay(&mut self) {
let cutoff = Utc::now() - Duration::days(Self::MAX_OBSERVATION_AGE_DAYS);
self.observations.retain(|obs| obs.created_at > cutoff);
for obs in &mut self.observations {
let age_days = (Utc::now() - obs.created_at).num_days();
let decay_factor = 1.0 - (age_days as f64 / Self::MAX_OBSERVATION_AGE_DAYS as f64 * 0.5);
obs.confidence *= decay_factor.max(0.5); }
}
pub fn clear_observations(&mut self) {
self.observations.clear();
self.observation_count_by_watch.clear();
self.last_updated_by_watch = None;
}
pub fn clear_all(&mut self) {
self.observations.clear();
self.interest_signals.clear();
self.observation_count_by_watch.clear();
self.last_updated_by_watch = None;
}
pub fn to_prompt_section(&self) -> String {
if self.is_empty() {
return String::new();
}
let mut lines = Vec::new();
if !self.observations.is_empty() {
lines.push("=== LEARNED PATTERNS (from watching your behavior) ===".to_string());
for obs in &self.observations {
lines.push(format!(
"- {} (confidence: {:.1}, source: {})",
obs.text, obs.confidence, obs.source_watch
));
}
}
if !self.interest_signals.is_empty() {
if !lines.is_empty() {
lines.push(String::new());
}
lines.push("=== INFERRED INTERESTS (from monitoring patterns) ===".to_string());
let mut signals: Vec<_> = self.interest_signals.iter().collect();
signals.sort_by(|a, b| b.1.partial_cmp(a.1).unwrap_or(std::cmp::Ordering::Equal));
for (topic, score) in signals.iter().take(10) {
lines.push(format!("- {}: {:.1}", topic, score));
}
}
lines.join("\n")
}
pub fn truncate_to_limit(&mut self) {
while let Ok(json) = self.to_json() {
if json.len() <= Self::MAX_SIZE {
break;
}
if self.observations.is_empty() {
let signals: Vec<_> = self.interest_signals.drain().collect();
let keep_count = signals.len().saturating_sub(1);
for (k, v) in signals.into_iter().take(keep_count) {
self.interest_signals.insert(k, v);
}
if self.interest_signals.is_empty() {
break;
}
} else {
self.observations.remove(0);
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InferredInterests {
pub interests: Vec<Interest>,
#[serde(default = "default_confidence")]
pub confidence: f64,
#[serde(default)]
pub reasoning: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_interest_profile_default() {
let profile = InterestProfile::default();
assert!(profile.is_empty());
}
#[test]
fn test_interest_profile_template() {
let profile = InterestProfile::template();
assert!(!profile.is_empty());
assert!(!profile.interests.is_empty());
}
#[test]
fn test_interest_profile_to_prompt() {
let profile = InterestProfile {
profile: ProfileDescription {
description: "I'm a software engineer".to_string(),
},
interests: vec![
Interest {
name: "Rust".to_string(),
keywords: vec!["rust".to_string(), "cargo".to_string()],
weight: 0.8,
scope: InterestScope::Narrow,
sources: vec![],
},
],
};
let prompt = profile.to_prompt_section();
assert!(prompt.contains("software engineer"));
assert!(prompt.contains("Rust"));
assert!(prompt.contains("narrow"));
}
#[test]
fn test_global_memory_add_observation() {
let mut memory = GlobalMemory::default();
memory.add_observation(
"User ignores UI changes".to_string(),
"HN".to_string(),
0.8,
);
assert_eq!(memory.observations.len(), 1);
assert_eq!(memory.observation_count_by_watch.get("HN"), Some(&1));
assert_eq!(memory.last_updated_by_watch, Some("HN".to_string()));
}
#[test]
fn test_global_memory_clear() {
let mut memory = GlobalMemory::default();
memory.add_observation("test".to_string(), "watch".to_string(), 0.5);
memory.interest_signals.insert("AI".to_string(), 0.9);
memory.clear_observations();
assert!(memory.observations.is_empty());
assert!(!memory.interest_signals.is_empty());
memory.clear_all();
assert!(memory.interest_signals.is_empty()); }
#[test]
fn test_global_memory_to_prompt() {
let mut memory = GlobalMemory::default();
memory.add_observation(
"User prefers price changes".to_string(),
"Amazon".to_string(),
0.9,
);
memory.interest_signals.insert("pricing".to_string(), 0.85);
let prompt = memory.to_prompt_section();
assert!(prompt.contains("LEARNED PATTERNS"));
assert!(prompt.contains("price changes"));
assert!(prompt.contains("INFERRED INTERESTS"));
assert!(prompt.contains("pricing"));
}
}