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()));
}
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,
origin: Some("conversation".to_owned()),
captured_by_client: Some(capture_client.to_owned()),
};
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;
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)
{
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";
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,
)
};
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()
};
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,
});
{
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" },
}
}
}))
}