// Claude Desktop — Intelligentes Context-Management // Drei-Schichten-Gedächtnis für kritischen Kontext use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; use tauri::{AppHandle, Manager}; use crate::db::{Database, DbState}; /// Schicht 1: Immer präsent (~200 Token) /// Wird bei JEDEM API-Call als System-Prompt gesendet #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct StickyContext { /// User-Infos (Name, Firma) pub user_info: Option, /// Komprimierte Zugänge (ohne Passwörter!) pub active_credentials: Vec, /// Aktuelles Projekt pub current_project: Option, /// Kritische Regeln pub critical_rules: Vec, } /// Zugangs-Hint (ohne sensible Daten!) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CredentialHint { pub name: String, pub host: String, pub db_or_path: Option, /// Wann automatisch injizieren (Regex-Pattern) pub inject_pattern: Option, } /// Projekt-Info (kompakt) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProjectInfo { pub id: String, pub name: String, pub current_phase: Option, pub working_dir: Option, } /// Schicht 2: Projekt-Kontext (~500 Token) /// Wird nach Compacting neu injiziert #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ProjectContext { /// Komprimiertes CLAUDE.md pub claude_md_summary: Option, /// Architektur-Entscheidungen pub decisions: Vec, /// Offene TODOs pub open_todos: Vec, /// Wichtige Erkenntnisse pub key_insights: Vec, } /// Eine Architektur-Entscheidung #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Decision { pub topic: String, pub decision: String, pub reason: Option, } /// Extrahierter Kontext vor Compacting #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExtractedContext { pub session_id: String, pub extracted_at: String, pub decisions: Vec, pub open_questions: Vec, pub key_insights: Vec, pub mentioned_files: Vec, pub mentioned_tools: Vec, } /// Wissens-Hint (Schicht 3, on-demand) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KnowledgeHint { pub title: String, pub content: String, pub relevance: f64, } impl StickyContext { /// Rendert Schicht 1 als System-Prompt-Teil pub fn render(&self) -> String { let mut parts = Vec::new(); if let Some(ref user) = self.user_info { parts.push(format!("**User:** {}", user)); } if let Some(ref proj) = self.current_project { let phase = proj.current_phase.as_deref().unwrap_or("unbekannt"); parts.push(format!("**Projekt:** {} (Phase: {})", proj.name, phase)); if let Some(ref dir) = proj.working_dir { parts.push(format!("**Arbeitsverzeichnis:** {}", dir)); } } if !self.active_credentials.is_empty() { parts.push("**Verfügbare Zugänge (NICHT nachfragen!):**".to_string()); for cred in &self.active_credentials { let db_part = cred.db_or_path.as_deref().map(|d| format!(" / {}", d)).unwrap_or_default(); parts.push(format!("- {}: {}{}", cred.name, cred.host, db_part)); } } if !self.critical_rules.is_empty() { parts.push("**Kritische Regeln:**".to_string()); for rule in &self.critical_rules { parts.push(format!("- {}", rule)); } } if parts.is_empty() { return String::new(); } format!("\n{}\n", parts.join("\n")) } /// Geschätzte Token-Anzahl pub fn estimate_tokens(&self) -> usize { // Grobe Schätzung: ~4 Zeichen pro Token self.render().len() / 4 } } impl ProjectContext { /// Rendert Schicht 2 als System-Reminder pub fn render(&self) -> String { let mut parts = Vec::new(); if let Some(ref summary) = self.claude_md_summary { parts.push(format!("**Projekt-Kontext:**\n{}", summary)); } if !self.decisions.is_empty() { parts.push("**Getroffene Entscheidungen:**".to_string()); for dec in &self.decisions { let reason = dec.reason.as_deref().map(|r| format!(" ({})", r)).unwrap_or_default(); parts.push(format!("- {}: {}{}", dec.topic, dec.decision, reason)); } } if !self.open_todos.is_empty() { parts.push("**Offene TODOs:**".to_string()); for todo in &self.open_todos { parts.push(format!("- [ ] {}", todo)); } } if !self.key_insights.is_empty() { parts.push("**Wichtige Erkenntnisse:**".to_string()); for insight in &self.key_insights { parts.push(format!("- {}", insight)); } } if parts.is_empty() { return String::new(); } format!("\n{}\n", parts.join("\n\n")) } /// Geschätzte Token-Anzahl pub fn estimate_tokens(&self) -> usize { self.render().len() / 4 } } // ============ Datenbank-Erweiterungen ============ impl Database { /// Erstellt die Context-Tabellen pub fn create_context_tables(&self) -> rusqlite::Result<()> { self.conn.execute_batch( " -- Sticky Context (Schicht 1) CREATE TABLE IF NOT EXISTS sticky_context ( key TEXT PRIMARY KEY, value TEXT NOT NULL, priority INTEGER DEFAULT 2, updated_at TEXT NOT NULL ); -- Compacting-Archiv CREATE TABLE IF NOT EXISTS compacting_archive ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, extracted_at TEXT NOT NULL, decisions TEXT, open_questions TEXT, key_insights TEXT, mentioned_files TEXT, mentioned_tools TEXT ); CREATE INDEX IF NOT EXISTS idx_archive_session ON compacting_archive(session_id); -- Context-Failures (für Prompt-Optimierung) CREATE TABLE IF NOT EXISTS context_failures ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL, timestamp TEXT NOT NULL, context_type TEXT NOT NULL, expected TEXT, actual TEXT, resolved INTEGER DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_failures_session ON context_failures(session_id); " )?; Ok(()) } /// Speichert einen Sticky-Context-Eintrag pub fn save_sticky_context(&self, key: &str, value: &str, priority: i32) -> rusqlite::Result<()> { self.conn.execute( "INSERT OR REPLACE INTO sticky_context (key, value, priority, updated_at) VALUES (?1, ?2, ?3, ?4)", rusqlite::params![key, value, priority, chrono::Local::now().to_rfc3339()], )?; Ok(()) } /// Lädt alle Sticky-Context-Einträge pub fn load_sticky_context(&self) -> rusqlite::Result> { let mut stmt = self.conn.prepare( "SELECT key, value, priority FROM sticky_context ORDER BY priority ASC" )?; let entries = stmt.query_map([], |row| { Ok((row.get(0)?, row.get(1)?, row.get(2)?)) })?.collect::>>()?; Ok(entries) } /// Löscht einen Sticky-Context-Eintrag pub fn delete_sticky_context(&self, key: &str) -> rusqlite::Result<()> { self.conn.execute("DELETE FROM sticky_context WHERE key = ?1", rusqlite::params![key])?; Ok(()) } /// Archiviert extrahierten Kontext vor Compacting pub fn archive_context(&self, extracted: &ExtractedContext) -> rusqlite::Result<()> { self.conn.execute( "INSERT INTO compacting_archive (id, session_id, extracted_at, decisions, open_questions, key_insights, mentioned_files, mentioned_tools) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![ uuid::Uuid::new_v4().to_string(), extracted.session_id, extracted.extracted_at, serde_json::to_string(&extracted.decisions).ok(), serde_json::to_string(&extracted.open_questions).ok(), serde_json::to_string(&extracted.key_insights).ok(), serde_json::to_string(&extracted.mentioned_files).ok(), serde_json::to_string(&extracted.mentioned_tools).ok(), ], )?; Ok(()) } /// Lädt archivierten Kontext einer Session pub fn load_archived_context(&self, session_id: &str) -> rusqlite::Result> { let result = self.conn.query_row( "SELECT session_id, extracted_at, decisions, open_questions, key_insights, mentioned_files, mentioned_tools FROM compacting_archive WHERE session_id = ?1 ORDER BY extracted_at DESC LIMIT 1", rusqlite::params![session_id], |row| { let decisions: Option = row.get(2)?; let questions: Option = row.get(3)?; let insights: Option = row.get(4)?; let files: Option = row.get(5)?; let tools: Option = row.get(6)?; Ok(ExtractedContext { session_id: row.get(0)?, extracted_at: row.get(1)?, decisions: decisions.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(), open_questions: questions.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(), key_insights: insights.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(), mentioned_files: files.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(), mentioned_tools: tools.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default(), }) }, ); match result { Ok(ctx) => Ok(Some(ctx)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e), } } /// Speichert einen Context-Failure pub fn log_context_failure( &self, session_id: &str, context_type: &str, expected: &str, actual: &str, ) -> rusqlite::Result<()> { self.conn.execute( "INSERT INTO context_failures (id, session_id, timestamp, context_type, expected, actual) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", rusqlite::params![ uuid::Uuid::new_v4().to_string(), session_id, chrono::Local::now().to_rfc3339(), context_type, expected, actual, ], )?; Ok(()) } } // ============ Tauri Commands ============ /// Sticky Context laden (Schicht 1) #[tauri::command] pub async fn get_sticky_context(app: AppHandle) -> Result { let state = app.state::(); let db = state.lock().unwrap(); // Context-Tabellen erstellen falls nicht vorhanden let _ = db.create_context_tables(); let entries = db.load_sticky_context().map_err(|e| e.to_string())?; let mut ctx = StickyContext::default(); for (key, value, _priority) in entries { match key.as_str() { "user_info" => ctx.user_info = Some(value), k if k.starts_with("cred:") => { if let Ok(cred) = serde_json::from_str::(&value) { ctx.active_credentials.push(cred); } } k if k.starts_with("project:") => { if let Ok(proj) = serde_json::from_str::(&value) { ctx.current_project = Some(proj); } } k if k.starts_with("rule:") => { ctx.critical_rules.push(value); } _ => {} } } Ok(ctx) } /// Sticky Context Eintrag setzen #[tauri::command] pub async fn set_sticky_context( app: AppHandle, key: String, value: String, priority: Option, ) -> Result<(), String> { let state = app.state::(); let db = state.lock().unwrap(); let _ = db.create_context_tables(); db.save_sticky_context(&key, &value, priority.unwrap_or(2)) .map_err(|e| e.to_string())?; println!("📌 Sticky Context gesetzt: {}", key); Ok(()) } /// Sticky Context Eintrag löschen #[tauri::command] pub async fn remove_sticky_context(app: AppHandle, key: String) -> Result<(), String> { let state = app.state::(); let db = state.lock().unwrap(); db.delete_sticky_context(&key).map_err(|e| e.to_string())?; println!("🗑️ Sticky Context entfernt: {}", key); Ok(()) } /// Projekt-Kontext laden (Schicht 2) #[tauri::command] pub async fn get_project_context( app: AppHandle, session_id: String, ) -> Result { let state = app.state::(); let db = state.lock().unwrap(); let _ = db.create_context_tables(); // Versuche archivierten Kontext zu laden if let Ok(Some(archived)) = db.load_archived_context(&session_id) { let mut ctx = ProjectContext::default(); ctx.decisions = archived.decisions; ctx.key_insights = archived.key_insights; ctx.open_todos = archived.open_questions; // open_questions als TODOs verwenden return Ok(ctx); } Ok(ProjectContext::default()) } /// Kontext vor Compacting extrahieren #[tauri::command] pub async fn extract_context_before_compacting( app: AppHandle, session_id: String, messages_json: String, ) -> Result { // Nachrichten parsen let messages: Vec = serde_json::from_str(&messages_json) .map_err(|e| e.to_string())?; let mut extracted = ExtractedContext { session_id: session_id.clone(), extracted_at: chrono::Local::now().to_rfc3339(), decisions: Vec::new(), open_questions: Vec::new(), key_insights: Vec::new(), mentioned_files: Vec::new(), mentioned_tools: Vec::new(), }; // Einfache Extraktion aus Nachrichten for msg in &messages { let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or(""); // Entscheidungen finden (Muster: "Entscheidung:", "Wir nehmen", "Ich verwende") if content.contains("Entscheidung:") || content.contains("Wir nehmen") || content.contains("Ich verwende") { // Vereinfachte Extraktion let lines: Vec<&str> = content.lines() .filter(|l| l.contains("Entscheidung") || l.contains("verwende") || l.contains("nehmen")) .take(3) .collect(); for line in lines { if line.len() > 10 && line.len() < 200 { extracted.decisions.push(Decision { topic: "Architektur".to_string(), decision: line.trim().to_string(), reason: None, }); } } } // Offene Fragen finden (Muster: "TODO", "FIXME", "?") if content.contains("TODO") || content.contains("FIXME") { let lines: Vec<&str> = content.lines() .filter(|l| l.contains("TODO") || l.contains("FIXME")) .take(5) .collect(); for line in lines { if line.len() > 5 && line.len() < 200 { extracted.open_questions.push(line.trim().to_string()); } } } // Dateien extrahieren (vereinfacht: Pfade mit /) for word in content.split_whitespace() { if word.contains('/') && word.contains('.') && word.len() < 100 { let clean = word.trim_matches(|c| c == '"' || c == '\'' || c == '`' || c == '(' || c == ')'); if !extracted.mentioned_files.contains(&clean.to_string()) { extracted.mentioned_files.push(clean.to_string()); } } } } // Archivieren let state = app.state::(); let db = state.lock().unwrap(); let _ = db.create_context_tables(); let _ = db.archive_context(&extracted); println!("📦 Kontext extrahiert: {} Entscheidungen, {} TODOs, {} Dateien", extracted.decisions.len(), extracted.open_questions.len(), extracted.mentioned_files.len() ); Ok(extracted) } /// Context-Failure loggen (für Prompt-Optimierung) #[tauri::command] pub async fn log_context_failure( app: AppHandle, session_id: String, context_type: String, expected: String, actual: String, ) -> Result<(), String> { let state = app.state::(); let db = state.lock().unwrap(); let _ = db.create_context_tables(); db.log_context_failure(&session_id, &context_type, &expected, &actual) .map_err(|e| e.to_string())?; println!("⚠️ Context-Failure geloggt: {} (erwartet: {}, tatsächlich: {})", context_type, expected, actual); Ok(()) } /// Kombinierter System-Prompt aus Schicht 1+2 #[tauri::command] pub async fn get_full_context( app: AppHandle, session_id: Option, ) -> Result { let state = app.state::(); let db = state.lock().unwrap(); let _ = db.create_context_tables(); // Schicht 1: Sticky Context let entries = db.load_sticky_context().map_err(|e| e.to_string())?; let mut sticky = StickyContext::default(); for (key, value, _priority) in entries { match key.as_str() { "user_info" => sticky.user_info = Some(value), k if k.starts_with("cred:") => { if let Ok(cred) = serde_json::from_str::(&value) { sticky.active_credentials.push(cred); } } k if k.starts_with("project:") => { if let Ok(proj) = serde_json::from_str::(&value) { sticky.current_project = Some(proj); } } k if k.starts_with("rule:") => { sticky.critical_rules.push(value); } _ => {} } } // Schicht 2: Projekt-Kontext (falls Session angegeben) let mut project = ProjectContext::default(); if let Some(ref sid) = session_id { if let Ok(Some(archived)) = db.load_archived_context(sid) { project.decisions = archived.decisions; project.key_insights = archived.key_insights; project.open_todos = archived.open_questions; } } // Kombinieren let sticky_rendered = sticky.render(); let project_rendered = project.render(); let mut full = String::new(); if !sticky_rendered.is_empty() { full.push_str(&sticky_rendered); } if !project_rendered.is_empty() { if !full.is_empty() { full.push_str("\n\n"); } full.push_str(&project_rendered); } Ok(full) } /// Liste alle Sticky-Context-Einträge auf #[tauri::command] pub async fn list_sticky_context( app: AppHandle, ) -> Result, String> { let state = app.state::(); let db = state.lock().unwrap(); let _ = db.create_context_tables(); db.load_sticky_context().map_err(|e| e.to_string()) }