Skip to main content

sparrow/
extras.rs

1use crate::engine::{Engine, Task};
2use crate::event::Event;
3use crate::memory::{Fact, Memory};
4use std::sync::Arc;
5use tokio::sync::mpsc;
6
7// ─── Auto-distillation ─────────────────────────────────────────────────────────
8
9/// After a successful run, extract durable facts about the user
10/// from the conversation trajectory.
11/// §3.8: "after sessions, distill durable facts/preferences into identity/facts_about_user"
12pub struct Distiller;
13
14impl Distiller {
15    /// Analyze run events and extract durable facts about the user and project.
16    /// Called automatically after every successful run (§3.8).
17    pub async fn distill(memory: &Arc<dyn Memory>, events: &[Event], task_description: &str) {
18        let mut facts = Vec::new();
19
20        // ── 1. Languages & frameworks (from file paths) ──────────────────────
21        let mut lang_hints: Vec<String> = Vec::new();
22        let mut framework_hints: Vec<String> = Vec::new();
23        let mut tool_usage: std::collections::HashMap<String, u32> =
24            std::collections::HashMap::new();
25        let mut style_hints: Vec<String> = Vec::new();
26        let mut pref_hints: Vec<String> = Vec::new();
27        let mut convention_hints: Vec<String> = Vec::new();
28        let mut directive_hints: Vec<String> = Vec::new();
29
30        // The initial user request is not always replayed as an Event::Message
31        // in WebView/console runs. Mine it directly so explicit "remember..."
32        // and preference instructions survive even on simple chat-only turns.
33        detect_preferences(task_description, &mut pref_hints);
34        detect_directives(task_description, &mut directive_hints);
35
36        for event in events {
37            match event {
38                Event::ToolUseProposed { name, args, .. } => {
39                    // Track tool usage frequency
40                    *tool_usage.entry(name.clone()).or_insert(0) += 1;
41
42                    // Languages from file extensions
43                    if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
44                        detect_languages(path, &mut lang_hints);
45                        detect_conventions(path, &mut convention_hints);
46                    }
47                    // Frameworks from content
48                    if let Some(content) = args.get("content").and_then(|v| v.as_str()) {
49                        detect_frameworks(content, &mut framework_hints);
50                    }
51                }
52                Event::ThinkingDelta { text, .. } => {
53                    // Style preferences
54                    if text.contains("refactor") {
55                        style_hints.push("refactoring-oriented".to_string());
56                    }
57                    if text.contains("test") || text.contains("TDD") {
58                        style_hints.push("test-driven".to_string());
59                    }
60                    if text.contains("async") || text.contains("await") {
61                        style_hints.push("async-first".to_string());
62                    }
63                    // Explicit user preferences mentioned in thinking
64                    detect_preferences(text, &mut pref_hints);
65                }
66                Event::Message { text, role, .. } if role == "user" => {
67                    // User messages often contain preferences
68                    detect_preferences(text, &mut pref_hints);
69                    // Explicit durable directives ("remember that…", "I prefer…",
70                    // "always…", "my name is…"). Captured verbatim so memory keeps
71                    // the real instruction, not a generic hint.
72                    detect_directives(text, &mut directive_hints);
73                }
74                _ => {}
75            }
76        }
77
78        // ── 2. Deduplicate ──────────────────────────────────────────────────
79        dedup(&mut lang_hints);
80        dedup(&mut framework_hints);
81        dedup(&mut style_hints);
82        dedup(&mut pref_hints);
83        dedup(&mut convention_hints);
84        dedup(&mut directive_hints);
85
86        // ── 3. Save facts ───────────────────────────────────────────────────
87        for lang in &lang_hints {
88            facts.push(fact("user:language", lang));
89        }
90        for fw in &framework_hints {
91            facts.push(fact("user:framework", fw));
92        }
93        for style in &style_hints {
94            facts.push(fact("user:style", style));
95        }
96        for pref in &pref_hints {
97            facts.push(fact("user:preference", pref));
98        }
99        for conv in &convention_hints {
100            facts.push(fact("project:convention", conv));
101        }
102        // Each captured directive becomes its own fact keyed by a stable hash of
103        // its text, so distinct directives never collide on a shared key.
104        for d in &directive_hints {
105            facts.push(fact(&format!("user:directive:{}", short_hash(d)), d));
106        }
107        // Tools used 3+ times become learned preferences
108        for (tool, count) in &tool_usage {
109            if *count >= 3 {
110                facts.push(fact(
111                    "user:frequent_tool",
112                    &format!("uses {} frequently ({}x this session)", tool, count),
113                ));
114            }
115        }
116
117        // ── 4. Persist (skip duplicates) ────────────────────────────────────
118        // v0.9.1 fix: deduplicate on the (key, value) PAIR, not the key alone.
119        // The keys here are generic buckets (`user:preference`, `user:language`,
120        // `user:directive`…). The previous `existing_keys.contains(key)` check
121        // meant that once ONE `user:preference` existed, no further preference
122        // — with a different value — was ever stored. Memory saturated after the
123        // first run and stopped learning. Comparing the full pair lets every new
124        // distinct fact land while still skipping exact repeats.
125        let existing = memory.all_facts();
126        let existing_pairs: std::collections::HashSet<(String, String)> = existing
127            .iter()
128            .map(|f| (f.key.clone(), f.value.clone()))
129            .collect();
130        let mut saved = 0;
131
132        for fact in &facts {
133            if !existing_pairs.contains(&(fact.key.clone(), fact.value.clone())) {
134                let _ = memory.remember(fact.clone());
135                saved += 1;
136            }
137        }
138
139        if saved > 0 {
140            tracing::info!(
141                "Distiller: extracted {} facts ({} new) from task: {}",
142                facts.len(),
143                saved,
144                &task_description[..task_description.len().min(60)]
145            );
146        }
147    }
148}
149
150// ─── Distiller helpers ─────────────────────────────────────────────────────────
151
152fn detect_languages(path: &str, hints: &mut Vec<String>) {
153    let ext_map: &[(&str, &str)] = &[
154        (".rs", "Rust"),
155        (".ts", "TypeScript"),
156        (".tsx", "TypeScript/React"),
157        (".py", "Python"),
158        (".go", "Go"),
159        (".js", "JavaScript"),
160        (".jsx", "JavaScript/React"),
161        (".java", "Java"),
162        (".rb", "Ruby"),
163        (".css", "CSS"),
164        (".html", "HTML"),
165        (".sql", "SQL"),
166        (".tf", "Terraform"),
167        (".yml", "YAML"),
168        (".yaml", "YAML"),
169        (".toml", "TOML"),
170        (".json", "JSON"),
171        (".md", "Markdown"),
172        (".sh", "Shell"),
173    ];
174    let lower = path.to_lowercase();
175    for (ext, lang) in ext_map {
176        if lower.ends_with(ext) {
177            hints.push(lang.to_string());
178            return;
179        }
180    }
181}
182
183fn detect_frameworks(content: &str, hints: &mut Vec<String>) {
184    let fw_map: &[(&str, &str)] = &[
185        ("Cargo.toml", "Rust/Cargo"),
186        ("package.json", "Node.js"),
187        ("go.mod", "Go modules"),
188        ("requirements.txt", "Python/pip"),
189        ("pyproject.toml", "Python/poetry"),
190        ("Dockerfile", "Docker"),
191        ("docker-compose", "Docker Compose"),
192        ("Makefile", "Make"),
193        ("CMakeLists.txt", "CMake"),
194        ("pom.xml", "Java/Maven"),
195        ("build.gradle", "Java/Gradle"),
196    ];
197    for (pattern, fw) in fw_map {
198        if content.contains(pattern) {
199            hints.push(fw.to_string());
200        }
201    }
202}
203
204fn detect_preferences(text: &str, hints: &mut Vec<String>) {
205    let pref_patterns: &[(&str, &str)] = &[
206        ("prefer async", "prefers async/await"),
207        ("prefer sync", "prefers synchronous code"),
208        ("use tabs", "uses tabs for indentation"),
209        ("use spaces", "uses spaces for indentation"),
210        (
211            "prefer unwrap",
212            "prefers .unwrap() over proper error handling",
213        ),
214        ("prefer anyhow", "prefers anyhow for error handling"),
215        ("instead of", "has strong opinions about alternatives"),
216        ("don't use", "has explicit dislikes"),
217        ("always use", "has explicit preferences"),
218        ("I like", "expressed a personal preference"),
219        ("I want", "expressed a desire"),
220        ("je préfère", "expressed a personal preference"),
221        ("je prefere", "expressed a personal preference"),
222        ("j'aime", "expressed a personal preference"),
223        ("je veux", "expressed a desire"),
224        ("utilise toujours", "has explicit preferences"),
225        ("n'utilise pas", "has explicit dislikes"),
226        ("ne pas utiliser", "has explicit dislikes"),
227    ];
228    let lower = text.to_lowercase();
229    for (pattern, hint) in pref_patterns {
230        if lower.contains(pattern) {
231            hints.push(hint.to_string());
232        }
233    }
234}
235
236/// Capture explicit durable directives from a user message — the kind of thing
237/// a user expects the agent to *remember* across sessions. We keep the user's
238/// actual sentence (trimmed to one line, capped) rather than a generic hint, so
239/// recall is faithful. Triggers on common FR/EN durable-intent markers.
240fn detect_directives(text: &str, hints: &mut Vec<String>) {
241    const MARKERS: &[&str] = &[
242        // English
243        "remember that",
244        "remember to",
245        "don't forget",
246        "keep in mind",
247        "note that",
248        "from now on",
249        "always ",
250        "never ",
251        "my name is",
252        "i prefer",
253        "i want you to",
254        "make sure to",
255        "going forward",
256        // French
257        "retiens",
258        "souviens-toi",
259        "souviens toi",
260        "n'oublie pas",
261        "note que",
262        "désormais",
263        "dorénavant",
264        "toujours ",
265        "jamais ",
266        "je m'appelle",
267        "je préfère",
268        "je veux que",
269        "à partir de maintenant",
270        "rappelle-toi",
271    ];
272    let lower = text.to_lowercase();
273    for line in text.lines() {
274        let line_l = line.to_lowercase();
275        if MARKERS.iter().any(|m| line_l.contains(m)) {
276            let cleaned = line.trim();
277            if cleaned.len() >= 4 {
278                // Cap to keep a fact compact; preserve the real instruction.
279                let capped: String = cleaned.chars().take(220).collect();
280                hints.push(capped);
281            }
282        }
283    }
284    // Fallback: single-line message with a marker but no line break handled above.
285    if hints.is_empty() && MARKERS.iter().any(|m| lower.contains(m)) {
286        let capped: String = text.trim().chars().take(220).collect();
287        if capped.len() >= 4 {
288            hints.push(capped);
289        }
290    }
291}
292
293/// Short stable hex hash for deriving collision-free fact keys from text.
294fn short_hash(s: &str) -> String {
295    use std::hash::{Hash, Hasher};
296    let mut h = std::collections::hash_map::DefaultHasher::new();
297    s.trim().to_lowercase().hash(&mut h);
298    format!("{:08x}", (h.finish() & 0xffff_ffff) as u32)
299}
300
301fn detect_conventions(path: &str, hints: &mut Vec<String>) {
302    let conv_patterns: &[(&str, &str)] = &[
303        ("src/main.rs", "Rust binary project structure"),
304        ("src/lib.rs", "Rust library project structure"),
305        ("src/index.ts", "TypeScript entry point convention"),
306        ("src/app.py", "Python app entry point"),
307        ("tests/", "has a test directory"),
308        ("spec/", "has a spec directory"),
309        ("docs/", "maintains documentation"),
310        (".github/workflows/", "uses GitHub Actions CI"),
311        (".gitignore", "has gitignore"),
312    ];
313    let lower = path.to_lowercase();
314    for (pattern, hint) in conv_patterns {
315        if lower.contains(&pattern.to_lowercase()) {
316            hints.push(hint.to_string());
317        }
318    }
319}
320
321fn dedup(v: &mut Vec<String>) {
322    v.sort();
323    v.dedup();
324}
325
326fn fact(key: &str, value: &str) -> Fact {
327    Fact {
328        id: uuid::Uuid::new_v4().to_string(),
329        key: key.to_string(),
330        value: value.to_string(),
331        created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
332        updated_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
333    }
334}
335
336#[cfg(test)]
337mod distiller_tests {
338    use super::*;
339
340    #[test]
341    fn detect_directives_captures_english_durable_instructions() {
342        let mut h = Vec::new();
343        detect_directives("Remember that I want reports in ./artifacts", &mut h);
344        assert!(h.iter().any(|s| s.contains("reports in ./artifacts")));
345
346        let mut h2 = Vec::new();
347        detect_directives("From now on, always run the tests first", &mut h2);
348        assert_eq!(h2.len(), 1);
349    }
350
351    #[test]
352    fn detect_directives_captures_french_durable_instructions() {
353        let mut h = Vec::new();
354        detect_directives("Retiens que je m'appelle Abdou", &mut h);
355        assert!(h.iter().any(|s| s.contains("Abdou")));
356
357        let mut h2 = Vec::new();
358        detect_directives("Désormais, range les livrables dans ./out", &mut h2);
359        assert_eq!(h2.len(), 1);
360    }
361
362    #[test]
363    fn detect_directives_ignores_ordinary_text() {
364        let mut h = Vec::new();
365        detect_directives("Can you fix the bug in main.rs please?", &mut h);
366        assert!(h.is_empty());
367    }
368
369    #[test]
370    fn short_hash_is_stable_and_distinct() {
371        // Stable across calls, case/whitespace-insensitive, distinct for
372        // different content — so directive keys never collide.
373        assert_eq!(
374            short_hash("use ./artifacts"),
375            short_hash("  USE ./artifacts  ")
376        );
377        assert_ne!(short_hash("prefer async"), short_hash("prefer sync"));
378        assert_eq!(short_hash("x").len(), 8);
379    }
380}
381
382// ─── Lightweight deterministic embeddings ──────────────────────────────────────
383
384/// Lightweight semantic embeddings for repo memory.
385/// §3.8: "optional embeddings (per project)"
386#[derive(Debug, Clone)]
387pub struct Embeddings {
388    /// Stored text + normalized hashing-vector embedding.
389    pub vectors: Vec<(String, Vec<f64>)>,
390    dimensions: usize,
391}
392
393impl Embeddings {
394    pub const DEFAULT_DIMENSIONS: usize = 512;
395
396    pub fn new() -> Self {
397        Self {
398            vectors: Vec::new(),
399            dimensions: Self::DEFAULT_DIMENSIONS,
400        }
401    }
402
403    pub fn with_dimensions(dimensions: usize) -> Self {
404        Self {
405            vectors: Vec::new(),
406            dimensions: dimensions.max(16),
407        }
408    }
409
410    /// Build a deterministic hashing-vector embedding from text.
411    ///
412    /// This is intentionally local-first: no model/API key required, fixed
413    /// dimensions across documents, stable across sessions, and good enough for
414    /// lexical semantic recall in memory. It uses signed feature hashing with
415    /// unigram + adjacent bigram features, sublinear term frequency, and L2
416    /// normalization.
417    pub fn embed(&self, text: &str) -> Vec<f64> {
418        embed_with_dimensions(text, self.dimensions)
419    }
420
421    pub fn add(&mut self, text: &str) {
422        let clean = text.trim();
423        if clean.is_empty() {
424            return;
425        }
426        self.vectors.push((clean.to_string(), self.embed(clean)));
427    }
428
429    pub fn add_many<I, S>(&mut self, texts: I)
430    where
431        I: IntoIterator<Item = S>,
432        S: AsRef<str>,
433    {
434        for text in texts {
435            self.add(text.as_ref());
436        }
437    }
438
439    /// Find the most similar stored text to the query
440    pub fn search(&self, query: &str, k: usize) -> Vec<String> {
441        self.search_scored(query, k)
442            .into_iter()
443            .map(|(_, text)| text)
444            .collect()
445    }
446
447    pub fn search_scored(&self, query: &str, k: usize) -> Vec<(f64, String)> {
448        if k == 0 {
449            return Vec::new();
450        }
451        let q_embed = self.embed(query);
452        let mut scored: Vec<(f64, usize, &str)> = self
453            .vectors
454            .iter()
455            .enumerate()
456            .map(|(idx, (text, emb))| (cosine_sim(&q_embed, emb), idx, text.as_str()))
457            .collect();
458        scored.sort_by(|a, b| {
459            b.0.partial_cmp(&a.0)
460                .unwrap_or(std::cmp::Ordering::Equal)
461                .then(a.1.cmp(&b.1))
462        });
463        scored
464            .into_iter()
465            .take(k)
466            .filter(|(score, _, _)| *score > 0.0)
467            .map(|(score, _, text)| (score, text.to_string()))
468            .collect()
469    }
470
471    pub fn save_to_path(&self, path: impl AsRef<std::path::Path>) -> anyhow::Result<()> {
472        let snapshot = EmbeddingsSnapshot {
473            dimensions: self.dimensions,
474            texts: self.vectors.iter().map(|(text, _)| text.clone()).collect(),
475        };
476        let json = serde_json::to_string_pretty(&snapshot)?;
477        if let Some(parent) = path.as_ref().parent() {
478            std::fs::create_dir_all(parent)?;
479        }
480        std::fs::write(path, json)?;
481        Ok(())
482    }
483
484    pub fn load_from_path(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
485        let json = std::fs::read_to_string(path)?;
486        let snapshot: EmbeddingsSnapshot = serde_json::from_str(&json)?;
487        let mut index = Self::with_dimensions(snapshot.dimensions);
488        index.add_many(snapshot.texts);
489        Ok(index)
490    }
491}
492
493impl Default for Embeddings {
494    fn default() -> Self {
495        Self::new()
496    }
497}
498
499#[derive(serde::Serialize, serde::Deserialize)]
500struct EmbeddingsSnapshot {
501    dimensions: usize,
502    texts: Vec<String>,
503}
504
505fn embed_with_dimensions(text: &str, dimensions: usize) -> Vec<f64> {
506    let mut vector = vec![0.0; dimensions.max(16)];
507    let tokens = tokenize(text);
508    for token in &tokens {
509        add_feature(&mut vector, token, 1.0);
510    }
511    for pair in tokens.windows(2) {
512        add_feature(&mut vector, &format!("{}__{}", pair[0], pair[1]), 1.35);
513    }
514    for value in &mut vector {
515        if *value != 0.0 {
516            *value = value.signum() * value.abs().ln_1p();
517        }
518    }
519    normalize(&mut vector);
520    vector
521}
522
523fn tokenize(text: &str) -> Vec<String> {
524    let mut tokens = Vec::new();
525    let mut current = String::new();
526    for ch in text.chars() {
527        if ch.is_alphanumeric() {
528            current.extend(ch.to_lowercase());
529        } else if !current.is_empty() {
530            tokens.push(std::mem::take(&mut current));
531        }
532    }
533    if !current.is_empty() {
534        tokens.push(current);
535    }
536    tokens
537}
538
539fn add_feature(vector: &mut [f64], feature: &str, weight: f64) {
540    let hash = fnv1a64(feature.as_bytes());
541    let idx = (hash as usize) % vector.len();
542    let sign = if hash & (1 << 63) == 0 { 1.0 } else { -1.0 };
543    vector[idx] += sign * weight;
544}
545
546fn fnv1a64(bytes: &[u8]) -> u64 {
547    let mut hash = 0xcbf29ce484222325u64;
548    for byte in bytes {
549        hash ^= *byte as u64;
550        hash = hash.wrapping_mul(0x100000001b3);
551    }
552    hash
553}
554
555fn normalize(vector: &mut [f64]) {
556    let norm = vector.iter().map(|v| v * v).sum::<f64>().sqrt();
557    if norm > 0.0 {
558        for value in vector {
559            *value /= norm;
560        }
561    }
562}
563
564fn cosine_sim(a: &[f64], b: &[f64]) -> f64 {
565    let len = a.len().min(b.len());
566    if len == 0 {
567        return 0.0;
568    }
569    let dot: f64 = a.iter().zip(b.iter()).take(len).map(|(x, y)| x * y).sum();
570    let norm_a: f64 = a.iter().take(len).map(|x| x * x).sum::<f64>().sqrt();
571    let norm_b: f64 = b.iter().take(len).map(|x| x * x).sum::<f64>().sqrt();
572    if norm_a == 0.0 || norm_b == 0.0 {
573        0.0
574    } else {
575        dot / (norm_a * norm_b)
576    }
577}
578
579// ─── Replay re-execute ──────────────────────────────────────────────────────────
580
581/// Re-execute a transcript against a chosen model.
582/// §3.15: "can re-execute against a chosen model"
583pub struct ReExecuter {
584    engine: Arc<Engine>,
585}
586
587impl ReExecuter {
588    pub fn new(engine: Arc<Engine>) -> Self {
589        Self { engine }
590    }
591
592    /// Re-execute from a transcript: send the original task to the engine
593    /// with the same parameters.
594    pub async fn re_execute(
595        &self,
596        transcript: &crate::runtime::recorder::Transcript,
597    ) -> anyhow::Result<crate::event::OutcomeSummary> {
598        let (tx, _rx) = mpsc::unbounded_channel::<Event>();
599        let task = Task {
600            description: transcript.inputs.task.clone(),
601            context: vec![],
602        };
603        self.engine.drive(task, tx).await
604    }
605}
606
607// ─── OAuth flow ─────────────────────────────────────────────────────────────────
608
609pub struct OAuthFlow;
610
611impl OAuthFlow {
612    /// Start a device code OAuth flow.
613    /// Accepts the endpoints and scope from the provider registry — no hardcoded list.
614    pub async fn start_device_flow(
615        device_endpoint: &str,
616        token_endpoint_hint: &str, // unused here, kept for symmetry
617        client_id: &str,
618        scope: &str,
619    ) -> anyhow::Result<(String, String, String)> {
620        let _ = token_endpoint_hint;
621        let client = reqwest::Client::new();
622        let resp: serde_json::Value = client
623            .post(device_endpoint)
624            .form(&[("client_id", client_id), ("scope", scope)])
625            .send()
626            .await?
627            .json()
628            .await?;
629
630        let verification_uri = resp["verification_uri"]
631            .as_str()
632            .or_else(|| resp["verification_url"].as_str())
633            .unwrap_or("")
634            .to_string();
635        let user_code = resp["user_code"].as_str().unwrap_or("").to_string();
636        let device_code = resp["device_code"].as_str().unwrap_or("").to_string();
637
638        if device_code.is_empty() {
639            anyhow::bail!("Device flow start failed — provider response: {}", resp);
640        }
641
642        Ok((verification_uri, user_code, device_code))
643    }
644
645    /// Poll for token completion using the provider token endpoint.
646    pub async fn poll_token(
647        token_endpoint: &str,
648        client_id: &str,
649        device_code: &str,
650        timeout_secs: u64,
651    ) -> anyhow::Result<String> {
652        let client = reqwest::Client::new();
653        let start = std::time::Instant::now();
654
655        loop {
656            if start.elapsed().as_secs() > timeout_secs {
657                anyhow::bail!("OAuth timed out after {}s", timeout_secs);
658            }
659
660            let resp: serde_json::Value = client
661                .post(token_endpoint)
662                .form(&[
663                    ("client_id", client_id),
664                    ("device_code", device_code),
665                    ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
666                ])
667                .send()
668                .await?
669                .json()
670                .await?;
671
672            if let Some(token) = resp["access_token"].as_str() {
673                return Ok(token.to_string());
674            }
675
676            match resp["error"].as_str() {
677                Some("authorization_pending") | Some("slow_down") => {
678                    tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
679                    continue;
680                }
681                Some(e) => anyhow::bail!("OAuth error: {}", e),
682                None => {
683                    tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
684                }
685            }
686        }
687    }
688}
689
690// ─── IBM Plex Mono reference ────────────────────────────────────────────────────
691
692/// §9.3: "IBM Plex Mono everywhere (TUI authenticity + web)"
693/// The font is not embedded in the binary; users install it system-wide.
694/// This constant provides the download URL and instructions.
695pub const IBM_PLEX_MONO_URL: &str =
696    "https://github.com/IBM/plex/releases/latest/download/IBM-Plex-Mono.zip";
697
698pub fn ibm_plex_install_instructions() -> String {
699    r#"IBM Plex Mono — recommended font for Sparrow TUI.
700
701Install:
702  Linux:   sudo apt install fonts-ibm-plex
703  macOS:   brew install font-ibm-plex
704  Windows: Download from https://github.com/IBM/plex/releases
705
706Then update your terminal to use "IBM Plex Mono" as the font.
707"#
708    .to_string()
709}
710
711// ─── Chat mode ──────────────────────────────────────────────────────────────────
712
713/// Interactive multi-turn chat loop.
714/// §4: "sparrow chat — interactive multi-turn (TUI/inline)"
715pub struct ChatSession {
716    engine: Arc<Engine>,
717    history: Vec<crate::provider::Msg>,
718    running: bool,
719}
720
721impl ChatSession {
722    pub fn new(engine: Arc<Engine>) -> Self {
723        Self {
724            engine,
725            history: Vec::new(),
726            running: true,
727        }
728    }
729
730    pub async fn run_interactive(&mut self) -> anyhow::Result<()> {
731        use std::io::{self, Write};
732
733        println!("═══ Sparrow Chat ═══");
734        println!("Type your message and press Enter. Type /exit to quit.");
735        println!();
736
737        while self.running {
738            print!("◆ you › ");
739            io::stdout().flush()?;
740
741            let mut input = String::new();
742            io::stdin().read_line(&mut input)?;
743            let input = input.trim().to_string();
744
745            if input.is_empty() {
746                continue;
747            }
748            if input == "/exit" || input == "/quit" {
749                break;
750            }
751
752            self.history.push(crate::provider::Msg {
753                role: "user".into(),
754                content: vec![crate::provider::ContentBlock::Text {
755                    text: input.clone(),
756                }],
757            });
758
759            let (tx, mut rx) = mpsc::unbounded_channel::<Event>();
760            let task = Task {
761                description: input.clone(),
762                context: self.history.clone(),
763            };
764
765            let engine = self.engine.clone();
766            let handle = tokio::spawn(async move { engine.drive(task, tx).await });
767
768            while let Some(event) = rx.recv().await {
769                match &event {
770                    Event::ThinkingDelta { text, .. } => {
771                        print!("{}", text);
772                        io::stdout().flush()?;
773                    }
774                    Event::RunFinished { outcome, .. } => {
775                        println!(
776                            "\n── {} | ${:.4} {}──",
777                            outcome.status,
778                            outcome.cost_usd,
779                            crate::cost::format_comparison_oneliner(
780                                outcome.cost_usd,
781                                &outcome.tokens
782                            )
783                        );
784                    }
785                    Event::Error { message, .. } => {
786                        eprintln!("\nError: {}", message);
787                    }
788                    _ => {}
789                }
790            }
791
792            match handle.await? {
793                Ok(outcome) => {
794                    self.history.push(crate::provider::Msg {
795                        role: "assistant".into(),
796                        content: vec![crate::provider::ContentBlock::Text {
797                            text: format!("[{}]", outcome.status),
798                        }],
799                    });
800                }
801                Err(e) => {
802                    eprintln!("Chat error: {}", e);
803                }
804            }
805            println!();
806        }
807
808        Ok(())
809    }
810}
811
812// ─── Configurable pipeline ──────────────────────────────────────────────────────
813
814/// Allow users to define custom swarm pipeline graphs.
815/// §3.11: "Configurable: number of agents, which model per role, pipeline graph."
816#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
817pub struct PipelineConfig {
818    pub name: String,
819    pub steps: Vec<PipelineStep>,
820    pub max_reworks: u32,
821}
822
823#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
824pub struct PipelineStep {
825    pub role: String,
826    pub model_preference: Option<String>,
827    pub prompt_override: Option<String>,
828    pub depends_on: Vec<String>,
829}
830
831impl PipelineConfig {
832    pub fn default_pipeline() -> Self {
833        Self {
834            name: "planner-coder-verifier".into(),
835            steps: vec![
836                PipelineStep {
837                    role: "planner".into(),
838                    model_preference: None,
839                    prompt_override: None,
840                    depends_on: vec![],
841                },
842                PipelineStep {
843                    role: "coder".into(),
844                    model_preference: None,
845                    prompt_override: None,
846                    depends_on: vec!["planner".into()],
847                },
848                PipelineStep {
849                    role: "verifier".into(),
850                    model_preference: None,
851                    prompt_override: None,
852                    depends_on: vec!["coder".into()],
853                },
854            ],
855            max_reworks: 3,
856        }
857    }
858
859    pub fn validate(&self) -> anyhow::Result<()> {
860        if self.steps.is_empty() {
861            anyhow::bail!("Pipeline must have at least one step");
862        }
863        for step in &self.steps {
864            for dep in &step.depends_on {
865                if !self.steps.iter().any(|s| s.role == *dep) {
866                    anyhow::bail!("Step '{}' depends on unknown role '{}'", step.role, dep);
867                }
868            }
869        }
870        Ok(())
871    }
872
873    pub fn from_toml(content: &str) -> anyhow::Result<Self> {
874        Ok(toml::from_str(content)?)
875    }
876
877    pub fn to_toml(&self) -> anyhow::Result<String> {
878        Ok(toml::to_string_pretty(self)?)
879    }
880}
881
882// ─── Profile isolation ──────────────────────────────────────────────────────────
883
884/// Full profile isolation: separate config, memory, agents per profile.
885/// §4: "sparrow profile <create|list|use> — multi-instance profiles"
886pub struct Profile {
887    pub name: String,
888    pub config_dir: std::path::PathBuf,
889    pub state_dir: std::path::PathBuf,
890    pub config: crate::config::Config,
891    pub memory: Arc<dyn Memory>,
892}
893
894impl Profile {
895    pub fn load(name: &str) -> anyhow::Result<Self> {
896        let base_config = dirs::config_dir().unwrap_or_default().join("sparrow");
897        let base_state = dirs::state_dir().unwrap_or_default().join("sparrow");
898
899        let config_dir = base_config.join("profiles").join(name);
900        let state_dir = base_state.join("profiles").join(name);
901
902        std::fs::create_dir_all(&config_dir)?;
903        std::fs::create_dir_all(&state_dir)?;
904
905        let config = if config_dir.join("config.toml").exists() {
906            let content = std::fs::read_to_string(config_dir.join("config.toml"))?;
907            toml::from_str(&content)?
908        } else {
909            // Inherit from default config if available
910            let default = base_config.join("config.toml");
911            if default.exists() {
912                let content = std::fs::read_to_string(&default)?;
913                toml::from_str(&content)?
914            } else {
915                crate::config::Config {
916                    defaults: Default::default(),
917                    routing: Default::default(),
918                    budget: Default::default(),
919                    providers: Default::default(),
920                    surfaces: Default::default(),
921                    experience: Default::default(),
922                    skills: Default::default(),
923                    intel: Default::default(),
924                    permissions: Default::default(),
925                    hooks: Default::default(),
926                    theme: "captain".into(),
927                    config_dir: config_dir.clone(),
928                    state_dir: state_dir.clone(),
929                    forced_model: None,
930                }
931            }
932        };
933
934        let memory: Arc<dyn Memory> = Arc::new(crate::memory::SqliteMemory::open(
935            &state_dir.join("profile.db"),
936        )?);
937
938        Ok(Self {
939            name: name.to_string(),
940            config_dir,
941            state_dir,
942            config,
943            memory,
944        })
945    }
946
947    pub fn create(name: &str) -> anyhow::Result<()> {
948        let base_config = dirs::config_dir().unwrap_or_default().join("sparrow");
949        let config_dir = base_config.join("profiles").join(name);
950        std::fs::create_dir_all(&config_dir)?;
951
952        // Copy default config
953        let default = base_config.join("config.toml");
954        if default.exists() {
955            std::fs::copy(&default, config_dir.join("config.toml"))?;
956        }
957
958        let base_state = dirs::state_dir().unwrap_or_default().join("sparrow");
959        std::fs::create_dir_all(base_state.join("profiles").join(name))?;
960
961        Ok(())
962    }
963
964    pub fn list() -> Vec<String> {
965        let base_config = dirs::config_dir().unwrap_or_default().join("sparrow");
966        let profiles_dir = base_config.join("profiles");
967        let mut names = Vec::new();
968        if let Ok(entries) = std::fs::read_dir(&profiles_dir) {
969            for entry in entries.flatten() {
970                if entry.path().is_dir() {
971                    if let Some(name) = entry.file_name().to_str() {
972                        names.push(name.to_string());
973                    }
974                }
975            }
976        }
977        names.sort();
978        names
979    }
980}