1use crate::engine::{Engine, Task};
2use crate::event::Event;
3use crate::memory::{Fact, Memory};
4use std::sync::Arc;
5use tokio::sync::mpsc;
6
7pub struct Distiller;
13
14impl Distiller {
15 pub async fn distill(memory: &Arc<dyn Memory>, events: &[Event], task_description: &str) {
18 let mut facts = Vec::new();
19
20 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 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 *tool_usage.entry(name.clone()).or_insert(0) += 1;
41
42 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 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 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 detect_preferences(text, &mut pref_hints);
65 }
66 Event::Message { text, role, .. } if role == "user" => {
67 detect_preferences(text, &mut pref_hints);
69 detect_directives(text, &mut directive_hints);
73 }
74 _ => {}
75 }
76 }
77
78 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 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 for d in &directive_hints {
105 facts.push(fact(&format!("user:directive:{}", short_hash(d)), d));
106 }
107 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 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
150fn 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
236fn detect_directives(text: &str, hints: &mut Vec<String>) {
241 const MARKERS: &[&str] = &[
242 "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 "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 let capped: String = cleaned.chars().take(220).collect();
280 hints.push(capped);
281 }
282 }
283 }
284 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
293fn 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 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#[derive(Debug, Clone)]
387pub struct Embeddings {
388 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 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 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
579pub struct ReExecuter {
584 engine: Arc<Engine>,
585}
586
587impl ReExecuter {
588 pub fn new(engine: Arc<Engine>) -> Self {
589 Self { engine }
590 }
591
592 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
607pub struct OAuthFlow;
610
611impl OAuthFlow {
612 pub async fn start_device_flow(
615 device_endpoint: &str,
616 token_endpoint_hint: &str, 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 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
690pub 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
711pub 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#[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
882pub 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 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 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}