Skip to main content

locus_mcp/
shared.rs

1use chrono::{DateTime, Utc};
2use locus_core_rs::EmbeddingMigrationMode;
3use serde_json::{Value, json};
4
5pub(crate) fn parse_utc_required(value: &str, field: &str) -> Result<DateTime<Utc>, String> {
6    DateTime::parse_from_rfc3339(value)
7        .map(|parsed| parsed.with_timezone(&Utc))
8        .map_err(|_| format!("{field} must be an ISO8601 UTC datetime"))
9}
10
11pub(crate) fn parse_utc_optional(
12    value: Option<&str>,
13    field: &str,
14) -> Result<Option<DateTime<Utc>>, String> {
15    match value {
16        Some(raw) => parse_utc_required(raw, field).map(Some),
17        None => Ok(None),
18    }
19}
20
21pub(crate) fn validate_limit(limit: usize, field: &str) -> Result<usize, String> {
22    if (1..=200).contains(&limit) {
23        Ok(limit)
24    } else {
25        Err(format!("{field} must be between 1 and 200"))
26    }
27}
28
29pub(crate) fn validate_batch_size(batch_size: usize) -> Result<usize, String> {
30    if (1..=500).contains(&batch_size) {
31        Ok(batch_size)
32    } else {
33        Err("batch_size must be between 1 and 500".to_string())
34    }
35}
36
37pub(crate) fn validate_max_nodes(max_nodes: usize) -> Result<usize, String> {
38    if (1..=50000).contains(&max_nodes) {
39        Ok(max_nodes)
40    } else {
41        Err("max_nodes must be between 1 and 50000".to_string())
42    }
43}
44
45pub(crate) fn parse_migration_mode(value: Option<&str>) -> Result<EmbeddingMigrationMode, String> {
46    match value
47        .unwrap_or("missing_only")
48        .trim()
49        .to_ascii_lowercase()
50        .as_str()
51    {
52        "missing_only" => Ok(EmbeddingMigrationMode::MissingOnly),
53        "reindex_all" => Ok(EmbeddingMigrationMode::ReindexAll),
54        _ => Err("mode must be one of: missing_only, reindex_all".to_string()),
55    }
56}
57
58pub(crate) fn mode_to_string(mode: EmbeddingMigrationMode) -> &'static str {
59    match mode {
60        EmbeddingMigrationMode::MissingOnly => "missing_only",
61        EmbeddingMigrationMode::ReindexAll => "reindex_all",
62    }
63}
64
65pub(crate) fn expanded_limit(limit: usize) -> usize {
66    limit.saturating_mul(5).clamp(1, 200)
67}
68
69pub(crate) fn normalize_tiers(tiers: &[String]) -> Vec<String> {
70    tiers
71        .iter()
72        .map(|value| value.trim().to_ascii_lowercase())
73        .filter(|value| !value.is_empty())
74        .collect::<Vec<_>>()
75}
76
77pub(crate) fn normalize_context_keywords(keywords: Option<&[String]>) -> Vec<String> {
78    keywords
79        .unwrap_or(&[])
80        .iter()
81        .map(|value| value.trim().to_ascii_lowercase())
82        .filter(|value| !value.is_empty())
83        .collect::<Vec<_>>()
84}
85
86fn context_keyword_score(node: &locus_core_rs::SttpNode, keywords: &[String]) -> usize {
87    let summary = node
88        .context_summary
89        .as_deref()
90        .map(|value| value.to_ascii_lowercase())
91        .unwrap_or_default();
92    let session_id = node.session_id.to_ascii_lowercase();
93
94    keywords
95        .iter()
96        .filter(|keyword| {
97            let needle = keyword.as_str();
98            summary.contains(needle) || session_id.contains(needle)
99        })
100        .count()
101}
102
103pub(crate) fn filter_nodes_by_context_keywords(
104    nodes: &[locus_core_rs::SttpNode],
105    keywords: &[String],
106    limit: usize,
107) -> Vec<locus_core_rs::SttpNode> {
108    let mut scored = nodes
109        .iter()
110        .filter_map(|node| {
111            let score = context_keyword_score(node, keywords);
112            if score == 0 {
113                None
114            } else {
115                Some((score, node.timestamp, node.clone()))
116            }
117        })
118        .collect::<Vec<_>>();
119
120    scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1)));
121
122    scored
123        .into_iter()
124        .take(limit)
125        .map(|(_, _, node)| node)
126        .collect::<Vec<_>>()
127}
128
129pub(crate) fn to_json_string(value: Value) -> String {
130    match serde_json::to_string(&value) {
131        Ok(serialized) => serialized,
132        Err(err) => tool_error("SerializationFailure", &err.to_string()),
133    }
134}
135
136pub(crate) fn tool_error(code: &str, message: &str) -> String {
137    to_json_string(json!({
138        "error": {
139            "code": code,
140            "message": message,
141            "model_guidance": schema_first_guidance_payload(
142                "If this error happened during payload construction, call get_schema first and align request shape."
143            ),
144        }
145    }))
146}
147
148pub(crate) fn infer_store_error_code(message: &str) -> &'static str {
149    let normalized = message.trim().to_ascii_lowercase();
150
151    if normalized.starts_with("parsefailure") {
152        "StrictTypedIrParseFailure"
153    } else if normalized.starts_with("ratelimited") {
154        "StoreRateLimited"
155    } else if normalized.starts_with("storefailure") {
156        "StoreFailure"
157    } else if normalized.contains("strict profile") {
158        "StrictTypedIrPolicyViolation"
159    } else {
160        "StoreContextFailure"
161    }
162}
163
164pub(crate) fn strict_typed_ir_profile_name() -> &'static str {
165    "strict_typed_ir"
166}
167
168fn schema_tool_name() -> &'static str {
169    "get_schema"
170}
171
172pub(crate) fn schema_first_guidance_payload(summary: &str) -> Value {
173    json!({
174        "summary": summary,
175        "recommended_first_tool": schema_tool_name(),
176        "recommended_next_steps": [
177            "call get_schema",
178            "verify payload layers provenance->envelope->content->metrics",
179            "ensure required typed-ir keys/enums/numerics are present before retry"
180        ],
181        "ingest_profile_policy": strict_typed_ir_profile_name(),
182    })
183}
184
185pub(crate) fn avec_to_json(avec: locus_core_rs::AvecState) -> Value {
186    json!({
187        "stability": avec.stability,
188        "friction": avec.friction,
189        "logic": avec.logic,
190        "autonomy": avec.autonomy,
191        "psi": avec.psi(),
192    })
193}
194
195pub(crate) fn sttp_node_to_json(node: &locus_core_rs::SttpNode) -> Value {
196    json!({
197        "raw": node.raw,
198        "session_id": node.session_id,
199        "tier": node.tier,
200        "timestamp": node.timestamp.to_rfc3339(),
201        "compression_depth": node.compression_depth,
202        "parent_node_id": node.parent_node_id,
203        "sync_key": node.sync_key,
204        "updated_at": node.updated_at.to_rfc3339(),
205        "source_metadata": node.source_metadata,
206        "context_summary": node.context_summary,
207        "has_embedding": node.embedding.as_ref().map(|values| !values.is_empty()).unwrap_or(false),
208        "embedding_model": node.embedding_model,
209        "embedding_dimensions": node.embedding_dimensions,
210        "embedded_at": node.embedded_at.map(|value| value.to_rfc3339()),
211        "user_avec": avec_to_json(node.user_avec),
212        "model_avec": avec_to_json(node.model_avec),
213        "compression_avec": node.compression_avec.map(avec_to_json),
214        "rho": node.rho,
215        "kappa": node.kappa,
216        "psi": node.psi,
217    })
218}