difflore-core 0.2.0

Core library for the difflore CLI — rule store, retrieval, MCP server, hooks, cloud sync. Not intended for direct use; depend on `difflore-cli` instead.
use serde_json::{Value, json};

use crate::domain::models::RememberRuleInput;
use crate::observability::trajectory::TrajectoryStep;
use crate::skills;

use super::super::{McpState, build_cost_meta, emit_trajectory_step, estimate_tokens};
use super::serve_stats::{drain_mcp_query_outbox, enqueue_mcp_query_outbox};

const MAX_REMEMBER_TITLE_CHARS: usize = 200;

pub(crate) async fn tool_remember_rule(
    state: &McpState,
    args: &Value,
) -> Result<Value, (i32, String)> {
    let title = args
        .get("title")
        .and_then(|v| v.as_str())
        .ok_or((-32602, "Missing required parameter: title".to_owned()))?
        .trim();
    let body = args
        .get("body")
        .and_then(|v| v.as_str())
        .ok_or((-32602, "Missing required parameter: body".to_owned()))?
        .trim();
    if title.is_empty() {
        return Err((-32602, "title must not be empty".to_owned()));
    }
    if body.is_empty() {
        return Err((-32602, "body must not be empty".to_owned()));
    }
    // Soft cap on title length so audit-list output stays one-line.
    if title.chars().count() > MAX_REMEMBER_TITLE_CHARS {
        return Err((
            -32602,
            format!("title must be {MAX_REMEMBER_TITLE_CHARS} chars or fewer"),
        ));
    }
    if body.chars().count() > skills::REMEMBER_BODY_CHAR_LIMIT {
        return Err((
            -32602,
            format!(
                "body must be {} chars or fewer",
                skills::REMEMBER_BODY_CHAR_LIMIT
            ),
        ));
    }

    let file_patterns = args
        .get("file_patterns")
        .and_then(|v| v.as_array())
        .map(|arr| {
            arr.iter()
                .filter_map(|v| v.as_str().map(|s| s.trim().to_owned()))
                .filter(|s| !s.is_empty())
                .collect::<Vec<_>>()
        })
        .filter(|v| !v.is_empty());
    if let Some(patterns) = file_patterns.as_ref() {
        if patterns.len() > skills::REMEMBER_FILE_PATTERN_LIMIT {
            return Err((
                -32602,
                format!(
                    "file_patterns accepts at most {} entries",
                    skills::REMEMBER_FILE_PATTERN_LIMIT
                ),
            ));
        }
        if patterns
            .iter()
            .any(|p| p.chars().count() > skills::REMEMBER_FILE_PATTERN_CHAR_LIMIT)
        {
            return Err((
                -32602,
                format!(
                    "file_patterns entries must be {} chars or fewer",
                    skills::REMEMBER_FILE_PATTERN_CHAR_LIMIT
                ),
            ));
        }
    }

    let bad_code = args
        .get("bad_code")
        .and_then(|v| v.as_str())
        .filter(|s| !s.trim().is_empty())
        .map(String::from);
    let good_code = args
        .get("good_code")
        .and_then(|v| v.as_str())
        .filter(|s| !s.trim().is_empty())
        .map(String::from);
    for (label, value) in [
        ("bad_code", bad_code.as_deref()),
        ("good_code", good_code.as_deref()),
    ] {
        if value.is_some_and(|v| v.chars().count() > skills::REMEMBER_EXAMPLE_CHAR_LIMIT) {
            return Err((
                -32602,
                format!(
                    "{label} must be {} chars or fewer",
                    skills::REMEMBER_EXAMPLE_CHAR_LIMIT
                ),
            ));
        }
    }
    let severity = args
        .get("severity")
        .and_then(|v| v.as_str())
        .filter(|s| !s.trim().is_empty())
        .map(String::from);
    let kind = args
        .get("kind")
        .and_then(|v| v.as_str())
        .filter(|s| !s.trim().is_empty())
        .map(String::from);
    let category = args
        .get("category")
        .and_then(|v| v.as_str())
        .filter(|s| !s.trim().is_empty())
        .map(String::from);

    let capture_client = "mcp-server";
    let input = RememberRuleInput {
        title: title.to_owned(),
        body: body.to_owned(),
        file_patterns,
        bad_code,
        good_code,
        severity,
        kind,
        category,
        // MCP path is always the conversation channel.
        origin: Some("conversation".to_owned()),
        captured_by_client: Some(capture_client.to_owned()),
    };

    // Warm the configured-GitLab-host cache from the auth DB before detecting
    // remotes. A fresh MCP-server process starts with an empty host cache; if
    // remember_rule runs before any recall (search_rules/hook), self-managed
    // GitLab remotes would otherwise fail to resolve and the rule would be
    // captured without a repo scope — invisible to the next repo-scoped recall.
    // Mirrors hook.rs and search_rules.rs.
    crate::mcp_server::hook::refresh_configured_gitlab_hosts_for_remote_detection().await;
    let detected_repos = crate::mcp_server::hook::detect_git_remote_owner_repos();

    let outcome = skills::remember(&state.db, input)
        .await
        .map_err(|e| (-32603, format!("Failed to remember rule: {e}")))?;
    let skill = &outcome.skill;

    // Route the detected remote through `RepoScope::canonical` so this UPDATE
    // shares the one normalization gate every other `source_repo` write uses.
    // The detected value is already canonical, but funnelling it through the
    // newtype keeps RepoScope the single source_repo write entry point and
    // fails closed (skips the write) on any non-canonical value rather than
    // binding a raw String.
    if let Some(repo_scope) = detected_repos
        .first()
        .map(String::as_str)
        .filter(|r| !r.trim().is_empty())
        .and_then(crate::infra::git::RepoScope::canonical)
    {
        // Attach the active rule to the current repo now so it recalls within
        // the repo where the user explicitly asked for the memory.
        let repo_full_name = repo_scope.as_str();
        let skill_id = skill.id.as_str();
        if let Err(e) = sqlx::query!(
            "UPDATE skills
             SET source_repo = CASE
                 WHEN source_repo IS NULL OR trim(source_repo) = '' THEN ?1
                 ELSE source_repo
             END
             WHERE id = ?2",
            repo_full_name,
            skill_id,
        )
        .execute(&state.db)
        .await
        {
            if crate::infra::env::debug_telemetry() {
                eprintln!("[difflore-mcp] remember_rule source_repo update failed: {e}");
            }
        }
    }

    let memory_state = skills::rule_status(&state.db, &skill.id)
        .await
        .ok()
        .flatten()
        .unwrap_or_else(|| "unknown".to_owned());
    let is_active = memory_state == "active";

    // A direct user "remember this" request is treated as approval, so fresh
    // MCP captures are active and should be indexed for immediate recall.
    if is_active {
        let repo_scopes =
            skills::expand_repo_scopes_with_source_aliases(&state.db, &detected_repos)
                .await
                .unwrap_or_else(|_| detected_repos.clone());
        if !repo_scopes.is_empty()
            && let Ok(index_pool) = state.resolve_index_pool().await
            && let Err(e) =
                crate::context::orchestrator::ensure_rules_indexed_for_repo_scopes_local_embeddings(
                    &state.db,
                    &index_pool,
                    &repo_scopes,
                )
                .await
            && crate::infra::env::debug_telemetry()
        {
            eprintln!("[difflore-mcp] remember_rule local index refresh failed: {e}");
        }
    }

    let item_id = format!("rule:{}", skill.id);
    let show_command = format!("difflore memory show {item_id}");
    let disable_command = format!("difflore memory disable {item_id}");
    let capture_status = match (outcome.deduped, is_active) {
        (false, false) => "captured_pending",
        (true, false) => "strengthened_pending",
        (true, true) => "strengthened_active",
        (false, true) => "captured_active",
    };

    let confirm = if outcome.deduped {
        if is_active {
            format!(
                "~ strengthened existing active rule **{}** (`{}`). \
                 It is approved and available to agents. \
                 Inspect with `{show_command}`.",
                skill.name, skill.id,
            )
        } else {
            format!(
                "~ strengthened existing memory rule **{}** (`{}`). \
                 Inspect with `{show_command}`.",
                skill.name, skill.id,
            )
        }
    } else {
        let pattern_hint = if skill.tags.iter().any(|t| t.contains('*')) {
            "file-pattern scoped"
        } else {
            "repo-wide"
        };
        format!(
            "+1 memory rule saved from agent chat as **{}** (`{}`), {pattern_hint}. \
             I treated the user's explicit remember request as approval, so it is active and available to agents now. \
             Inspect with `{show_command}`.",
            skill.name, skill.id,
        )
    };

    // Soft warning when the user is approaching the daily cap. The agent
    // sees this in the tool result and will (per its description) echo
    // it back to the user — important UX so a runaway capture rate
    // doesn't become a silent flood.
    let warn_suffix = if outcome.captures_today >= skills::REMEMBER_WARN_THRESHOLD {
        format!(
            "\n\nWarning: {} conversation captures today (cap: {}). \
             Audit with `difflore memory inbox`.",
            outcome.captures_today,
            skills::REMEMBER_DAILY_LIMIT,
        )
    } else {
        String::new()
    };

    // Track this conversation capture in the MCP response-size stream.
    let confirm_tokens = estimate_tokens(&confirm) + estimate_tokens(&warn_suffix);
    emit_trajectory_step(&TrajectoryStep::McpResponseSize {
        tool: "remember_rule".to_owned(),
        total_tokens: confirm_tokens,
        rules_injected: usize::from(is_active),
    });
    emit_trajectory_step(&TrajectoryStep::RuleHitByOrigin {
        manual: 0,
        conversation: 1,
        pr_review: 0,
        extracted: 0,
        cloud: 0,
    });

    // Fire-and-forget telemetry so the cloud Dashboard sees the proposal
    // origin in near-real-time. Same outbox-fallback as rule retrieval:
    // logged-out events are persisted locally and drained on next login
    // instead of being silently lost.
    {
        let cloud = state.cloud.clone();
        let db = state.db.clone();
        let rule_id = skill.id.clone();
        let rule_name = skill.name.clone();
        let repo_full_name: Option<String> = detected_repos.first().cloned();
        enqueue_mcp_query_outbox(
            &state.db,
            super::serve_stats::McpQueryOutboxEntry {
                file: "remember_rule",
                intent: &rule_name,
                rules_injected: usize::from(is_active),
                strict_match_count: 0,
                rule_titles: std::slice::from_ref(&rule_name),
                rule_ids: std::slice::from_ref(&rule_id),
                client_label: "mcp-server",
                repo_full_name: repo_full_name.as_deref(),
            },
        )
        .await;
        tokio::spawn(async move {
            let _ = drain_mcp_query_outbox(&db, &cloud, 8).await;
        });
    }

    Ok(json!({
        "content": [{
            "type": "text",
            "text": format!("{confirm}{warn_suffix}"),
        }],
        "_meta": {
            "cost": build_cost_meta(confirm_tokens, None),
            "rule_id": skill.id,
            "item_id": item_id,
            "origin": skill.origin,
            "captured_by_client": capture_client,
            "state": memory_state,
            "capture_status": capture_status,
            "published": false,
            "requires_user_approval": !is_active,
            "served_to_agents": is_active,
            "deduped": outcome.deduped,
            "dedup_window_hit": outcome.dedup_window_hit,
            "confidence": outcome.confidence_after,
            "captures_today": outcome.captures_today,
            "daily_limit": skills::REMEMBER_DAILY_LIMIT,
            "provenance": {
                "source": "remember_rule",
                "origin": skill.origin,
                "capturedByClient": capture_client,
                "trustState": memory_state,
                "servedToAgents": is_active,
                "requiresUserApproval": !is_active,
            },
            "governance": {
                "show": show_command,
                "disable": disable_command,
            },
            "impact": {
                "rulesAdded": i32::from(!outcome.deduped && is_active),
                "draftsProposed": 0,
                "rulesStrengthened": i32::from(outcome.deduped),
                "kind": if outcome.deduped { "strengthened" } else { "active_rule" },
            }
        }
    }))
}