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) {
17 let mut facts = Vec::new();
18
19 let mut lang_hints = Vec::new();
21 let mut framework_hints = Vec::new();
22 let mut style_hints = Vec::new();
23
24 for event in events {
25 match event {
26 Event::ToolUseProposed { args, .. } => {
27 if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
28 if path.ends_with(".rs") {
29 lang_hints.push("Rust".to_string());
30 }
31 if path.ends_with(".ts") || path.ends_with(".tsx") {
32 lang_hints.push("TypeScript".to_string());
33 }
34 if path.ends_with(".py") {
35 lang_hints.push("Python".to_string());
36 }
37 if path.ends_with(".go") {
38 lang_hints.push("Go".to_string());
39 }
40 if path.ends_with(".js") || path.ends_with(".jsx") {
41 lang_hints.push("JavaScript".to_string());
42 }
43 }
44 if let Some(content) = args.get("content").and_then(|v| v.as_str()) {
45 if content.contains("Cargo.toml") {
46 framework_hints.push("Rust/Cargo".to_string());
47 }
48 if content.contains("package.json") {
49 framework_hints.push("Node.js".to_string());
50 }
51 if content.contains("go.mod") {
52 framework_hints.push("Go modules".to_string());
53 }
54 }
55 }
56 Event::ThinkingDelta { text, .. } => {
57 if text.contains("refactor") {
58 style_hints.push("prefers refactoring".to_string());
59 }
60 if text.contains("test") || text.contains("TDD") {
61 style_hints.push("test-driven".to_string());
62 }
63 }
64 _ => {}
65 }
66 }
67
68 lang_hints.sort();
70 lang_hints.dedup();
71 framework_hints.sort();
72 framework_hints.dedup();
73 style_hints.sort();
74 style_hints.dedup();
75
76 for lang in &lang_hints {
77 facts.push(Fact {
78 id: uuid::Uuid::new_v4().to_string(),
79 key: "user:language".into(),
80 value: lang.clone(),
81 created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
82 updated_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
83 });
84 }
85 for fw in &framework_hints {
86 facts.push(Fact {
87 id: uuid::Uuid::new_v4().to_string(),
88 key: "user:framework".into(),
89 value: fw.clone(),
90 created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
91 updated_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
92 });
93 }
94 for style in &style_hints {
95 facts.push(Fact {
96 id: uuid::Uuid::new_v4().to_string(),
97 key: "user:style".into(),
98 value: style.clone(),
99 created_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
100 updated_at: chrono::Utc::now().format("%Y-%m-%d").to_string(),
101 });
102 }
103
104 let existing = memory.all_facts();
106 let existing_keys: Vec<&str> = existing.iter().map(|f| f.key.as_str()).collect();
107
108 for fact in facts {
109 if !existing_keys.contains(&fact.key.as_str()) {
110 let _ = memory.remember(fact);
111 }
112 }
113
114 if !lang_hints.is_empty() || !framework_hints.is_empty() {
115 tracing::info!(
116 "Distiller: extracted {} facts from session",
117 lang_hints.len() + framework_hints.len() + style_hints.len()
118 );
119 }
120 }
121}
122
123#[derive(Debug, Clone)]
128pub struct Embeddings {
129 pub vectors: Vec<(String, Vec<f64>)>,
131 dimensions: usize,
132}
133
134impl Embeddings {
135 pub const DEFAULT_DIMENSIONS: usize = 512;
136
137 pub fn new() -> Self {
138 Self {
139 vectors: Vec::new(),
140 dimensions: Self::DEFAULT_DIMENSIONS,
141 }
142 }
143
144 pub fn with_dimensions(dimensions: usize) -> Self {
145 Self {
146 vectors: Vec::new(),
147 dimensions: dimensions.max(16),
148 }
149 }
150
151 pub fn embed(&self, text: &str) -> Vec<f64> {
159 embed_with_dimensions(text, self.dimensions)
160 }
161
162 pub fn add(&mut self, text: &str) {
163 let clean = text.trim();
164 if clean.is_empty() {
165 return;
166 }
167 self.vectors.push((clean.to_string(), self.embed(clean)));
168 }
169
170 pub fn add_many<I, S>(&mut self, texts: I)
171 where
172 I: IntoIterator<Item = S>,
173 S: AsRef<str>,
174 {
175 for text in texts {
176 self.add(text.as_ref());
177 }
178 }
179
180 pub fn search(&self, query: &str, k: usize) -> Vec<String> {
182 self.search_scored(query, k)
183 .into_iter()
184 .map(|(_, text)| text)
185 .collect()
186 }
187
188 pub fn search_scored(&self, query: &str, k: usize) -> Vec<(f64, String)> {
189 if k == 0 {
190 return Vec::new();
191 }
192 let q_embed = self.embed(query);
193 let mut scored: Vec<(f64, usize, &str)> = self
194 .vectors
195 .iter()
196 .enumerate()
197 .map(|(idx, (text, emb))| (cosine_sim(&q_embed, emb), idx, text.as_str()))
198 .collect();
199 scored.sort_by(|a, b| {
200 b.0.partial_cmp(&a.0)
201 .unwrap_or(std::cmp::Ordering::Equal)
202 .then(a.1.cmp(&b.1))
203 });
204 scored
205 .into_iter()
206 .take(k)
207 .filter(|(score, _, _)| *score > 0.0)
208 .map(|(score, _, text)| (score, text.to_string()))
209 .collect()
210 }
211
212 pub fn save_to_path(&self, path: impl AsRef<std::path::Path>) -> anyhow::Result<()> {
213 let snapshot = EmbeddingsSnapshot {
214 dimensions: self.dimensions,
215 texts: self.vectors.iter().map(|(text, _)| text.clone()).collect(),
216 };
217 let json = serde_json::to_string_pretty(&snapshot)?;
218 if let Some(parent) = path.as_ref().parent() {
219 std::fs::create_dir_all(parent)?;
220 }
221 std::fs::write(path, json)?;
222 Ok(())
223 }
224
225 pub fn load_from_path(path: impl AsRef<std::path::Path>) -> anyhow::Result<Self> {
226 let json = std::fs::read_to_string(path)?;
227 let snapshot: EmbeddingsSnapshot = serde_json::from_str(&json)?;
228 let mut index = Self::with_dimensions(snapshot.dimensions);
229 index.add_many(snapshot.texts);
230 Ok(index)
231 }
232}
233
234impl Default for Embeddings {
235 fn default() -> Self {
236 Self::new()
237 }
238}
239
240#[derive(serde::Serialize, serde::Deserialize)]
241struct EmbeddingsSnapshot {
242 dimensions: usize,
243 texts: Vec<String>,
244}
245
246fn embed_with_dimensions(text: &str, dimensions: usize) -> Vec<f64> {
247 let mut vector = vec![0.0; dimensions.max(16)];
248 let tokens = tokenize(text);
249 for token in &tokens {
250 add_feature(&mut vector, token, 1.0);
251 }
252 for pair in tokens.windows(2) {
253 add_feature(&mut vector, &format!("{}__{}", pair[0], pair[1]), 1.35);
254 }
255 for value in &mut vector {
256 if *value != 0.0 {
257 *value = value.signum() * value.abs().ln_1p();
258 }
259 }
260 normalize(&mut vector);
261 vector
262}
263
264fn tokenize(text: &str) -> Vec<String> {
265 let mut tokens = Vec::new();
266 let mut current = String::new();
267 for ch in text.chars() {
268 if ch.is_alphanumeric() {
269 current.extend(ch.to_lowercase());
270 } else if !current.is_empty() {
271 tokens.push(std::mem::take(&mut current));
272 }
273 }
274 if !current.is_empty() {
275 tokens.push(current);
276 }
277 tokens
278}
279
280fn add_feature(vector: &mut [f64], feature: &str, weight: f64) {
281 let hash = fnv1a64(feature.as_bytes());
282 let idx = (hash as usize) % vector.len();
283 let sign = if hash & (1 << 63) == 0 { 1.0 } else { -1.0 };
284 vector[idx] += sign * weight;
285}
286
287fn fnv1a64(bytes: &[u8]) -> u64 {
288 let mut hash = 0xcbf29ce484222325u64;
289 for byte in bytes {
290 hash ^= *byte as u64;
291 hash = hash.wrapping_mul(0x100000001b3);
292 }
293 hash
294}
295
296fn normalize(vector: &mut [f64]) {
297 let norm = vector.iter().map(|v| v * v).sum::<f64>().sqrt();
298 if norm > 0.0 {
299 for value in vector {
300 *value /= norm;
301 }
302 }
303}
304
305fn cosine_sim(a: &[f64], b: &[f64]) -> f64 {
306 let len = a.len().min(b.len());
307 if len == 0 {
308 return 0.0;
309 }
310 let dot: f64 = a.iter().zip(b.iter()).take(len).map(|(x, y)| x * y).sum();
311 let norm_a: f64 = a.iter().take(len).map(|x| x * x).sum::<f64>().sqrt();
312 let norm_b: f64 = b.iter().take(len).map(|x| x * x).sum::<f64>().sqrt();
313 if norm_a == 0.0 || norm_b == 0.0 {
314 0.0
315 } else {
316 dot / (norm_a * norm_b)
317 }
318}
319
320pub struct ReExecuter {
325 engine: Arc<Engine>,
326}
327
328impl ReExecuter {
329 pub fn new(engine: Arc<Engine>) -> Self {
330 Self { engine }
331 }
332
333 pub async fn re_execute(
336 &self,
337 transcript: &crate::runtime::recorder::Transcript,
338 ) -> anyhow::Result<crate::event::OutcomeSummary> {
339 let (tx, _rx) = mpsc::unbounded_channel::<Event>();
340 let task = Task {
341 description: transcript.inputs.task.clone(),
342 context: vec![],
343 };
344 self.engine.drive(task, tx).await
345 }
346}
347
348pub struct OAuthFlow;
351
352impl OAuthFlow {
353 pub async fn start_device_flow(
356 device_endpoint: &str,
357 token_endpoint_hint: &str, client_id: &str,
359 scope: &str,
360 ) -> anyhow::Result<(String, String, String)> {
361 let _ = token_endpoint_hint;
362 let client = reqwest::Client::new();
363 let resp: serde_json::Value = client
364 .post(device_endpoint)
365 .form(&[("client_id", client_id), ("scope", scope)])
366 .send()
367 .await?
368 .json()
369 .await?;
370
371 let verification_uri = resp["verification_uri"]
372 .as_str()
373 .or_else(|| resp["verification_url"].as_str())
374 .unwrap_or("")
375 .to_string();
376 let user_code = resp["user_code"].as_str().unwrap_or("").to_string();
377 let device_code = resp["device_code"].as_str().unwrap_or("").to_string();
378
379 if device_code.is_empty() {
380 anyhow::bail!("Device flow start failed — provider response: {}", resp);
381 }
382
383 Ok((verification_uri, user_code, device_code))
384 }
385
386 pub async fn poll_token(
388 token_endpoint: &str,
389 client_id: &str,
390 device_code: &str,
391 timeout_secs: u64,
392 ) -> anyhow::Result<String> {
393 let client = reqwest::Client::new();
394 let start = std::time::Instant::now();
395
396 loop {
397 if start.elapsed().as_secs() > timeout_secs {
398 anyhow::bail!("OAuth timed out after {}s", timeout_secs);
399 }
400
401 let resp: serde_json::Value = client
402 .post(token_endpoint)
403 .form(&[
404 ("client_id", client_id),
405 ("device_code", device_code),
406 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
407 ])
408 .send()
409 .await?
410 .json()
411 .await?;
412
413 if let Some(token) = resp["access_token"].as_str() {
414 return Ok(token.to_string());
415 }
416
417 match resp["error"].as_str() {
418 Some("authorization_pending") | Some("slow_down") => {
419 tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
420 continue;
421 }
422 Some(e) => anyhow::bail!("OAuth error: {}", e),
423 None => {
424 tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
425 }
426 }
427 }
428 }
429}
430
431pub const IBM_PLEX_MONO_URL: &str =
437 "https://github.com/IBM/plex/releases/latest/download/IBM-Plex-Mono.zip";
438
439pub fn ibm_plex_install_instructions() -> String {
440 r#"IBM Plex Mono — recommended font for Sparrow TUI.
441
442Install:
443 Linux: sudo apt install fonts-ibm-plex
444 macOS: brew install font-ibm-plex
445 Windows: Download from https://github.com/IBM/plex/releases
446
447Then update your terminal to use "IBM Plex Mono" as the font.
448"#
449 .to_string()
450}
451
452pub struct ChatSession {
457 engine: Arc<Engine>,
458 history: Vec<crate::provider::Msg>,
459 running: bool,
460}
461
462impl ChatSession {
463 pub fn new(engine: Arc<Engine>) -> Self {
464 Self {
465 engine,
466 history: Vec::new(),
467 running: true,
468 }
469 }
470
471 pub async fn run_interactive(&mut self) -> anyhow::Result<()> {
472 use std::io::{self, Write};
473
474 println!("═══ Sparrow Chat ═══");
475 println!("Type your message and press Enter. Type /exit to quit.");
476 println!();
477
478 while self.running {
479 print!("◆ you › ");
480 io::stdout().flush()?;
481
482 let mut input = String::new();
483 io::stdin().read_line(&mut input)?;
484 let input = input.trim().to_string();
485
486 if input.is_empty() {
487 continue;
488 }
489 if input == "/exit" || input == "/quit" {
490 break;
491 }
492
493 self.history.push(crate::provider::Msg {
494 role: "user".into(),
495 content: vec![crate::provider::ContentBlock::Text {
496 text: input.clone(),
497 }],
498 });
499
500 let (tx, mut rx) = mpsc::unbounded_channel::<Event>();
501 let task = Task {
502 description: input.clone(),
503 context: self.history.clone(),
504 };
505
506 let engine = self.engine.clone();
507 let handle = tokio::spawn(async move { engine.drive(task, tx).await });
508
509 while let Some(event) = rx.recv().await {
510 match &event {
511 Event::ThinkingDelta { text, .. } => {
512 print!("{}", text);
513 io::stdout().flush()?;
514 }
515 Event::RunFinished { outcome, .. } => {
516 println!(
517 "\n── {} | ${:.4} {}──",
518 outcome.status,
519 outcome.cost_usd,
520 crate::cost::format_comparison_oneliner(
521 outcome.cost_usd,
522 &outcome.tokens
523 )
524 );
525 }
526 Event::Error { message, .. } => {
527 eprintln!("\nError: {}", message);
528 }
529 _ => {}
530 }
531 }
532
533 match handle.await? {
534 Ok(outcome) => {
535 self.history.push(crate::provider::Msg {
536 role: "assistant".into(),
537 content: vec![crate::provider::ContentBlock::Text {
538 text: format!("[{}]", outcome.status),
539 }],
540 });
541 }
542 Err(e) => {
543 eprintln!("Chat error: {}", e);
544 }
545 }
546 println!();
547 }
548
549 Ok(())
550 }
551}
552
553#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
558pub struct PipelineConfig {
559 pub name: String,
560 pub steps: Vec<PipelineStep>,
561 pub max_reworks: u32,
562}
563
564#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
565pub struct PipelineStep {
566 pub role: String,
567 pub model_preference: Option<String>,
568 pub prompt_override: Option<String>,
569 pub depends_on: Vec<String>,
570}
571
572impl PipelineConfig {
573 pub fn default_pipeline() -> Self {
574 Self {
575 name: "planner-coder-verifier".into(),
576 steps: vec![
577 PipelineStep {
578 role: "planner".into(),
579 model_preference: None,
580 prompt_override: None,
581 depends_on: vec![],
582 },
583 PipelineStep {
584 role: "coder".into(),
585 model_preference: None,
586 prompt_override: None,
587 depends_on: vec!["planner".into()],
588 },
589 PipelineStep {
590 role: "verifier".into(),
591 model_preference: None,
592 prompt_override: None,
593 depends_on: vec!["coder".into()],
594 },
595 ],
596 max_reworks: 3,
597 }
598 }
599
600 pub fn validate(&self) -> anyhow::Result<()> {
601 if self.steps.is_empty() {
602 anyhow::bail!("Pipeline must have at least one step");
603 }
604 for step in &self.steps {
605 for dep in &step.depends_on {
606 if !self.steps.iter().any(|s| s.role == *dep) {
607 anyhow::bail!("Step '{}' depends on unknown role '{}'", step.role, dep);
608 }
609 }
610 }
611 Ok(())
612 }
613
614 pub fn from_toml(content: &str) -> anyhow::Result<Self> {
615 Ok(toml::from_str(content)?)
616 }
617
618 pub fn to_toml(&self) -> anyhow::Result<String> {
619 Ok(toml::to_string_pretty(self)?)
620 }
621}
622
623pub struct Profile {
628 pub name: String,
629 pub config_dir: std::path::PathBuf,
630 pub state_dir: std::path::PathBuf,
631 pub config: crate::config::Config,
632 pub memory: Arc<dyn Memory>,
633}
634
635impl Profile {
636 pub fn load(name: &str) -> anyhow::Result<Self> {
637 let base_config = dirs::config_dir().unwrap_or_default().join("sparrow");
638 let base_state = dirs::state_dir().unwrap_or_default().join("sparrow");
639
640 let config_dir = base_config.join("profiles").join(name);
641 let state_dir = base_state.join("profiles").join(name);
642
643 std::fs::create_dir_all(&config_dir)?;
644 std::fs::create_dir_all(&state_dir)?;
645
646 let config = if config_dir.join("config.toml").exists() {
647 let content = std::fs::read_to_string(config_dir.join("config.toml"))?;
648 toml::from_str(&content)?
649 } else {
650 let default = base_config.join("config.toml");
652 if default.exists() {
653 let content = std::fs::read_to_string(&default)?;
654 toml::from_str(&content)?
655 } else {
656 crate::config::Config {
657 defaults: Default::default(),
658 routing: Default::default(),
659 budget: Default::default(),
660 providers: Default::default(),
661 surfaces: Default::default(),
662 skills: Default::default(),
663 permissions: Default::default(),
664 hooks: Default::default(),
665 theme: "captain".into(),
666 config_dir: config_dir.clone(),
667 state_dir: state_dir.clone(),
668 forced_model: None,
669 }
670 }
671 };
672
673 let memory: Arc<dyn Memory> = Arc::new(crate::memory::SqliteMemory::open(
674 &state_dir.join("profile.db"),
675 )?);
676
677 Ok(Self {
678 name: name.to_string(),
679 config_dir,
680 state_dir,
681 config,
682 memory,
683 })
684 }
685
686 pub fn create(name: &str) -> anyhow::Result<()> {
687 let base_config = dirs::config_dir().unwrap_or_default().join("sparrow");
688 let config_dir = base_config.join("profiles").join(name);
689 std::fs::create_dir_all(&config_dir)?;
690
691 let default = base_config.join("config.toml");
693 if default.exists() {
694 std::fs::copy(&default, config_dir.join("config.toml"))?;
695 }
696
697 let base_state = dirs::state_dir().unwrap_or_default().join("sparrow");
698 std::fs::create_dir_all(base_state.join("profiles").join(name))?;
699
700 Ok(())
701 }
702
703 pub fn list() -> Vec<String> {
704 let base_config = dirs::config_dir().unwrap_or_default().join("sparrow");
705 let profiles_dir = base_config.join("profiles");
706 let mut names = Vec::new();
707 if let Ok(entries) = std::fs::read_dir(&profiles_dir) {
708 for entry in entries.flatten() {
709 if entry.path().is_dir() {
710 if let Some(name) = entry.file_name().to_str() {
711 names.push(name.to_string());
712 }
713 }
714 }
715 }
716 names.sort();
717 names
718 }
719}