diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a1f6f50 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Test & Lint Suite + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache Cargo Dependencies + uses: Swatinem/rust-cache@v2 + + - name: Verify Code Formatting + run: cargo fmt --check + + - name: Run Clippy Linter + run: cargo clippy --all-targets -- -D warnings + + - name: Run Cargo Test Suite + run: cargo test --all + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install Bun Dependencies + run: bun install --frozen-lockfile + + - name: Run TypeScript Typecheck + run: bun run typecheck + + - name: Run Bun Test Suite + run: bun test + + - name: Run Oxlint Anti-Slop Linter + run: bunx oxlint src/ diff --git a/.gitignore b/.gitignore index 4931f2b..fa24155 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ browser_profiles/ # Logs & Debug logs/ *.log +*.dump +erl_crash.dump npm-debug.log* yarn-debug.log* pnpm-debug.log* diff --git a/src/agents/mod.rs b/src/agents/mod.rs index 4b0f38b..60ccfb5 100644 --- a/src/agents/mod.rs +++ b/src/agents/mod.rs @@ -17,7 +17,5 @@ pub use opportunity::{ pub use orchestrator::{MasterOrchestrator, OrchestrationReport}; pub use reddit_2_skill::{GeneratedSkillPackage, Reddit2SkillEngine}; pub use router::{DagStep, DagWave, DynamicRouter, ExecutionDag, IntentCategory}; -pub use tool_agents::{ - DiggerAgent, DrafterAgent, MinerAgent, OrchestrationContext, ToolAgent, -}; +pub use tool_agents::{DiggerAgent, DrafterAgent, MinerAgent, OrchestrationContext, ToolAgent}; pub use velocity::{InteractionVerdict, VelocityGovernor}; diff --git a/src/agents/opportunity.rs b/src/agents/opportunity.rs index f570762..f100a71 100644 --- a/src/agents/opportunity.rs +++ b/src/agents/opportunity.rs @@ -63,7 +63,10 @@ impl RedditThread { if !self.url.is_empty() { self.url.clone() } else { - format!("https://reddit.com/r/{}/comments/{}/", self.subreddit, self.id) + format!( + "https://reddit.com/r/{}/comments/{}/", + self.subreddit, self.id + ) } } } @@ -204,7 +207,10 @@ impl OpportunityWorker { id: s.id.clone(), subreddit: s.subreddit.clone(), title: s.title.clone(), - body: s.full_body.clone().unwrap_or_else(|| s.body_snippet.clone()), + body: s + .full_body + .clone() + .unwrap_or_else(|| s.body_snippet.clone()), author: s.author.clone(), created_utc: s.created_utc, num_comments: s.num_comments, @@ -423,10 +429,14 @@ impl OpportunityWorker { // Drop off-topic threads that have no grounding in the community ontology continue; } - crate::ontology::EngagementVerdict::CertifiedSafe { alignment_score, .. } => { + crate::ontology::EngagementVerdict::CertifiedSafe { + alignment_score, .. + } => { // Boost product fit and composite score with certified grounding - opp.product_fit_score = (opp.product_fit_score * (1.0 + *alignment_score as f64 * 0.25)).min(1.0); - opp.composite_score = (opp.composite_score * (1.0 + *alignment_score as f64 * 0.2)).min(1.0); + opp.product_fit_score = + (opp.product_fit_score * (1.0 + *alignment_score as f64 * 0.25)).min(1.0); + opp.composite_score = + (opp.composite_score * (1.0 + *alignment_score as f64 * 0.2)).min(1.0); opp.preflight_verdict = Some(verdict); final_ranked.push(opp); } @@ -505,7 +515,11 @@ impl OpportunityWorker { .contains(&OpportunitySignal::UrgentWorkflow)) && opportunity.product_fit_score >= 0.65; - let (draft_text, review_status, product_named, affiliation_disclosed) = if let Some(crate::ontology::EngagementVerdict::CertifiedSafe { angle, .. }) = &opportunity.preflight_verdict { + let (draft_text, review_status, product_named, affiliation_disclosed) = if let Some( + crate::ontology::EngagementVerdict::CertifiedSafe { angle, .. }, + ) = + &opportunity.preflight_verdict + { let disclosure = if !angle.mandatory_disclosure.is_empty() { format!("{}, ", angle.mandatory_disclosure) } else { @@ -706,15 +720,22 @@ mod tests { ); assert_eq!(digger_res.total_sources, 1); assert_eq!(digger_res.sources[0].id, "d1"); - assert_eq!(digger_res.sources[0].canonical_url, "https://reddit.com/r/SaaS/comments/d1/"); + assert_eq!( + digger_res.sources[0].canonical_url, + "https://reddit.com/r/SaaS/comments/d1/" + ); // 2. Miner analyzes content, intent signals, and ranks let miner_res = OpportunityWorker::mine_content(&digger_res.sources, &product, 1500); assert_eq!(miner_res.analyzed_count, 1); let opp = &miner_res.opportunities[0]; assert_eq!(opp.thread.id, "d1"); - assert!(opp.detected_signals.contains(&OpportunitySignal::RecommendationAsk)); - assert!(opp.detected_signals.contains(&OpportunitySignal::CompetitorFrustration)); + assert!(opp + .detected_signals + .contains(&OpportunitySignal::RecommendationAsk)); + assert!(opp + .detected_signals + .contains(&OpportunitySignal::CompetitorFrustration)); assert!(opp.composite_score > 0.70); } @@ -726,7 +747,8 @@ mod tests { id: "t_safe".to_string(), subreddit: "SaaS".to_string(), title: "Need recommendations for customer outreach tools".to_string(), - body: "Tired of wasting hours on Reddit outreach. Is there a safe tool?".to_string(), + body: "Tired of wasting hours on Reddit outreach. Is there a safe tool?" + .to_string(), author: "founder1".to_string(), created_utc: 1200, num_comments: 5, @@ -770,7 +792,9 @@ mod tests { "certifier_1", ); - let ranked = OpportunityWorker::discover_opportunities_with_ontology(&threads, &product, &ontology, 1500); + let ranked = OpportunityWorker::discover_opportunities_with_ontology( + &threads, &product, &ontology, 1500, + ); // t_spam must be completely filtered out by HighRiskViolation preflight check assert_eq!(ranked.len(), 1); @@ -782,7 +806,9 @@ mod tests { // Test drafted reply uses the certified angle let draft = OpportunityWorker::draft_reply(&ranked[0], &product).expect("draft"); - assert!(draft.draft_text.contains("building an open-source local triage tool")); + assert!(draft + .draft_text + .contains("building an open-source local triage tool")); assert_eq!(draft.review_status, ReviewStatus::Approved); } } diff --git a/src/agents/orchestrator.rs b/src/agents/orchestrator.rs index 02b1bf9..2425fb9 100644 --- a/src/agents/orchestrator.rs +++ b/src/agents/orchestrator.rs @@ -92,7 +92,8 @@ impl MasterOrchestrator { status: "pending_human_approval".to_string(), steps_completed, total_steps, - context_summary: "Workflow paused at cryptographic action approval gate.".to_string(), + context_summary: "Workflow paused at cryptographic action approval gate." + .to_string(), execution_log: context.execution_log, result: serde_json::json!({ "paused_at_step": step.step_id, @@ -151,6 +152,12 @@ impl MasterOrchestrator { } } +impl Default for MasterOrchestrator { + fn default() -> Self { + Self::new() + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/agents/reddit_2_skill.rs b/src/agents/reddit_2_skill.rs index 72cf86c..1c05c1a 100644 --- a/src/agents/reddit_2_skill.rs +++ b/src/agents/reddit_2_skill.rs @@ -1,5 +1,5 @@ -use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; use tracing::info; use crate::agents::opportunity::RedditThread; @@ -44,17 +44,36 @@ impl Reddit2SkillEngine { let lower = topic.to_lowercase(); let mut subs = Vec::new(); - if lower.contains("vibe") || lower.contains("ai code") || lower.contains("cursor") || lower.contains("copilot") { + if lower.contains("vibe") + || lower.contains("ai code") + || lower.contains("cursor") + || lower.contains("copilot") + { subs.extend_from_slice(&["vibecoding", "ChatGPTCoding", "webdev", "programming"]); - } else if lower.contains("shopify") || lower.contains("ecommerce") || lower.contains("store") { + } else if lower.contains("shopify") + || lower.contains("ecommerce") + || lower.contains("store") + { subs.extend_from_slice(&["shopify", "ecommerce", "dropship", "webdev"]); - } else if lower.contains("marketing") || lower.contains("growth") || lower.contains("lead") || lower.contains("customer") { + } else if lower.contains("marketing") + || lower.contains("growth") + || lower.contains("lead") + || lower.contains("customer") + { subs.extend_from_slice(&["SaaS", "Entrepreneur", "marketing", "indiehackers"]); - } else if lower.contains("fastapi") || lower.contains("python") || lower.contains("backend") { + } else if lower.contains("fastapi") || lower.contains("python") || lower.contains("backend") + { subs.extend_from_slice(&["FastAPI", "Python", "webdev"]); - } else if lower.contains("react") || lower.contains("frontend") || lower.contains("next") || lower.contains("css") { + } else if lower.contains("react") + || lower.contains("frontend") + || lower.contains("next") + || lower.contains("css") + { subs.extend_from_slice(&["reactjs", "nextjs", "webdev", "frontend"]); - } else if lower.contains("rust") || lower.contains("systems") || lower.contains("performance") { + } else if lower.contains("rust") + || lower.contains("systems") + || lower.contains("performance") + { subs.extend_from_slice(&["rust", "programming", "systems"]); } else { subs.extend_from_slice(&["SaaS", "webdev", "programming", "technology"]); @@ -71,7 +90,11 @@ impl Reddit2SkillEngine { .map(|c| if c.is_alphanumeric() { c } else { '-' }) .collect(); let trimmed = clean.trim_matches('-'); - let parts: Vec<&str> = trimmed.split('-').filter(|p| !p.is_empty()).take(4).collect(); + let parts: Vec<&str> = trimmed + .split('-') + .filter(|p| !p.is_empty()) + .take(4) + .collect(); if parts.is_empty() { "reddit-mined-skill".to_string() } else { @@ -108,7 +131,10 @@ impl Reddit2SkillEngine { .await; let thread_count = threads.len(); - info!(threads_found = thread_count, "Crawled Reddit threads for skill synthesis"); + info!( + threads_found = thread_count, + "Crawled Reddit threads for skill synthesis" + ); // Synthesize OmniSkill artifacts let skill_package = self.synthesize_skill(&skill_id, topic, &subreddits, &threads)?; @@ -130,7 +156,10 @@ impl Reddit2SkillEngine { subreddits: &[String], threads: &[RedditThread], ) -> Result { - let display_name = format!("🛡️ {} Operator (Reddit-Synthesized)", skill_id.replace('-', " ")); + let display_name = format!( + "🛡️ {} Operator (Reddit-Synthesized)", + skill_id.replace('-', " ") + ); let sub_list = subreddits.join(", r/"); // 1. Build SKILL.md @@ -148,7 +177,10 @@ impl Reddit2SkillEngine { " Addresses real-world failure modes, undocumented traps, and battle-tested solutions\n for '{}' adhering to OmniSkill v6.2.0 and Poteto Mode standards.\n", topic )); - skill_md.push_str(&format!("argument-hint: '{} audit | {} execute'\n", skill_id, skill_id)); + skill_md.push_str(&format!( + "argument-hint: '{} audit | {} execute'\n", + skill_id, skill_id + )); skill_md.push_str("allowed-tools: Bash, Read, Write\n"); skill_md.push_str("metadata:\n"); skill_md.push_str(" openclaw:\n"); @@ -172,11 +204,15 @@ impl Reddit2SkillEngine { skill_md.push_str("## 4-Step Operational Loop\n\n"); skill_md.push_str("### Step 1: Preflight & Context Discovery\n"); - skill_md.push_str("- Inspect current environment, dependencies, and configuration states.\n"); - skill_md.push_str("- Execute `scripts/preflight_check.py` to identify silent regressions.\n\n"); + skill_md + .push_str("- Inspect current environment, dependencies, and configuration states.\n"); + skill_md + .push_str("- Execute `scripts/preflight_check.py` to identify silent regressions.\n\n"); skill_md.push_str("### Step 2: Anti-Pattern Detection\n"); - skill_md.push_str("- Check against known failure modes documented in `references/reddit_evidence.md`.\n"); + skill_md.push_str( + "- Check against known failure modes documented in `references/reddit_evidence.md`.\n", + ); skill_md.push_str("- Flag and block recursive trial-and-error edits.\n\n"); skill_md.push_str("### Step 3: Targeted Remediation\n"); @@ -189,16 +225,19 @@ impl Reddit2SkillEngine { // Extract community ontology domain graph for grounding let primary_sub = subreddits.first().map(|s| s.as_str()).unwrap_or("reddit"); - let ontology = crate::ontology::AutoOntologyBuilder::build_from_threads( - primary_sub, - topic, - threads, - ); + let ontology = + crate::ontology::AutoOntologyBuilder::build_from_threads(primary_sub, topic, threads); skill_md.push_str("## Community Domain Ontology & Taboo Boundaries\n\n"); - skill_md.push_str(&format!("*Extracted semantic boundaries for r/{}:*\n\n", primary_sub)); + skill_md.push_str(&format!( + "*Extracted semantic boundaries for r/{}:*\n\n", + primary_sub + )); for rule in &ontology.rules { - skill_md.push_str(&format!("- **Rule: {}** ({:?}): {}\n", rule.rule_name, rule.severity, rule.description)); + skill_md.push_str(&format!( + "- **Rule: {}** ({:?}): {}\n", + rule.rule_name, rule.severity, rule.description + )); } if !ontology.terms.is_empty() { skill_md.push_str("\n### Verified Practitioner Concepts\n"); @@ -206,11 +245,14 @@ impl Reddit2SkillEngine { skill_md.push_str(&format!("- `{}` ({:?})\n", term.term, term.category)); } } - skill_md.push_str("\n"); + skill_md.push('\n'); // 2. Build references/reddit_evidence.md let mut evidence_md = String::new(); - evidence_md.push_str(&format!("# Reddit Empirical Evidence Dossier: {}\n\n", topic)); + evidence_md.push_str(&format!( + "# Reddit Empirical Evidence Dossier: {}\n\n", + topic + )); evidence_md.push_str(&format!("*Mined across communities: r/{}*\n\n", sub_list)); evidence_md.push_str("## Analyzed High-Signal Threads\n\n"); evidence_md.push_str("| # | Title | Subreddit | Comments | Direct Thread URL |\n"); @@ -236,7 +278,10 @@ impl Reddit2SkillEngine { if !ontology.relationships.is_empty() { evidence_md.push_str("\n## Discovered Problem-Solution Semantic Graph\n\n"); for rel in ontology.relationships.iter().take(10) { - evidence_md.push_str(&format!("- `{}` --[{:?}]--> `{}` (confidence: {:.2})\n", rel.from_term, rel.relation, rel.to_term, rel.confidence)); + evidence_md.push_str(&format!( + "- `{}` --[{:?}]--> `{}` (confidence: {:.2})\n", + rel.from_term, rel.relation, rel.to_term, rel.confidence + )); } } @@ -269,7 +314,8 @@ def run_preflight(): if __name__ == "__main__": run_preflight() -"#.to_string(); +"# + .to_string(); Ok(GeneratedSkillPackage { skill_name: skill_id.to_string(), @@ -301,7 +347,10 @@ if __name__ == "__main__": std::fs::write(skill_dir.join("SKILL.md"), &package.skill_md_content)?; // Write references/reddit_evidence.md - std::fs::write(references_dir.join("reddit_evidence.md"), &package.evidence_md_content)?; + std::fs::write( + references_dir.join("reddit_evidence.md"), + &package.evidence_md_content, + )?; // Write scripts/preflight_check.py let script_path = scripts_dir.join("preflight_check.py"); @@ -351,29 +400,34 @@ mod tests { #[test] fn test_synthesize_skill_structure() { let engine = Reddit2SkillEngine::new(); - let threads = vec![ - RedditThread { - id: "101".to_string(), - subreddit: "vibecoding".to_string(), - title: "Fix the bug trap is real".to_string(), - body: "Cascading failures".to_string(), - author: "alice".to_string(), - created_utc: 1700000000, - num_comments: 25, - is_locked: false, - is_archived: false, - url: "https://reddit.com/r/vibecoding/comments/101/".to_string(), - } - ]; + let threads = vec![RedditThread { + id: "101".to_string(), + subreddit: "vibecoding".to_string(), + title: "Fix the bug trap is real".to_string(), + body: "Cascading failures".to_string(), + author: "alice".to_string(), + created_utc: 1700000000, + num_comments: 25, + is_locked: false, + is_archived: false, + url: "https://reddit.com/r/vibecoding/comments/101/".to_string(), + }]; let package = engine - .synthesize_skill("vibe-defense", "vibe coding defense", &["vibecoding".to_string()], &threads) + .synthesize_skill( + "vibe-defense", + "vibe coding defense", + &["vibecoding".to_string()], + &threads, + ) .unwrap(); assert_eq!(package.skill_name, "vibe-defense"); assert!(package.skill_md_content.contains("name: vibe-defense")); assert!(package.skill_md_content.contains("Core Invariants")); - assert!(package.evidence_md_content.contains("Fix the bug trap is real")); + assert!(package + .evidence_md_content + .contains("Fix the bug trap is real")); assert!(package.script_content.contains("run_preflight")); } } diff --git a/src/agents/router.rs b/src/agents/router.rs index 43c2559..f12197a 100644 --- a/src/agents/router.rs +++ b/src/agents/router.rs @@ -60,7 +60,9 @@ pub struct DynamicRouter; impl DynamicRouter { /// Detects language (Arabic, English, or mixed). pub fn detect_language(prompt: &str) -> String { - let has_arabic = prompt.chars().any(|c| ('\u{0600}'..='\u{06FF}').contains(&c)); + let has_arabic = prompt + .chars() + .any(|c| ('\u{0600}'..='\u{06FF}').contains(&c)); if has_arabic { "ar".to_string() } else { @@ -162,7 +164,10 @@ impl DynamicRouter { } /// Constructs an execution DAG for a user prompt. - pub fn build_execution_dag(prompt: &str, target_context: Option) -> ExecutionDag { + pub fn build_execution_dag( + prompt: &str, + target_context: Option, + ) -> ExecutionDag { let lang = Self::detect_language(prompt); let (intent, confidence) = Self::classify_intent(prompt); @@ -173,7 +178,11 @@ impl DynamicRouter { .to_string(); let subreddits = context["subreddits"] .as_array() - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect::>()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>() + }) .unwrap_or_else(|| vec!["SaaS".to_string(), "startups".to_string()]); let mut steps = Vec::new(); @@ -187,7 +196,9 @@ impl DynamicRouter { step_id: "step_dig_sources".to_string(), agent_role: "DiggerAgent".to_string(), tool_name: "reddit_digger".to_string(), - description: "Determine candidate subreddits and deeply aggregate source threads".to_string(), + description: + "Determine candidate subreddits and deeply aggregate source threads" + .to_string(), dependencies: vec![], requires_human_approval: false, parameters: serde_json::json!({ @@ -232,7 +243,9 @@ impl DynamicRouter { step_id: "step_draft_response".to_string(), agent_role: "DrafterAgent".to_string(), tool_name: "reddit_draft_response".to_string(), - description: "Formulate 25-55 word Experience Grammar reply with mandatory disclosure".to_string(), + description: + "Formulate 25-55 word Experience Grammar reply with mandatory disclosure" + .to_string(), dependencies: vec!["step_mine_content".to_string()], requires_human_approval: false, parameters: serde_json::json!({}), @@ -243,7 +256,10 @@ impl DynamicRouter { }); safety_notes.push("Multi-tier keyless scraping preserves anonymity.".to_string()); - safety_notes.push("Replies strictly enforced within 25-55 words with anti-slop rules.".to_string()); + safety_notes.push( + "Replies strictly enforced within 25-55 words with anti-slop rules." + .to_string(), + ); } IntentCategory::SkillSynthesis => { @@ -251,7 +267,8 @@ impl DynamicRouter { step_id: "step_synthesize_skill".to_string(), agent_role: "SkillSynthesizerAgent".to_string(), tool_name: "reddit_2_skill".to_string(), - description: "Mine deep community consensus and forge OmniSkill v6.2.0 package".to_string(), + description: "Mine deep community consensus and forge OmniSkill v6.2.0 package" + .to_string(), dependencies: vec![], requires_human_approval: false, parameters: serde_json::json!({ @@ -263,7 +280,9 @@ impl DynamicRouter { wave_index: 0, step_ids: vec!["step_synthesize_skill".to_string()], }); - safety_notes.push("Generated skill follows AgentSkills.io v1 & Poteto principles.".to_string()); + safety_notes.push( + "Generated skill follows AgentSkills.io v1 & Poteto principles.".to_string(), + ); } IntentCategory::ContentPublishing => { @@ -272,7 +291,9 @@ impl DynamicRouter { step_id: "step_prepare_action".to_string(), agent_role: "PlannerAgent".to_string(), tool_name: "reddit_prepare_comment".to_string(), - description: "Validate content and calculate cryptographic SHA-256 ActionPlan hash".to_string(), + description: + "Validate content and calculate cryptographic SHA-256 ActionPlan hash" + .to_string(), dependencies: vec![], requires_human_approval: false, parameters: serde_json::json!({ @@ -305,7 +326,8 @@ impl DynamicRouter { step_id: "step_publish_action".to_string(), agent_role: "PublisherAgent".to_string(), tool_name: "reddit_publish_prepared_comment".to_string(), - description: "Publish verified payload through authenticated driver".to_string(), + description: "Publish verified payload through authenticated driver" + .to_string(), dependencies: vec!["step_approve_action".to_string()], requires_human_approval: false, parameters: serde_json::json!({}), @@ -315,7 +337,10 @@ impl DynamicRouter { step_ids: vec!["step_publish_action".to_string()], }); - safety_notes.push("Fail-closed invariant: unapproved actions cannot mutate Reddit state.".to_string()); + safety_notes.push( + "Fail-closed invariant: unapproved actions cannot mutate Reddit state." + .to_string(), + ); } IntentCategory::AccountGovernance => { @@ -323,7 +348,8 @@ impl DynamicRouter { step_id: "step_audit_ratio".to_string(), agent_role: "AuditorAgent".to_string(), tool_name: "reddit_audit_community_ratio".to_string(), - description: "Evaluate 90/10 content-to-promo participation balance".to_string(), + description: "Evaluate 90/10 content-to-promo participation balance" + .to_string(), dependencies: vec![], requires_human_approval: false, parameters: serde_json::json!({}), @@ -339,7 +365,10 @@ impl DynamicRouter { }); waves.push(DagWave { wave_index: 0, - step_ids: vec!["step_audit_ratio".to_string(), "step_account_health".to_string()], + step_ids: vec![ + "step_audit_ratio".to_string(), + "step_account_health".to_string(), + ], }); safety_notes.push("Non-destructive read-only governance checks.".to_string()); } @@ -349,7 +378,8 @@ impl DynamicRouter { step_id: "step_diagnose".to_string(), agent_role: "DiagnosticianAgent".to_string(), tool_name: "reddit_diagnose_issue".to_string(), - description: "Isolate root cause from API error or rejection payload".to_string(), + description: "Isolate root cause from API error or rejection payload" + .to_string(), dependencies: vec![], requires_human_approval: false, parameters: serde_json::json!({ @@ -415,15 +445,18 @@ mod tests { #[test] fn test_classify_intent_english_and_arabic() { - let (intent_en, conf_en) = DynamicRouter::classify_intent("Find SaaS opportunities on Reddit"); + let (intent_en, conf_en) = + DynamicRouter::classify_intent("Find SaaS opportunities on Reddit"); assert_eq!(intent_en, IntentCategory::OpportunityHunting); assert!(conf_en >= 0.90); - let (intent_ar, conf_ar) = DynamicRouter::classify_intent("ابحث عن فرص تسويقية للشركات الناشئة"); + let (intent_ar, conf_ar) = + DynamicRouter::classify_intent("ابحث عن فرص تسويقية للشركات الناشئة"); assert_eq!(intent_ar, IntentCategory::OpportunityHunting); assert!(conf_ar >= 0.90); - let (intent_skill, _) = DynamicRouter::classify_intent("صمم مهارة للتعامل مع أخطاء الـ vibe coding"); + let (intent_skill, _) = + DynamicRouter::classify_intent("صمم مهارة للتعامل مع أخطاء الـ vibe coding"); assert_eq!(intent_skill, IntentCategory::SkillSynthesis); let (intent_diag, _) = DynamicRouter::classify_intent("شخص لي سبب حظر الحساب أو الخطأ"); diff --git a/src/agents/tool_agents.rs b/src/agents/tool_agents.rs index e5f3254..f106428 100644 --- a/src/agents/tool_agents.rs +++ b/src/agents/tool_agents.rs @@ -48,6 +48,12 @@ impl DiggerAgent { } } +impl Default for DiggerAgent { + fn default() -> Self { + Self::new() + } +} + #[async_trait] impl ToolAgent for DiggerAgent { fn name(&self) -> &'static str { @@ -61,10 +67,7 @@ impl ToolAgent for DiggerAgent { } async fn execute(&self, ctx: &mut OrchestrationContext) -> Result { - let product = ctx - .product - .clone() - .unwrap_or_else(|| ProductContext::default()); + let product = ctx.product.clone().unwrap_or_default(); ctx.execution_log.push(format!( "[{}] Mapping target subreddits: r/{}", @@ -74,11 +77,7 @@ impl ToolAgent for DiggerAgent { let threads = self .driver - .discover_threads( - &product.target_subreddits, - &product.high_intent_phrases, - 15, - ) + .discover_threads(&product.target_subreddits, &product.high_intent_phrases, 15) .await; let digger_res = OpportunityWorker::dig_sources( @@ -133,10 +132,7 @@ impl ToolAgent for MinerAgent { return Ok("0 sources mined".to_string()); } - let product = ctx - .product - .clone() - .unwrap_or_else(|| ProductContext::default()); + let product = ctx.product.clone().unwrap_or_default(); ctx.execution_log.push(format!( "[{}] Mining {} candidate threads for intent signals and prompt sanitization...", @@ -189,10 +185,7 @@ impl ToolAgent for DrafterAgent { return Ok("0 drafts generated".to_string()); } - let product = ctx - .product - .clone() - .unwrap_or_else(|| ProductContext::default()); + let product = ctx.product.clone().unwrap_or_default(); let mut drafted = 0; // Draft for top opportunities diff --git a/src/auth.rs b/src/auth.rs index cc910e4..0b6de98 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -233,13 +233,20 @@ impl AuthManager { pub fn get_cookies(&self, alias: &str) -> Result { self.credential_store - .get_secret(Self::keyring_service(), &Self::keyring_cookies_account(alias)) - .map_err(|e| AuthError::Keyring(format!("No cookies found for account '{}': {}", alias, e))) + .get_secret( + Self::keyring_service(), + &Self::keyring_cookies_account(alias), + ) + .map_err(|e| { + AuthError::Keyring(format!("No cookies found for account '{}': {}", alias, e)) + }) } pub fn delete_cookies(&self, alias: &str) -> Result<(), AuthError> { - self.credential_store - .delete_secret(Self::keyring_service(), &Self::keyring_cookies_account(alias)) + self.credential_store.delete_secret( + Self::keyring_service(), + &Self::keyring_cookies_account(alias), + ) } pub fn generate_authorize_url( diff --git a/src/auth/secret_manager.rs b/src/auth/secret_manager.rs index a7dd9d6..8ab08d7 100644 --- a/src/auth/secret_manager.rs +++ b/src/auth/secret_manager.rs @@ -66,8 +66,11 @@ impl CredentialStore for GcpSecretManagerStore { // 2. Check environment variable injected via Cloud Secret Manager let primary_env_key = Self::normalize_env_key(service, account); - let secret_prefix_key = format!("REDDIT_SECRET_{}", account.to_uppercase().replace(['-', ':', '.', '/'], "_")); - + let secret_prefix_key = format!( + "REDDIT_SECRET_{}", + account.to_uppercase().replace(['-', ':', '.', '/'], "_") + ); + if let Ok(val) = std::env::var(&primary_env_key) { return Ok(SecretToken::new(val)); } diff --git a/src/cli.rs b/src/cli.rs index 31f0c9c..f2b257d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,7 +6,9 @@ use std::time::Duration; use crate::accounts::AccountManager; use crate::actions::{ActionManager, RiskLevel}; -use crate::agents::opportunity::{OpportunityWorker, ProductContext, RankedOpportunity, RedditThread}; +use crate::agents::opportunity::{ + OpportunityWorker, ProductContext, RankedOpportunity, RedditThread, +}; use crate::audit::AuditLogger; use crate::auth::AuthManager; use crate::drivers::browser::BrowserDriver; @@ -139,9 +141,16 @@ pub enum OntologySubcommand { Generate { #[arg(long, short, help = "Subreddit name (e.g. 'vibecoding', 'SaaS')")] subreddit: String, - #[arg(long, default_value = "Community domain knowledge", help = "Description of the community")] + #[arg( + long, + default_value = "Community domain knowledge", + help = "Description of the community" + )] description: String, - #[arg(long, help = "Path to optional JSON file containing RedditThread items")] + #[arg( + long, + help = "Path to optional JSON file containing RedditThread items" + )] threads_file: Option, }, /// Inspect terms, rules, and relationships for a community ontology @@ -168,9 +177,17 @@ pub enum OntologySubcommand { pitch: String, #[arg(long, help = "Target pain point or keyword")] pain_point: String, - #[arg(long, default_value = "Full disclosure: I am working on this tool", help = "Mandatory affiliation disclosure")] + #[arg( + long, + default_value = "Full disclosure: I am working on this tool", + help = "Mandatory affiliation disclosure" + )] disclosure: String, - #[arg(long, default_value = "cli_user", help = "Name of the human reviewer certifying this angle")] + #[arg( + long, + default_value = "cli_user", + help = "Name of the human reviewer certifying this angle" + )] certified_by: String, }, /// List all subreddits with stored community ontologies @@ -258,9 +275,7 @@ pub enum AccountSubcommand { timeout_secs: u64, }, /// Inspect session cookies stored in OS Keychain - Cookies { - alias: String, - }, + Cookies { alias: String }, /// Interactive account takeover with custom starting URL Takeover { alias: String, @@ -691,7 +706,10 @@ impl AppContext { timeout_secs, } => { let ctx = self.account_manager.get_account_context(Some(&alias))?; - println!("Initiating Obscura account takeover for alias '{}'...", alias); + println!( + "Initiating Obscura account takeover for alias '{}'...", + alias + ); let result = self .browser_driver .takeover_account(&ctx, url.as_deref(), Duration::from_secs(timeout_secs)) @@ -736,14 +754,8 @@ impl AppContext { ); } - let username = val["username"] - .as_str() - .unwrap_or("unknown") - .to_string(); - let raw_cookies = val["cookies"] - .as_array() - .cloned() - .unwrap_or_default(); + let username = val["username"].as_str().unwrap_or("unknown").to_string(); + let raw_cookies = val["cookies"].as_array().cloned().unwrap_or_default(); let mut session_cookies = Vec::new(); for c in raw_cookies { @@ -768,8 +780,7 @@ impl AppContext { cookies: session_cookies, }; - self.browser_driver - .save_cookies_to_keychain(&alias, &jar)?; + self.browser_driver.save_cookies_to_keychain(&alias, &jar)?; self.account_manager .link_identity(&ctx.account.id, &username, &[])?; self.storage @@ -1137,10 +1148,7 @@ impl AppContext { BrowserSubcommand::Snapshot { alias, url, headed } => { let target_alias = alias.as_deref().or(account_override); let ctx = self.account_manager.get_account_context(target_alias)?; - println!( - "Browsing {} with natural human behavior simulation...", - url - ); + println!("Browsing {} with natural human behavior simulation...", url); let snapshot = self .browser_driver .human_browse(&ctx, &url, !headed) @@ -1170,15 +1178,22 @@ impl AppContext { format, } => { let queries = if query.is_empty() { - vec!["recommendation".to_string(), "tool".to_string(), "alternative".to_string()] + vec![ + "recommendation".to_string(), + "tool".to_string(), + "alternative".to_string(), + ] } else { vec![query] }; println!("⛏️ Running reddit-digger on r/{}...", subreddit); let keyless = RedditKeylessDriver::new(); - let threads = keyless.discover_threads(&[subreddit.clone()], &queries, limit).await; - let digger_result = OpportunityWorker::dig_sources(&threads, &[subreddit], &queries); + let threads = keyless + .discover_threads(std::slice::from_ref(&subreddit), &queries, limit) + .await; + let digger_result = + OpportunityWorker::dig_sources(&threads, &[subreddit], &queries); match format.to_lowercase().as_str() { "json" => println!("{}", serde_json::to_string_pretty(&digger_result)?), @@ -1223,29 +1238,38 @@ impl AppContext { "alternative".to_string(), "what tool".to_string(), ], - affiliation_disclosure: "Full disclosure: I am affiliated with this tool".to_string(), + affiliation_disclosure: "Full disclosure: I am affiliated with this tool" + .to_string(), }; println!("🔬 Running reddit-miner on r/{}...", subreddit); let keyless = RedditKeylessDriver::new(); let threads = keyless - .discover_threads(&[subreddit.clone()], &product_ctx.high_intent_phrases, limit) + .discover_threads( + std::slice::from_ref(&subreddit), + &product_ctx.high_intent_phrases, + limit, + ) .await; let digger_res = OpportunityWorker::dig_sources( &threads, - &[subreddit.clone()], + std::slice::from_ref(&subreddit), &product_ctx.high_intent_phrases, ); let now = chrono::Utc::now().timestamp(); - let miner_res = OpportunityWorker::mine_content(&digger_res.sources, &product_ctx, now); + let miner_res = + OpportunityWorker::mine_content(&digger_res.sources, &product_ctx, now); let ranked = miner_res.opportunities; let formatted_output = match format.to_lowercase().as_str() { "json" => serde_json::to_string_pretty(&ranked)?, "md" | "markdown" => { let mut md = String::new(); - md.push_str(&format!("# Mined Reddit Opportunities: r/{}\n\n", subreddit)); + md.push_str(&format!( + "# Mined Reddit Opportunities: r/{}\n\n", + subreddit + )); md.push_str(&format!( "*Analyzed {} threads, identified {} high-intent opportunities for `{}`*\n\n", miner_res.analyzed_count, @@ -1275,7 +1299,8 @@ impl AppContext { md } _ => { - let mut text = format!("\nMined {} ranked opportunities:\n\n", ranked.len()); + let mut text = + format!("\nMined {} ranked opportunities:\n\n", ranked.len()); for (i, opp) in ranked.iter().take(10).enumerate() { text.push_str(&format!( "{}. [Score: {:.2}] {} (r/{})\n URL: {}\n Why: {}\n Signals: {:?} | Comments: {}\n\n", @@ -1332,18 +1357,15 @@ impl AppContext { let keyless = RedditKeylessDriver::new(); let threads = keyless .discover_threads( - &[subreddit.clone()], + std::slice::from_ref(&subreddit), &product_ctx.high_intent_phrases, limit, ) .await; let now = chrono::Utc::now().timestamp(); - let ranked = OpportunityWorker::discover_opportunities( - &threads, - &product_ctx, - now, - ); + let ranked = + OpportunityWorker::discover_opportunities(&threads, &product_ctx, now); let formatted_output = match format.to_lowercase().as_str() { "json" => serde_json::to_string_pretty(&ranked)?, @@ -1378,7 +1400,10 @@ impl AppContext { md.push_str("\n## Detailed Breakdown\n\n"); for (i, opp) in ranked.iter().take(10).enumerate() { md.push_str(&format!("### {}. {}\n\n", i + 1, opp.thread.title)); - md.push_str(&format!("- **URL:** {}\n", opp.thread.canonical_url())); + md.push_str(&format!( + "- **URL:** {}\n", + opp.thread.canonical_url() + )); md.push_str(&format!( "- **Opportunity Reason:** {}\n", opp.opportunity_reason @@ -1434,7 +1459,8 @@ impl AppContext { num_comments: 5, is_locked: false, is_archived: false, - url: "https://reddit.com/r/community/comments/cli_draft_target/".to_string(), + url: "https://reddit.com/r/community/comments/cli_draft_target/" + .to_string(), }; let sample_product = ProductContext { @@ -1454,20 +1480,31 @@ impl AppContext { product_fit_score: 0.80, timing_score: 0.85, composite_score: 0.85, - detected_signals: vec![crate::agents::opportunity::OpportunitySignal::RecommendationAsk], + detected_signals: vec![ + crate::agents::opportunity::OpportunitySignal::RecommendationAsk, + ], opportunity_reason: "OP is looking for assistance".to_string(), preflight_verdict: None, }; match OpportunityWorker::draft_reply(&sample_opp, &sample_product) { Ok(draft) => { - println!("\n=== Experience Grammar Draft (Words: {}) ===", draft.word_count); + println!( + "\n=== Experience Grammar Draft (Words: {}) ===", + draft.word_count + ); println!("{}", draft.draft_text); println!("\nChecks passed:"); - println!("- Experience grammar valid: {}", draft.experience_grammar_valid); + println!( + "- Experience grammar valid: {}", + draft.experience_grammar_valid + ); println!("- Product named: {}", draft.product_named); println!("- Affiliation disclosed: {}", draft.affiliation_disclosed); - println!("- Referenced detail: '{}'", draft.concrete_detail_referenced); + println!( + "- Referenced detail: '{}'", + draft.concrete_detail_referenced + ); } Err(err) => { eprintln!("Draft generation failed: {}", err); @@ -1484,14 +1521,28 @@ impl AppContext { println!("🧭 === OmniSkill Dynamic Agent Router ==="); println!("Prompt: '{}'", dag.user_prompt); println!("Language: {}", dag.detected_language); - println!("Intent: {:?} (Confidence: {:.2})", dag.intent_category, dag.confidence_score); - println!("\nExecution Waves ({} waves, {} total steps):", dag.execution_waves.len(), dag.steps.len()); + println!( + "Intent: {:?} (Confidence: {:.2})", + dag.intent_category, dag.confidence_score + ); + println!( + "\nExecution Waves ({} waves, {} total steps):", + dag.execution_waves.len(), + dag.steps.len() + ); for wave in &dag.execution_waves { println!(" [Wave {}]", wave.wave_index + 1); for sid in &wave.step_ids { if let Some(step) = dag.steps.iter().find(|s| &s.step_id == sid) { - let approval_str = if step.requires_human_approval { " ⚠️ [APPROVAL REQUIRED]" } else { "" }; - println!(" • {} -> Tool: `{}`{}", step.agent_role, step.tool_name, approval_str); + let approval_str = if step.requires_human_approval { + " ⚠️ [APPROVAL REQUIRED]" + } else { + "" + }; + println!( + " • {} -> Tool: `{}`{}", + step.agent_role, step.tool_name, approval_str + ); println!(" {}", step.description); } } @@ -1513,7 +1564,11 @@ impl AppContext { println!("🎭 Launching Master Orchestrator for: '{}'...", prompt); let product_ctx = product.map(|name| { let subs = subreddits - .map(|s| s.split(',').map(|i| i.trim().trim_start_matches("r/").to_string()).collect()) + .map(|s| { + s.split(',') + .map(|i| i.trim().trim_start_matches("r/").to_string()) + .collect() + }) .unwrap_or_else(|| vec!["SaaS".to_string(), "startups".to_string()]); ProductContext { product_name: name.clone(), @@ -1522,17 +1577,28 @@ impl AppContext { pain_points: vec!["manual effort".to_string(), "time waste".to_string()], competitors: vec![], target_subreddits: subs, - high_intent_phrases: vec!["recommendation".to_string(), "alternative".to_string()], + high_intent_phrases: vec![ + "recommendation".to_string(), + "alternative".to_string(), + ], affiliation_disclosure: format!("Full disclosure: I work on {}", name), } }); let orchestrator = crate::agents::orchestrator::MasterOrchestrator::new(); - let report = orchestrator.orchestrate_from_prompt(&prompt, product_ctx).await + let report = orchestrator + .orchestrate_from_prompt(&prompt, product_ctx) + .await .map_err(|e| anyhow::anyhow!(e))?; - println!("\n✅ Workflow Completed: [{}] (Status: {})", report.workflow_id, report.status); - println!("- Completed Steps: {}/{}", report.steps_completed, report.total_steps); + println!( + "\n✅ Workflow Completed: [{}] (Status: {})", + report.workflow_id, report.status + ); + println!( + "- Completed Steps: {}/{}", + report.steps_completed, report.total_steps + ); println!("- Context Summary: {}", report.context_summary); println!("\nExecution Log:"); @@ -1563,16 +1629,24 @@ impl AppContext { .await .map_err(|e| anyhow::anyhow!(e))?; - println!("\n✅ Successfully forged Agent Skill: '{}'", package.skill_name); + println!( + "\n✅ Successfully forged Agent Skill: '{}'", + package.skill_name + ); println!("- Display Name: {}", package.display_name); - println!("- Target Subreddits: r/{}", package.target_subreddits.join(", r/")); + println!( + "- Target Subreddits: r/{}", + package.target_subreddits.join(", r/") + ); println!("- Analyzed Threads: {}", package.analyzed_threads_count); println!( "- Output Directory: {}", output_dir.join(&package.skill_name).display() ); println!("\nStructure created:"); - println!(" ├── SKILL.md (AgentSkills.io v1 & Poteto Mode standard)"); + println!( + " ├── SKILL.md (AgentSkills.io v1 & Poteto Mode standard)" + ); println!(" ├── scripts/preflight_check.py"); println!(" └── references/reddit_evidence.md"); } @@ -1587,10 +1661,12 @@ impl AppContext { println!("🧠 Mining community ontology for r/{}...", sub_clean); let threads: Vec = if let Some(path) = threads_file { - let content = std::fs::read_to_string(&path) - .map_err(|e| anyhow::anyhow!("Failed to read {}: {}", path.display(), e))?; - serde_json::from_str(&content) - .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", path.display(), e))? + let content = std::fs::read_to_string(&path).map_err(|e| { + anyhow::anyhow!("Failed to read {}: {}", path.display(), e) + })?; + serde_json::from_str(&content).map_err(|e| { + anyhow::anyhow!("Failed to parse {}: {}", path.display(), e) + })? } else { println!("📡 Fetching public threads via keyless driver..."); let keyless = RedditKeylessDriver::new(); @@ -1609,10 +1685,14 @@ impl AppContext { &threads, ); - self.storage.save_community_ontology(&ontology) + self.storage + .save_community_ontology(&ontology) .map_err(|e| anyhow::anyhow!("Storage error: {}", e))?; - println!("✅ Successfully compiled and persisted Community Ontology for r/{}", sub_clean); + println!( + "✅ Successfully compiled and persisted Community Ontology for r/{}", + sub_clean + ); println!("- Discovered Terms: {}", ontology.terms.len()); println!("- Default Rules: {}", ontology.rules.len()); println!("- Relationships: {}", ontology.relationships.len()); @@ -1623,21 +1703,35 @@ impl AppContext { let sub_clean = subreddit.trim_start_matches("r/").to_string(); match self.storage.get_community_ontology(&sub_clean)? { Some(ont) => { - println!("\n=== Community Ontology: r/{} (v{}) ===", ont.subreddit, ont.version); + println!( + "\n=== Community Ontology: r/{} (v{}) ===", + ont.subreddit, ont.version + ); println!("Description: {}", ont.description); println!("Updated: {}\n", ont.updated_at); println!("--- Rules & Constraints ({}) ---", ont.rules.len()); for rule in &ont.rules { - println!("* [{:?}] {}: {}", rule.severity, rule.rule_name, rule.description); + println!( + "* [{:?}] {}: {}", + rule.severity, rule.rule_name, rule.description + ); if !rule.forbidden_phrases.is_empty() { println!(" Forbidden: {}", rule.forbidden_phrases.join(", ")); } } - println!("\n--- Certified Angles ({}) ---", ont.certified_angles.len()); + println!( + "\n--- Certified Angles ({}) ---", + ont.certified_angles.len() + ); for angle in &ont.certified_angles { - println!("* [{}] {} (By: {})", if angle.active { "ACTIVE" } else { "INACTIVE" }, angle.title, angle.certified_by); + println!( + "* [{}] {} (By: {})", + if angle.active { "ACTIVE" } else { "INACTIVE" }, + angle.title, + angle.certified_by + ); println!(" Target Pain: {}", angle.target_pain_point); println!(" Pitch: {}", angle.angle_pitch); println!(" Disclosure: {}", angle.mandatory_disclosure); @@ -1645,8 +1739,13 @@ impl AppContext { println!("\n--- Sample Community Terms ({}) ---", ont.terms.len()); for term in ont.terms.iter().take(15) { - println!("* [{:?}] {} (Freq: {}, Sentiment: {:.2}, Certified: {})", - term.category, term.term, term.frequency, term.sentiment_bias, term.certified + println!( + "* [{:?}] {} (Freq: {}, Sentiment: {:.2}, Certified: {})", + term.category, + term.term, + term.frequency, + term.sentiment_bias, + term.certified ); } } @@ -1662,7 +1761,9 @@ impl AppContext { body, } => { let sub_clean = subreddit.trim_start_matches("r/").to_string(); - let ontology = self.storage.get_community_ontology(&sub_clean)? + let ontology = self + .storage + .get_community_ontology(&sub_clean)? .unwrap_or_else(|| { crate::ontology::AutoOntologyBuilder::build_from_threads( &sub_clean, @@ -1686,14 +1787,16 @@ impl AppContext { let context = ProductContext::default(); let verdict = crate::ontology::PreflightAnswerabilityGate::check_engagable( - &thread, - &ontology, - &context, + &thread, &ontology, &context, ); println!("\n=== Preflight Answerability Check ==="); match verdict { - crate::ontology::EngagementVerdict::CertifiedSafe { angle, matched_terms, alignment_score } => { + crate::ontology::EngagementVerdict::CertifiedSafe { + angle, + matched_terms, + alignment_score, + } => { println!("Verdict: ✅ CERTIFIED SAFE"); println!("Alignment Score: {:.2}", alignment_score); println!("Matched Terms: {}", matched_terms.join(", ")); @@ -1701,18 +1804,28 @@ impl AppContext { println!("Approved Pitch: {}", angle.angle_pitch); println!("Disclosure: {}", angle.mandatory_disclosure); } - crate::ontology::EngagementVerdict::HighRiskViolation { rule_name, detected_phrase, severity } => { + crate::ontology::EngagementVerdict::HighRiskViolation { + rule_name, + detected_phrase, + severity, + } => { println!("Verdict: ❌ HIGH RISK VIOLATION (FAIL-CLOSED)"); println!("Rule: {}", rule_name); println!("Severity: {:?}", severity); println!("Detected Phrase: '{}'", detected_phrase); } - crate::ontology::EngagementVerdict::UnanswerableMissingConcepts { missing_concepts, rationale } => { + crate::ontology::EngagementVerdict::UnanswerableMissingConcepts { + missing_concepts, + rationale, + } => { println!("Verdict: ⚠️ UNANSWERABLE / OUT OF SCOPE"); println!("Rationale: {}", rationale); println!("Missing Concepts: {}", missing_concepts.join(", ")); } - crate::ontology::EngagementVerdict::UncertifiedAngleRequired { detected_pain_point, suggested_pitch } => { + crate::ontology::EngagementVerdict::UncertifiedAngleRequired { + detected_pain_point, + suggested_pitch, + } => { println!("Verdict: 🛑 UNCERTIFIED ANGLE REQUIRED"); println!("Detected Pain: {}", detected_pain_point); println!("Suggested Pitch: {}", suggested_pitch); @@ -1741,10 +1854,13 @@ impl AppContext { active: true, }; - self.storage.save_certified_angle(&sub_clean, &angle) + self.storage + .save_certified_angle(&sub_clean, &angle) .map_err(|e| anyhow::anyhow!("Failed to save angle: {}", e))?; - let mut ont = self.storage.get_community_ontology(&sub_clean)? + let mut ont = self + .storage + .get_community_ontology(&sub_clean)? .unwrap_or_else(|| { crate::ontology::AutoOntologyBuilder::build_from_threads( &sub_clean, @@ -1753,19 +1869,28 @@ impl AppContext { ) }); ont.certified_angles.push(angle.clone()); - self.storage.save_community_ontology(&ont) + self.storage + .save_community_ontology(&ont) .map_err(|e| anyhow::anyhow!("Failed to update ontology: {}", e))?; - println!("✅ Certified and activated angle '{}' for r/{}", angle.title, sub_clean); + println!( + "✅ Certified and activated angle '{}' for r/{}", + angle.title, sub_clean + ); println!("- ID: {}", angle.id); println!("- Target: {}", angle.target_pain_point); println!("- Pitch: {}", angle.angle_pitch); println!("- Disclosure: {}", angle.mandatory_disclosure); - println!("- Certified: {} at {}", angle.certified_by, angle.certified_at); + println!( + "- Certified: {} at {}", + angle.certified_by, angle.certified_at + ); } OntologySubcommand::List => { - let subs = self.storage.list_community_ontologies() + let subs = self + .storage + .list_community_ontologies() .map_err(|e| anyhow::anyhow!("Storage error: {}", e))?; println!("\n=== Stored Community Ontologies ({}) ===", subs.len()); if subs.is_empty() { @@ -1773,8 +1898,12 @@ impl AppContext { } else { for sub in subs { if let Ok(Some(ont)) = self.storage.get_community_ontology(&sub) { - println!("* r/{} — Terms: {}, Rules: {}, Certified Angles: {}", - ont.subreddit, ont.terms.len(), ont.rules.len(), ont.certified_angles.len() + println!( + "* r/{} — Terms: {}, Rules: {}, Certified Angles: {}", + ont.subreddit, + ont.terms.len(), + ont.rules.len(), + ont.certified_angles.len() ); } else { println!("* r/{}", sub); @@ -1782,7 +1911,7 @@ impl AppContext { } } } - } + }, Commands::Mcp => { let server = McpServer::new( diff --git a/src/drivers/browser.rs b/src/drivers/browser.rs index 1a88fbc..bee6689 100644 --- a/src/drivers/browser.rs +++ b/src/drivers/browser.rs @@ -228,7 +228,11 @@ impl HumanBehaviorEngine { } /// Types text character-by-character with realistic human jitter - pub async fn type_like_human(page: &Page, selector: &str, text: &str) -> Result<(), DriverError> { + pub async fn type_like_human( + page: &Page, + selector: &str, + text: &str, + ) -> Result<(), DriverError> { let focus_js = format!( r#"(() => {{ const el = document.querySelector({}); @@ -244,7 +248,9 @@ impl HumanBehaviorEngine { let focused: bool = page .evaluate(focus_js) .await - .map_err(|e| DriverError::BrowserCapabilityBroken(format!("Failed to focus selector: {}", e)))? + .map_err(|e| { + DriverError::BrowserCapabilityBroken(format!("Failed to focus selector: {}", e)) + })? .into_value() .unwrap_or(false); @@ -302,7 +308,12 @@ impl HumanBehaviorEngine { let clicked: bool = page .evaluate(click_js) .await - .map_err(|e| DriverError::BrowserCapabilityBroken(format!("Failed to click ref {}: {}", ref_id, e)))? + .map_err(|e| { + DriverError::BrowserCapabilityBroken(format!( + "Failed to click ref {}: {}", + ref_id, e + )) + })? .into_value() .unwrap_or(false); @@ -318,7 +329,11 @@ impl HumanBehaviorEngine { } /// Smooth stepped scrolling with deceleration physics and frame jitter - pub async fn scroll_like_human(page: &Page, total_pixels: i32, steps: usize) -> Result<(), DriverError> { + pub async fn scroll_like_human( + page: &Page, + total_pixels: i32, + steps: usize, + ) -> Result<(), DriverError> { if steps == 0 { return Ok(()); } @@ -347,12 +362,20 @@ impl HumanBehaviorEngine { let raw_json: String = page .evaluate(SEMANTIC_SNAPSHOT_JS) .await - .map_err(|e| DriverError::BrowserCapabilityBroken(format!("Failed to extract semantic snapshot: {}", e)))? + .map_err(|e| { + DriverError::BrowserCapabilityBroken(format!( + "Failed to extract semantic snapshot: {}", + e + )) + })? .into_value() .unwrap_or_else(|_| "{}".to_string()); let snapshot: SemanticSnapshot = serde_json::from_str(&raw_json).map_err(|e| { - DriverError::BrowserCapabilityBroken(format!("Failed to parse semantic snapshot JSON: {}", e)) + DriverError::BrowserCapabilityBroken(format!( + "Failed to parse semantic snapshot JSON: {}", + e + )) })?; Ok(snapshot) @@ -457,9 +480,8 @@ impl BrowserDriver { DriverError::Internal("No CredentialStore configured on BrowserDriver".to_string()) })?; - let json = serde_json::to_string(jar).map_err(|e| { - DriverError::Internal(format!("Failed to serialize cookie jar: {}", e)) - })?; + let json = serde_json::to_string(jar) + .map_err(|e| DriverError::Internal(format!("Failed to serialize cookie jar: {}", e)))?; store .set_secret( @@ -511,13 +533,12 @@ impl BrowserDriver { raw.to_string() }; - let jar: SessionCookieJar = - serde_json::from_str(&decoded_str).map_err(|e| { - DriverError::Internal(format!( - "Failed to parse session cookies from Keychain: {}", - e - )) - })?; + let jar: SessionCookieJar = serde_json::from_str(&decoded_str).map_err(|e| { + DriverError::Internal(format!( + "Failed to parse session cookies from Keychain: {}", + e + )) + })?; Ok(Some(jar)) } Err(_) => Ok(None), @@ -719,7 +740,10 @@ impl BrowserDriver { self.apply_stealth_engine(&page).await?; - let injected = self.inject_keychain_cookies(&page, alias).await.unwrap_or(0); + let injected = self + .inject_keychain_cookies(&page, alias) + .await + .unwrap_or(0); if injected > 0 { info!( "Pre-injected {} existing Keychain cookies for '{}'", @@ -768,8 +792,7 @@ impl BrowserDriver { } } - if current_url == "https://www.reddit.com/" - || current_url.contains("reddit.com/r/") + if current_url == "https://www.reddit.com/" || current_url.contains("reddit.com/r/") { verified_user = verified_user.or_else(|| account_ctx.account.reddit_username.clone()); @@ -809,7 +832,9 @@ impl BrowserDriver { let _ = storage.update_reddit_username(&account_ctx.account.id, &verified_username); } - let initial_snapshot = HumanBehaviorEngine::take_semantic_snapshot(&page).await.ok(); + let initial_snapshot = HumanBehaviorEngine::take_semantic_snapshot(&page) + .await + .ok(); info!( "Account takeover verified for '{}' (u/{}), captured {} cookies (keychain={})", @@ -1064,7 +1089,10 @@ mod tests { driver.save_cookies_to_keychain("my_acc", &jar).unwrap(); // Load - let loaded = driver.load_cookies_from_keychain("my_acc").unwrap().unwrap(); + let loaded = driver + .load_cookies_from_keychain("my_acc") + .unwrap() + .unwrap(); assert_eq!(loaded.alias, "my_acc"); assert_eq!(loaded.reddit_username.as_deref(), Some("reddit_dev")); assert_eq!(loaded.cookies.len(), 1); diff --git a/src/drivers/reddit_api.rs b/src/drivers/reddit_api.rs index 3320d25..636b9fd 100644 --- a/src/drivers/reddit_api.rs +++ b/src/drivers/reddit_api.rs @@ -642,9 +642,7 @@ impl Driver for RedditApiDriver { Err(DriverError::Unsupported(Capability::BrowserSettings)) } - Capability::KeylessSearch => { - Err(DriverError::Unsupported(Capability::KeylessSearch)) - } + Capability::KeylessSearch => Err(DriverError::Unsupported(Capability::KeylessSearch)), Capability::DiscoverOpportunities => { Err(DriverError::Unsupported(Capability::DiscoverOpportunities)) diff --git a/src/drivers/reddit_keyless.rs b/src/drivers/reddit_keyless.rs index 51943ef..16169cf 100644 --- a/src/drivers/reddit_keyless.rs +++ b/src/drivers/reddit_keyless.rs @@ -1,7 +1,7 @@ -use std::collections::HashSet; -use std::time::Duration; use reqwest::header::{HeaderMap, HeaderValue, ACCEPT, ACCEPT_LANGUAGE, USER_AGENT}; use serde_json::Value; +use std::collections::HashSet; +use std::time::Duration; use tracing::{debug, warn}; use crate::agents::opportunity::RedditThread; @@ -9,12 +9,59 @@ use crate::drivers::DriverError; /// Noise words to ignore during relevance scoring (matching last30days-skill catalog). pub const NOISE_WORDS: &[&str] = &[ - "best", "top", "good", "great", "awesome", "killer", "latest", "new", "news", "update", - "updates", "trending", "hottest", "popular", "practices", "features", "tips", - "recommendations", "advice", "prompt", "prompts", "prompting", "methods", "strategies", - "approaches", "how", "to", "the", "a", "an", "for", "with", "of", "in", "on", "is", "are", - "what", "which", "guide", "tutorial", "using", "and", "or", "i", "my", "we", "our", "you", - "your", "it", "this", "that", + "best", + "top", + "good", + "great", + "awesome", + "killer", + "latest", + "new", + "news", + "update", + "updates", + "trending", + "hottest", + "popular", + "practices", + "features", + "tips", + "recommendations", + "advice", + "prompt", + "prompts", + "prompting", + "methods", + "strategies", + "approaches", + "how", + "to", + "the", + "a", + "an", + "for", + "with", + "of", + "in", + "on", + "is", + "are", + "what", + "which", + "guide", + "tutorial", + "using", + "and", + "or", + "i", + "my", + "we", + "our", + "you", + "your", + "it", + "this", + "that", ]; /// Keyless Reddit Driver inspired by the last30days-skill multi-tier discovery pipeline. @@ -60,10 +107,7 @@ impl RedditKeylessDriver { ACCEPT, HeaderValue::from_static("application/json, text/plain, */*"), ); - headers.insert( - ACCEPT_LANGUAGE, - HeaderValue::from_static("en-US,en;q=0.9"), - ); + headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.9")); headers } @@ -82,7 +126,9 @@ impl RedditKeylessDriver { let url = format!( "https://www.reddit.com/r/{}/{}.json?limit={}", - clean_sub, valid_sort, limit.min(100) + clean_sub, + valid_sort, + limit.min(100) ); debug!(url = %url, "Fetching keyless reddit listing"); @@ -176,7 +222,10 @@ impl RedditKeylessDriver { // Tier 1: Public listings across target subreddits for sub in subreddits { - match self.fetch_subreddit_listing(sub, "hot", limit_per_sub).await { + match self + .fetch_subreddit_listing(sub, "hot", limit_per_sub) + .await + { Ok(posts) => { for post in posts { if seen_ids.insert(post.id.clone()) { @@ -188,7 +237,10 @@ impl RedditKeylessDriver { warn!(subreddit = %sub, error = %err, "Public listing failed, attempting Arctic Shift fallback"); // Fallback to Arctic Shift for this subreddit for phrase in search_phrases.iter().take(2) { - if let Ok(posts) = self.fetch_arctic_archive(phrase, Some(sub), limit_per_sub).await { + if let Ok(posts) = self + .fetch_arctic_archive(phrase, Some(sub), limit_per_sub) + .await + { for post in posts { if seen_ids.insert(post.id.clone()) { collected.push(post); @@ -371,7 +423,11 @@ mod tests { ]; let relevance = RedditKeylessDriver::calculate_relevance(title, body, &targets); - assert!(relevance > 0.6, "Relevance should match 2 out of 3 targets, got {}", relevance); + assert!( + relevance > 0.6, + "Relevance should match 2 out of 3 targets, got {}", + relevance + ); } #[test] @@ -380,7 +436,8 @@ mod tests { let viral_off_topic_relevance = 0.10; let on_topic_score = RedditKeylessDriver::rank_composite_score(on_topic_relevance, 5, 10); - let viral_score = RedditKeylessDriver::rank_composite_score(viral_off_topic_relevance, 5000, 20000); + let viral_score = + RedditKeylessDriver::rank_composite_score(viral_off_topic_relevance, 5000, 20000); // Even with 25,000 engagement, viral post cannot overcome 0.25 bonus cap assert!( diff --git a/src/mcp.rs b/src/mcp.rs index b88043d..9fe11f1 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -7,7 +7,9 @@ use tracing::info; use crate::accounts::AccountManager; use crate::actions::{ActionManager, RiskLevel}; use crate::agents::community_builder::CommunityBuilderWorker; -use crate::agents::opportunity::{DiggerSource, OpportunityWorker, ProductContext, RankedOpportunity}; +use crate::agents::opportunity::{ + DiggerSource, OpportunityWorker, ProductContext, RankedOpportunity, +}; use crate::drivers::reddit_keyless::RedditKeylessDriver; use crate::drivers::Capability; use crate::issues::IssueEngine; @@ -548,7 +550,8 @@ impl McpServer { .ok_or_else(|| "Missing required 'prompt' argument".to_string())?; let context_opt = args.get("context").cloned(); - let dag = crate::agents::router::DynamicRouter::build_execution_dag(prompt, context_opt); + let dag = + crate::agents::router::DynamicRouter::build_execution_dag(prompt, context_opt); return Ok(json!(dag)); } @@ -562,7 +565,9 @@ impl McpServer { .and_then(|p| serde_json::from_value(p.clone()).ok()); let orchestrator = crate::agents::orchestrator::MasterOrchestrator::new(); - let report = orchestrator.orchestrate_from_prompt(prompt, product).await?; + let report = orchestrator + .orchestrate_from_prompt(prompt, product) + .await?; return Ok(json!(report)); } @@ -713,7 +718,8 @@ impl McpServer { .discover_threads(&subreddits, &high_intent_phrases, limit_per_sub) .await; - let digger_result = OpportunityWorker::dig_sources(&threads, &subreddits, &high_intent_phrases); + let digger_result = + OpportunityWorker::dig_sources(&threads, &subreddits, &high_intent_phrases); return Ok(json!(digger_result)); } @@ -735,7 +741,10 @@ impl McpServer { } if name == "reddit_discover_opportunities" { - let product_name = args["product_name"].as_str().unwrap_or("Product").to_string(); + let product_name = args["product_name"] + .as_str() + .unwrap_or("Product") + .to_string(); let value_prop = args["value_prop"].as_str().unwrap_or("").to_string(); let icp_target = args["icp_target"].as_str().unwrap_or("").to_string(); let affiliation_disclosure = args["affiliation_disclosure"] @@ -797,9 +806,11 @@ impl McpServer { .discover_threads(&subreddits, &high_intent_phrases, limit_per_sub) .await; - let digger_result = OpportunityWorker::dig_sources(&threads, &subreddits, &high_intent_phrases); + let digger_result = + OpportunityWorker::dig_sources(&threads, &subreddits, &high_intent_phrases); let now_utc = chrono::Utc::now().timestamp(); - let miner_result = OpportunityWorker::mine_content(&digger_result.sources, &product, now_utc); + let miner_result = + OpportunityWorker::mine_content(&digger_result.sources, &product, now_utc); return Ok(json!(miner_result.opportunities)); } @@ -880,9 +891,7 @@ impl McpServer { }; let verdict = crate::ontology::PreflightAnswerabilityGate::check_engagable( - &thread, - &ontology, - &context, + &thread, &ontology, &context, ); return Ok(json!(verdict)); @@ -972,9 +981,12 @@ impl McpServer { let link_karma = identity["link_karma"].as_i64().unwrap_or(0); let comment_karma = identity["comment_karma"].as_i64().unwrap_or(0); - let input_records: Vec = args["history"] + let input_records: Vec = args + ["history"] .as_array() - .and_then(|arr| serde_json::from_value(serde_json::Value::Array(arr.clone())).ok()) + .and_then(|arr| { + serde_json::from_value(serde_json::Value::Array(arr.clone())).ok() + }) .unwrap_or_else(|| { let value_count = (comment_karma.max(0) / 10).clamp(1, 90) as usize; let promo_count = (link_karma.max(0) / 50).clamp(0, 10) as usize; @@ -1508,9 +1520,15 @@ mod tests { // Verify newly added opportunity and community audit tools are present in tools list assert!(tools_arr.iter().any(|t| t["name"] == "reddit_digger")); assert!(tools_arr.iter().any(|t| t["name"] == "reddit_miner")); - assert!(tools_arr.iter().any(|t| t["name"] == "reddit_discover_opportunities")); - assert!(tools_arr.iter().any(|t| t["name"] == "reddit_draft_response")); - assert!(tools_arr.iter().any(|t| t["name"] == "reddit_audit_community_ratio")); + assert!(tools_arr + .iter() + .any(|t| t["name"] == "reddit_discover_opportunities")); + assert!(tools_arr + .iter() + .any(|t| t["name"] == "reddit_draft_response")); + assert!(tools_arr + .iter() + .any(|t| t["name"] == "reddit_audit_community_ratio")); let sample_product = json!({ "product_name": "RedditAgent", @@ -1602,9 +1620,15 @@ mod tests { assert!(ratio_res["audit"]["is_compliant"].is_boolean()); // Verify newly added stealth browser & keychain takeover tools are present - assert!(tools_arr.iter().any(|t| t["name"] == "reddit_account_login")); - assert!(tools_arr.iter().any(|t| t["name"] == "reddit_takeover_account")); - assert!(tools_arr.iter().any(|t| t["name"] == "reddit_account_cookies")); + assert!(tools_arr + .iter() + .any(|t| t["name"] == "reddit_account_login")); + assert!(tools_arr + .iter() + .any(|t| t["name"] == "reddit_takeover_account")); + assert!(tools_arr + .iter() + .any(|t| t["name"] == "reddit_account_cookies")); assert!(tools_arr.iter().any(|t| t["name"] == "reddit_human_browse")); // Call reddit_account_cookies when empty @@ -1616,9 +1640,8 @@ mod tests { // Populate keychain cookies and test again let store = server.account_manager.auth().credential_store(); - let browser_driver = Arc::new( - crate::drivers::browser::BrowserDriver::new().with_credential_store(store), - ); + let browser_driver = + Arc::new(crate::drivers::browser::BrowserDriver::new().with_credential_store(store)); let sample_jar = crate::drivers::browser::SessionCookieJar { alias: "personal".to_string(), reddit_username: Some("my_user".to_string()), @@ -1650,9 +1673,15 @@ mod tests { // Verify newly added auto-ontology tools assert!(tools_arr.iter().any(|t| t["name"] == "reddit_ontology_get")); - assert!(tools_arr.iter().any(|t| t["name"] == "reddit_ontology_check_engagable")); - assert!(tools_arr.iter().any(|t| t["name"] == "reddit_ontology_certify_angle")); - assert!(tools_arr.iter().any(|t| t["name"] == "reddit_ontology_list")); + assert!(tools_arr + .iter() + .any(|t| t["name"] == "reddit_ontology_check_engagable")); + assert!(tools_arr + .iter() + .any(|t| t["name"] == "reddit_ontology_certify_angle")); + assert!(tools_arr + .iter() + .any(|t| t["name"] == "reddit_ontology_list")); let server_with_storage = server_with_browser.with_storage(storage.clone()); diff --git a/src/ontology/certification.rs b/src/ontology/certification.rs index 8faabbd..298bdf1 100644 --- a/src/ontology/certification.rs +++ b/src/ontology/certification.rs @@ -38,7 +38,11 @@ impl CertificationManager { angle_id: &str, certified_by: &str, ) -> Result { - if let Some(angle) = ontology.certified_angles.iter_mut().find(|a| a.id == angle_id) { + if let Some(angle) = ontology + .certified_angles + .iter_mut() + .find(|a| a.id == angle_id) + { angle.active = true; angle.certified_by = certified_by.to_string(); angle.certified_at = chrono::Utc::now().to_rfc3339(); @@ -54,7 +58,11 @@ impl CertificationManager { ontology: &mut CommunityOntology, angle_id: &str, ) -> Result { - if let Some(angle) = ontology.certified_angles.iter_mut().find(|a| a.id == angle_id) { + if let Some(angle) = ontology + .certified_angles + .iter_mut() + .find(|a| a.id == angle_id) + { angle.active = false; ontology.updated_at = chrono::Utc::now().to_rfc3339(); Ok(angle.clone()) diff --git a/src/ontology/engine.rs b/src/ontology/engine.rs index fb4826d..4fdd2e5 100644 --- a/src/ontology/engine.rs +++ b/src/ontology/engine.rs @@ -32,34 +32,207 @@ impl AutoOntologyBuilder { let mut competitor_indicators: HashMap = HashMap::new(); let pain_triggers = [ - "broken", "frustrating", "nightmare", "sucks", "hate", "issue", "problem", - "struggling", "impossible", "failed", "bug", "crash", "slow", "expensive", - "alternative to", "wasting hours", "getting banned", + "broken", + "frustrating", + "nightmare", + "sucks", + "hate", + "issue", + "problem", + "struggling", + "impossible", + "failed", + "bug", + "crash", + "slow", + "expensive", + "alternative to", + "wasting hours", + "getting banned", ]; let competitor_triggers = [ - "tool", "bot", "platform", "software", "app", "service", "competitor", - "zapier", "make.com", "cursor", "copilot", "chatgpt", "gemini", "claude", + "tool", + "bot", + "platform", + "software", + "app", + "service", + "competitor", + "zapier", + "make.com", + "cursor", + "copilot", + "chatgpt", + "gemini", + "claude", ]; let stop_words: HashSet<&'static str> = [ - "the", "and", "or", "to", "in", "is", "of", "it", "with", "for", "on", "that", - "this", "was", "at", "by", "an", "be", "from", "as", "about", "into", "like", - "through", "after", "over", "between", "out", "against", "during", "without", - "before", "under", "around", "among", "have", "has", "had", "having", "do", "does", - "did", "doing", "would", "should", "could", "ought", "i'm", "you're", "he's", - "she's", "it's", "we're", "they're", "i've", "you've", "we've", "they've", "i'd", - "you'd", "he'd", "she'd", "we'd", "they'd", "i'll", "you'll", "he'll", "she'll", - "we'll", "they'll", "isn't", "aren't", "wasn't", "weren't", "hasn't", "haven't", - "hadn't", "doesn't", "don't", "didn't", "won't", "wouldn't", "shan't", "shouldn't", - "can't", "cannot", "couldn't", "mustn't", "let's", "that's", "who's", "what's", - "here's", "there's", "when's", "where's", "why's", "how's", "a", "an", "the", - "and", "but", "if", "or", "because", "as", "until", "while", "of", "at", "by", - "for", "with", "about", "against", "between", "into", "through", "during", "before", - "after", "above", "below", "to", "from", "up", "down", "in", "out", "on", "off", - "over", "under", "again", "further", "then", "once", "just", "my", "your", "our", - "their", "me", "him", "her", "us", "them", "what", "which", "who", "whom", "this", - "that", "these", "those", "am", "is", "are", "was", "were", "be", "been", "being", + "the", + "and", + "or", + "to", + "in", + "is", + "of", + "it", + "with", + "for", + "on", + "that", + "this", + "was", + "at", + "by", + "an", + "be", + "from", + "as", + "about", + "into", + "like", + "through", + "after", + "over", + "between", + "out", + "against", + "during", + "without", + "before", + "under", + "around", + "among", + "have", + "has", + "had", + "having", + "do", + "does", + "did", + "doing", + "would", + "should", + "could", + "ought", + "i'm", + "you're", + "he's", + "she's", + "it's", + "we're", + "they're", + "i've", + "you've", + "we've", + "they've", + "i'd", + "you'd", + "he'd", + "she'd", + "we'd", + "they'd", + "i'll", + "you'll", + "he'll", + "she'll", + "we'll", + "they'll", + "isn't", + "aren't", + "wasn't", + "weren't", + "hasn't", + "haven't", + "hadn't", + "doesn't", + "don't", + "didn't", + "won't", + "wouldn't", + "shan't", + "shouldn't", + "can't", + "cannot", + "couldn't", + "mustn't", + "let's", + "that's", + "who's", + "what's", + "here's", + "there's", + "when's", + "where's", + "why's", + "how's", + "a", + "an", + "the", + "and", + "but", + "if", + "or", + "because", + "as", + "until", + "while", + "of", + "at", + "by", + "for", + "with", + "about", + "against", + "between", + "into", + "through", + "during", + "before", + "after", + "above", + "below", + "to", + "from", + "up", + "down", + "in", + "out", + "on", + "off", + "over", + "under", + "again", + "further", + "then", + "once", + "just", + "my", + "your", + "our", + "their", + "me", + "him", + "her", + "us", + "them", + "what", + "which", + "who", + "whom", + "this", + "that", + "these", + "those", + "am", + "is", + "are", + "was", + "were", + "be", + "been", + "being", ] .into_iter() .collect(); @@ -126,7 +299,7 @@ impl AutoOntologyBuilder { // Add top recurring domain keywords as technical concepts / slang let mut sorted_words: Vec<(String, u32)> = word_counts.into_iter().collect(); - sorted_words.sort_by(|a, b| b.1.cmp(&a.1)); + sorted_words.sort_by_key(|a| std::cmp::Reverse(a.1)); for (word, count) in sorted_words.into_iter().take(30) { let is_slang = word.contains("vibe") @@ -190,7 +363,9 @@ impl AutoOntologyBuilder { OntologyRule { id: format!("rule_ban_{}", subreddit), rule_name: "AntiSpamAndVoteManipulation".to_string(), - description: "Outlaw direct affiliate links, upvote trading, and automated spamming".to_string(), + description: + "Outlaw direct affiliate links, upvote trading, and automated spamming" + .to_string(), severity: RuleSeverity::StrictBan, forbidden_phrases: vec![ "upvote for upvote".to_string(), @@ -203,7 +378,8 @@ impl AutoOntologyBuilder { OntologyRule { id: format!("rule_promo_{}", subreddit), rule_name: "CommunitySelfPromoPolicy".to_string(), - description: "Disallow aggressive sales pitches lacking engineering depth".to_string(), + description: "Disallow aggressive sales pitches lacking engineering depth" + .to_string(), severity: RuleSeverity::SelfPromoRestriction, forbidden_phrases: vec![ "buy now".to_string(), diff --git a/src/ontology/gate.rs b/src/ontology/gate.rs index f7595f2..5e4107b 100644 --- a/src/ontology/gate.rs +++ b/src/ontology/gate.rs @@ -81,13 +81,16 @@ impl PreflightAnswerabilityGate { // Also check against certified angles' target pain points for angle in ontology.active_certified_angles() { let angle_pain = angle.target_pain_point.to_lowercase(); - if text_lower.contains(&angle_pain) && !matched_pain_points.contains(&angle.target_pain_point) { + if text_lower.contains(&angle_pain) + && !matched_pain_points.contains(&angle.target_pain_point) + { matched_pain_points.push(angle.target_pain_point.clone()); } } // If the ontology has terms defined, but none matched the thread at all - if !ontology.terms.is_empty() && matched_terms.is_empty() && matched_pain_points.is_empty() { + if !ontology.terms.is_empty() && matched_terms.is_empty() && matched_pain_points.is_empty() + { return EngagementVerdict::UnanswerableMissingConcepts { missing_concepts: vec![thread.title.clone()], rationale: format!( @@ -125,8 +128,9 @@ impl PreflightAnswerabilityGate { .into_iter() .find(|angle| { let angle_pain = angle.target_pain_point.to_lowercase(); - matched_pain_points.iter().any(|p| p.to_lowercase().contains(&angle_pain) || angle_pain.contains(&p.to_lowercase())) - || text_lower.contains(&angle.target_pain_point.to_lowercase()) + matched_pain_points.iter().any(|p| { + p.to_lowercase().contains(&angle_pain) || angle_pain.contains(&p.to_lowercase()) + }) || text_lower.contains(&angle.target_pain_point.to_lowercase()) }); if let Some(angle) = matching_angle { diff --git a/src/ontology/mod.rs b/src/ontology/mod.rs index a76ae6b..ed7a254 100644 --- a/src/ontology/mod.rs +++ b/src/ontology/mod.rs @@ -99,15 +99,23 @@ mod tests { }; let context = ProductContext::default(); - let verdict = PreflightAnswerabilityGate::check_engagable(&spam_thread, &ontology, &context); + let verdict = + PreflightAnswerabilityGate::check_engagable(&spam_thread, &ontology, &context); match verdict { - EngagementVerdict::HighRiskViolation { rule_name, detected_phrase, severity } => { + EngagementVerdict::HighRiskViolation { + rule_name, + detected_phrase, + severity, + } => { assert_eq!(rule_name, "AntiSpamAndVoteManipulation"); assert_eq!(detected_phrase, "upvote for upvote"); assert_eq!(severity, RuleSeverity::StrictBan); } - _ => panic!("Expected HighRiskViolation for spam thread, got {:?}", verdict), + _ => panic!( + "Expected HighRiskViolation for spam thread, got {:?}", + verdict + ), } } @@ -143,12 +151,19 @@ mod tests { let context = ProductContext::default(); // 1. Without certified angle -> UncertifiedAngleRequired - let verdict_uncertified = PreflightAnswerabilityGate::check_engagable(&thread, &ontology, &context); + let verdict_uncertified = + PreflightAnswerabilityGate::check_engagable(&thread, &ontology, &context); match verdict_uncertified { - EngagementVerdict::UncertifiedAngleRequired { detected_pain_point, .. } => { + EngagementVerdict::UncertifiedAngleRequired { + detected_pain_point, + .. + } => { assert_eq!(detected_pain_point, "wasting hours"); } - _ => panic!("Expected UncertifiedAngleRequired, got {:?}", verdict_uncertified), + _ => panic!( + "Expected UncertifiedAngleRequired, got {:?}", + verdict_uncertified + ), } // 2. Add human-certified angle -> CertifiedSafe @@ -161,9 +176,14 @@ mod tests { "qa_reviewer", ); - let verdict_certified = PreflightAnswerabilityGate::check_engagable(&thread, &ontology, &context); + let verdict_certified = + PreflightAnswerabilityGate::check_engagable(&thread, &ontology, &context); match verdict_certified { - EngagementVerdict::CertifiedSafe { angle, matched_terms, alignment_score } => { + EngagementVerdict::CertifiedSafe { + angle, + matched_terms, + alignment_score, + } => { assert_eq!(angle.target_pain_point, "wasting hours"); assert!(matched_terms.contains(&"wasting hours".to_string())); assert!(alignment_score > 0.4); diff --git a/src/ontology/models.rs b/src/ontology/models.rs index 2b6156e..92d1c98 100644 --- a/src/ontology/models.rs +++ b/src/ontology/models.rs @@ -112,8 +112,7 @@ impl CommunityOntology { pub fn find_term(&self, query: &str) -> Option<&CommunityTerm> { let lower = query.to_lowercase(); self.terms.iter().find(|t| { - t.term.to_lowercase() == lower - || t.synonyms.iter().any(|s| s.to_lowercase() == lower) + t.term.to_lowercase() == lower || t.synonyms.iter().any(|s| s.to_lowercase() == lower) }) } diff --git a/src/storage.rs b/src/storage.rs index 07c6bba..980906a 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -812,7 +812,8 @@ impl Storage { pub fn list_community_ontologies(&self) -> Result, StorageError> { let conn = self.conn.lock().map_err(|_| StorageError::LockError)?; - let mut stmt = conn.prepare("SELECT subreddit FROM community_ontologies ORDER BY subreddit ASC")?; + let mut stmt = + conn.prepare("SELECT subreddit FROM community_ontologies ORDER BY subreddit ASC")?; let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; let mut subs = Vec::new(); for r in rows { @@ -951,7 +952,8 @@ mod tests { #[test] fn test_storage_community_ontology_persistence() { let storage = Storage::new_in_memory().expect("in-memory db"); - let mut ontology = crate::ontology::CommunityOntology::new("vibecoding", "Vibe Coding discussions"); + let mut ontology = + crate::ontology::CommunityOntology::new("vibecoding", "Vibe Coding discussions"); ontology.terms.push(crate::ontology::CommunityTerm { id: "term_1".to_string(), term: "spec-driven".to_string(), @@ -963,9 +965,14 @@ mod tests { certified: true, }); - storage.save_community_ontology(&ontology).expect("save ontology"); + storage + .save_community_ontology(&ontology) + .expect("save ontology"); - let loaded = storage.get_community_ontology("vibecoding").expect("get ontology").expect("found"); + let loaded = storage + .get_community_ontology("vibecoding") + .expect("get ontology") + .expect("found"); assert_eq!(loaded.subreddit, "vibecoding"); assert_eq!(loaded.terms.len(), 1); assert_eq!(loaded.terms[0].term, "spec-driven"); @@ -984,8 +991,12 @@ mod tests { active: true, }; - storage.save_certified_angle("vibecoding", &angle).expect("save angle"); - let angles = storage.get_certified_angles("vibecoding").expect("get angles"); + storage + .save_certified_angle("vibecoding", &angle) + .expect("save angle"); + let angles = storage + .get_certified_angles("vibecoding") + .expect("get angles"); assert_eq!(angles.len(), 1); assert_eq!(angles[0].title, "Zero-Shot Engineering"); } diff --git a/tests/test_auto_ontology.rs b/tests/test_auto_ontology.rs index a275b95..add3ed4 100644 --- a/tests/test_auto_ontology.rs +++ b/tests/test_auto_ontology.rs @@ -1,9 +1,11 @@ -use std::sync::Arc; use serde_json::json; +use std::sync::Arc; use reddit_agent::accounts::AccountManager; use reddit_agent::actions::ActionManager; -use reddit_agent::agents::opportunity::{OpportunityWorker, ProductContext, RedditThread, ReviewStatus}; +use reddit_agent::agents::opportunity::{ + OpportunityWorker, ProductContext, RedditThread, ReviewStatus, +}; use reddit_agent::audit::AuditLogger; use reddit_agent::auth::{AuthManager, MemoryKeyring}; use reddit_agent::drivers::Capability; @@ -108,7 +110,10 @@ async fn test_e2e_auto_ontology_lifecycle_and_gate_enforcement() { assert!(!ontology.relationships.is_empty()); // Verify pain point detected - let has_wasting_hours = ontology.terms.iter().any(|t| t.term == "wasting hours" && t.category == TermCategory::PainPoint); + let has_wasting_hours = ontology + .terms + .iter() + .any(|t| t.term == "wasting hours" && t.category == TermCategory::PainPoint); assert!(has_wasting_hours); // 3. Human-in-the-Loop Certification: Add and activate a certified angle @@ -124,10 +129,17 @@ async fn test_e2e_auto_ontology_lifecycle_and_gate_enforcement() { assert!(angle.active); // 4. Persist ontology & certified angle in Storage - storage.save_community_ontology(&ontology).expect("save ontology"); - storage.save_certified_angle("vibecoding", &angle).expect("save angle"); + storage + .save_community_ontology(&ontology) + .expect("save ontology"); + storage + .save_certified_angle("vibecoding", &angle) + .expect("save angle"); - let loaded_ont = storage.get_community_ontology("vibecoding").expect("query").expect("found"); + let loaded_ont = storage + .get_community_ontology("vibecoding") + .expect("query") + .expect("found"); assert_eq!(loaded_ont.subreddit, "vibecoding"); assert_eq!(loaded_ont.certified_angles.len(), 1); @@ -144,39 +156,66 @@ async fn test_e2e_auto_ontology_lifecycle_and_gate_enforcement() { }; // Scenario A: Spam thread -> HighRiskViolation (Fail-closed, 0 API waste) - let verdict_spam = PreflightAnswerabilityGate::check_engagable(&threads[1], &loaded_ont, &product); + let verdict_spam = + PreflightAnswerabilityGate::check_engagable(&threads[1], &loaded_ont, &product); match verdict_spam { - EngagementVerdict::HighRiskViolation { rule_name, severity, detected_phrase } => { + EngagementVerdict::HighRiskViolation { + rule_name, + severity, + detected_phrase, + } => { assert_eq!(rule_name, "AntiSpamAndVoteManipulation"); assert_eq!(severity, RuleSeverity::StrictBan); assert!(detected_phrase.contains("upvote for upvote")); } - other => panic!("Expected HighRiskViolation for spam thread, got {:?}", other), + other => panic!( + "Expected HighRiskViolation for spam thread, got {:?}", + other + ), } // Scenario B: Off-topic thread -> UnanswerableMissingConcepts - let verdict_offtopic = PreflightAnswerabilityGate::check_engagable(&threads[2], &loaded_ont, &product); + let verdict_offtopic = + PreflightAnswerabilityGate::check_engagable(&threads[2], &loaded_ont, &product); match verdict_offtopic { - EngagementVerdict::UnanswerableMissingConcepts { missing_concepts, .. } => { + EngagementVerdict::UnanswerableMissingConcepts { + missing_concepts, .. + } => { assert!(!missing_concepts.is_empty()); } - other => panic!("Expected UnanswerableMissingConcepts for cooking thread, got {:?}", other), + other => panic!( + "Expected UnanswerableMissingConcepts for cooking thread, got {:?}", + other + ), } // Scenario C: High-signal thread -> CertifiedSafe - let verdict_safe = PreflightAnswerabilityGate::check_engagable(&threads[0], &loaded_ont, &product); + let verdict_safe = + PreflightAnswerabilityGate::check_engagable(&threads[0], &loaded_ont, &product); match verdict_safe { - EngagementVerdict::CertifiedSafe { angle: matched_angle, matched_terms, alignment_score } => { + EngagementVerdict::CertifiedSafe { + angle: matched_angle, + matched_terms, + alignment_score, + } => { assert_eq!(matched_angle.title, "Local-First Deterministic Engineering"); assert!(matched_terms.contains(&"wasting hours".to_string())); assert!(alignment_score >= 0.5); } - other => panic!("Expected CertifiedSafe for high-signal thread, got {:?}", other), + other => panic!( + "Expected CertifiedSafe for high-signal thread, got {:?}", + other + ), } // 6. Test OpportunityWorker Integration with Preflight Ontology Gate let now_utc = 1700000500; - let ranked = OpportunityWorker::discover_opportunities_with_ontology(&threads, &product, &loaded_ont, now_utc); + let ranked = OpportunityWorker::discover_opportunities_with_ontology( + &threads, + &product, + &loaded_ont, + now_utc, + ); // Spam thread and off-topic thread are automatically eliminated! assert_eq!(ranked.len(), 1); @@ -186,8 +225,12 @@ async fn test_e2e_auto_ontology_lifecycle_and_gate_enforcement() { // 7. Verify Drafted Reply utilizes the Certified Angle let draft = OpportunityWorker::draft_reply(&ranked[0], &product).expect("draft reply"); assert_eq!(draft.review_status, ReviewStatus::Approved); - assert!(draft.draft_text.contains("building local-first CLI tools in Rust")); - assert!(draft.draft_text.contains("Full disclosure: I am the author of reddit-agent")); + assert!(draft + .draft_text + .contains("building local-first CLI tools in Rust")); + assert!(draft + .draft_text + .contains("Full disclosure: I am the author of reddit-agent")); assert!(draft.experience_grammar_valid); assert!(draft.word_count >= 20 && draft.word_count <= 65); @@ -199,28 +242,49 @@ async fn test_e2e_auto_ontology_lifecycle_and_gate_enforcement() { let driver = Arc::new(TestDummyDriver); let router = Arc::new(CapabilityRouter::new(driver.clone(), driver)); let audit = Arc::new(AuditLogger::new(storage.clone())); - let action_mgr = Arc::new(ActionManager::new(storage.clone(), router.clone(), account_mgr.clone(), audit)); - let issue_engine = Arc::new(IssueEngine::new(router.clone(), account_mgr.clone(), action_mgr.clone(), storage.clone(), "test_cid".to_string())); + let action_mgr = Arc::new(ActionManager::new( + storage.clone(), + router.clone(), + account_mgr.clone(), + audit, + )); + let issue_engine = Arc::new(IssueEngine::new( + router.clone(), + account_mgr.clone(), + action_mgr.clone(), + storage.clone(), + "test_cid".to_string(), + )); - let mcp = McpServer::new(account_mgr, router, action_mgr, issue_engine).with_storage(storage.clone()); + let mcp = + McpServer::new(account_mgr, router, action_mgr, issue_engine).with_storage(storage.clone()); // MCP: List ontologies - let mcp_list = mcp.handle_tool_call("reddit_ontology_list", json!({})).await.unwrap(); + let mcp_list = mcp + .handle_tool_call("reddit_ontology_list", json!({})) + .await + .unwrap(); assert_eq!(mcp_list, json!(["vibecoding"])); // MCP: Get ontology - let mcp_ont = mcp.handle_tool_call("reddit_ontology_get", json!({ "subreddit": "vibecoding" })).await.unwrap(); + let mcp_ont = mcp + .handle_tool_call("reddit_ontology_get", json!({ "subreddit": "vibecoding" })) + .await + .unwrap(); assert_eq!(mcp_ont["subreddit"], "vibecoding"); assert_eq!(mcp_ont["certified_angles"].as_array().unwrap().len(), 1); // MCP: Check engagable - let mcp_check = mcp.handle_tool_call( - "reddit_ontology_check_engagable", - json!({ - "subreddit": "vibecoding", - "title": "Cursor broken, looking for alternative to cursor that is wasting hours", - "pain_point": "wasting hours" - }), - ).await.unwrap(); + let mcp_check = mcp + .handle_tool_call( + "reddit_ontology_check_engagable", + json!({ + "subreddit": "vibecoding", + "title": "Cursor broken, looking for alternative to cursor that is wasting hours", + "pain_point": "wasting hours" + }), + ) + .await + .unwrap(); assert!(mcp_check.get("CertifiedSafe").is_some()); } diff --git a/tests/test_secret_manager.rs b/tests/test_secret_manager.rs index bdd4603..ab6022b 100644 --- a/tests/test_secret_manager.rs +++ b/tests/test_secret_manager.rs @@ -7,11 +7,15 @@ fn test_headless_credential_store_fallback_and_memory() { // Set a secret in headless mode store - .set_secret("reddit-agent", "browser_cookies:mamdouh", "cookie_data_test") + .set_secret( + "reddit-agent", + "browser_cookies:memory_isolated", + "cookie_data_test", + ) .expect("set secret"); let retrieved = store - .get_secret("reddit-agent", "browser_cookies:mamdouh") + .get_secret("reddit-agent", "browser_cookies:memory_isolated") .expect("get secret"); assert_eq!(retrieved.expose_secret(), "cookie_data_test"); @@ -19,13 +23,14 @@ fn test_headless_credential_store_fallback_and_memory() { #[test] fn test_headless_env_secret_provider() { - std::env::set_var("REDDIT_SECRET_BROWSER_COOKIES_MAMDOUH", "env_injected_cookie_json"); + let env_key = "REDDIT_SECRET_BROWSER_COOKIES_ENV_ISOLATED"; + std::env::set_var(env_key, "env_injected_cookie_json"); let store = GcpSecretManagerStore::new_with_memory_fallback(); let retrieved = store - .get_secret("reddit-agent", "browser_cookies:mamdouh") + .get_secret("reddit-agent", "browser_cookies:env_isolated") .expect("get secret from env"); assert_eq!(retrieved.expose_secret(), "env_injected_cookie_json"); - std::env::remove_var("REDDIT_SECRET_BROWSER_COOKIES_MAMDOUH"); + std::env::remove_var(env_key); } diff --git a/tests/test_split_plane_router.rs b/tests/test_split_plane_router.rs index 0b88b4f..6cf74e2 100644 --- a/tests/test_split_plane_router.rs +++ b/tests/test_split_plane_router.rs @@ -31,7 +31,9 @@ async fn test_split_plane_router_dispatches_to_local_relay() { let verdict = router.validate_egress_safety(&plan); assert!(verdict.is_ok()); - let payload = router.encode_relay_envelope(&plan).expect("encode envelope"); + let payload = router + .encode_relay_envelope(&plan) + .expect("encode envelope"); assert_eq!(payload["action_id"], "act_test_split_plane"); assert_eq!(payload["egress_mode"], "split_plane_relay"); assert_eq!(payload["target"], "t3_1xnhs2u"); diff --git a/tests/test_velocity_governor.rs b/tests/test_velocity_governor.rs index 4a6cdb2..ef798f0 100644 --- a/tests/test_velocity_governor.rs +++ b/tests/test_velocity_governor.rs @@ -7,7 +7,10 @@ fn test_velocity_governor_enforces_sleep_and_daily_cap() { // 1. Simulate 03:00 AM local time (Sleep mode between 23:00 and 07:00) let late_night = NaiveTime::from_hms_opt(3, 0, 0).unwrap(); - assert_eq!(gov.evaluate_time(late_night), InteractionVerdict::SleepWindow); + assert_eq!( + gov.evaluate_time(late_night), + InteractionVerdict::SleepWindow + ); // 2. Simulate 14:00 PM local time (Active hours) let daytime = NaiveTime::from_hms_opt(14, 0, 0).unwrap();