Skip to main content

lean_ctx/core/
intent_protocol.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use std::collections::hash_map::DefaultHasher;
5use std::hash::{Hash, Hasher};
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
8#[serde(rename_all = "snake_case")]
9pub enum IntentSource {
10    Inferred,
11    Explicit,
12}
13
14impl IntentSource {
15    pub fn as_str(&self) -> &'static str {
16        match self {
17            Self::Inferred => "inferred",
18            Self::Explicit => "explicit",
19        }
20    }
21}
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
24#[serde(rename_all = "snake_case")]
25pub enum IntentType {
26    Task,
27    Execute,
28    WorkflowTransition,
29    KnowledgeFact,
30    KnowledgeRecall,
31    Setup,
32    Unknown,
33}
34
35impl IntentType {
36    pub fn as_str(&self) -> &'static str {
37        match self {
38            Self::Task => "task",
39            Self::Execute => "execute",
40            Self::WorkflowTransition => "workflow_transition",
41            Self::KnowledgeFact => "knowledge_fact",
42            Self::KnowledgeRecall => "knowledge_recall",
43            Self::Setup => "setup",
44            Self::Unknown => "unknown",
45        }
46    }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(tag = "kind", rename_all = "snake_case")]
51pub enum IntentSubject {
52    Project {
53        root: Option<String>,
54    },
55    Command {
56        command: String,
57    },
58    Workflow {
59        action: String,
60    },
61    KnowledgeFact {
62        category: String,
63        key: String,
64        value: String,
65    },
66    KnowledgeQuery {
67        category: Option<String>,
68        query: Option<String>,
69    },
70    Tool {
71        name: String,
72    },
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct IntentRecord {
77    pub id: String,
78    pub source: IntentSource,
79    pub intent_type: IntentType,
80    pub subject: IntentSubject,
81    pub assertion: String,
82    pub confidence: f32,
83    #[serde(default)]
84    pub evidence_keys: Vec<String>,
85    #[serde(default)]
86    pub occurrences: u32,
87    pub timestamp: DateTime<Utc>,
88}
89
90impl IntentRecord {
91    pub fn fingerprint(&self) -> (IntentSource, IntentType, String, String) {
92        (
93            self.source.clone(),
94            self.intent_type.clone(),
95            format!("{:?}", self.subject),
96            self.assertion.clone(),
97        )
98    }
99}
100
101pub fn infer_from_tool_call(
102    tool: &str,
103    action: Option<&str>,
104    args: &serde_json::Map<String, Value>,
105    project_root: Option<&str>,
106) -> Option<IntentRecord> {
107    match tool {
108        "ctx_execute" => {
109            let cmd = get_str(args, "command")
110                .unwrap_or_default()
111                .trim()
112                .to_string();
113            if cmd.is_empty() {
114                return None;
115            }
116            Some(IntentRecord {
117                id: stable_id(tool, action, &cmd),
118                source: IntentSource::Inferred,
119                intent_type: IntentType::Execute,
120                subject: IntentSubject::Command {
121                    command: cmd.clone(),
122                },
123                assertion: truncate_one_line(&cmd, 180),
124                confidence: 0.9,
125                evidence_keys: evidence_keys_for(tool, action),
126                occurrences: 1,
127                timestamp: Utc::now(),
128            })
129        }
130        "ctx_workflow" => {
131            let a = action
132                .or_else(|| get_str(args, "action"))
133                .unwrap_or("unknown");
134            Some(IntentRecord {
135                id: stable_id(tool, Some(a), a),
136                source: IntentSource::Inferred,
137                intent_type: IntentType::WorkflowTransition,
138                subject: IntentSubject::Workflow {
139                    action: a.to_string(),
140                },
141                assertion: truncate_one_line(a, 180),
142                confidence: 0.75,
143                evidence_keys: evidence_keys_for(tool, Some(a)),
144                occurrences: 1,
145                timestamp: Utc::now(),
146            })
147        }
148        "ctx_knowledge" => {
149            let a = action
150                .or_else(|| get_str(args, "action"))
151                .unwrap_or("unknown");
152            match a {
153                "remember" => {
154                    let category = get_str(args, "category")?.to_string();
155                    let key = get_str(args, "key")?.to_string();
156                    let value = get_str(args, "value")?.to_string();
157                    Some(IntentRecord {
158                        id: stable_id(tool, Some(a), &format!("{category}/{key}")),
159                        source: IntentSource::Inferred,
160                        intent_type: IntentType::KnowledgeFact,
161                        subject: IntentSubject::KnowledgeFact {
162                            category: category.clone(),
163                            key: key.clone(),
164                            value: value.clone(),
165                        },
166                        assertion: truncate_one_line(&format!("{category}:{key}={value}"), 180),
167                        confidence: 0.9,
168                        evidence_keys: evidence_keys_for(tool, Some(a)),
169                        occurrences: 1,
170                        timestamp: Utc::now(),
171                    })
172                }
173                "recall" => Some(IntentRecord {
174                    id: stable_id(tool, Some(a), get_str(args, "query").unwrap_or("")),
175                    source: IntentSource::Inferred,
176                    intent_type: IntentType::KnowledgeRecall,
177                    subject: IntentSubject::KnowledgeQuery {
178                        category: get_str(args, "category").map(std::string::ToString::to_string),
179                        query: get_str(args, "query").map(std::string::ToString::to_string),
180                    },
181                    assertion: truncate_one_line(get_str(args, "query").unwrap_or(""), 180),
182                    confidence: 0.7,
183                    evidence_keys: evidence_keys_for(tool, Some(a)),
184                    occurrences: 1,
185                    timestamp: Utc::now(),
186                }),
187                _ => None,
188            }
189        }
190        "ctx_intent" => {
191            let query = get_str(args, "query").unwrap_or_default();
192            Some(intent_from_query(query, project_root))
193        }
194        "ctx_session" => {
195            let a = action
196                .or_else(|| get_str(args, "action"))
197                .unwrap_or("unknown");
198            if a != "task" {
199                return None;
200            }
201            let v = get_str(args, "value").unwrap_or("").trim().to_string();
202            if v.is_empty() {
203                return None;
204            }
205            Some(IntentRecord {
206                id: stable_id(tool, Some(a), &v),
207                source: IntentSource::Inferred,
208                intent_type: IntentType::Task,
209                subject: IntentSubject::Project {
210                    root: project_root.map(std::string::ToString::to_string),
211                },
212                assertion: truncate_one_line(&v, 220),
213                confidence: 0.8,
214                evidence_keys: evidence_keys_for(tool, Some(a)),
215                occurrences: 1,
216                timestamp: Utc::now(),
217            })
218        }
219        "setup" | "doctor" | "bootstrap" => Some(IntentRecord {
220            id: stable_id(tool, action, tool),
221            source: IntentSource::Inferred,
222            intent_type: IntentType::Setup,
223            subject: IntentSubject::Tool {
224                name: tool.to_string(),
225            },
226            assertion: tool.to_string(),
227            confidence: 0.8,
228            evidence_keys: evidence_keys_for(tool, action),
229            occurrences: 1,
230            timestamp: Utc::now(),
231        }),
232        _ => None,
233    }
234}
235
236pub fn intent_from_query(query: &str, project_root: Option<&str>) -> IntentRecord {
237    let now = Utc::now();
238    let q = query.trim();
239    if let Ok(v) = serde_json::from_str::<Value>(q) {
240        if let Some(obj) = v.as_object() {
241            if let Some(intent_type) = obj.get("intent_type").and_then(|v| v.as_str()) {
242                if let Some(intent) = intent_from_json(intent_type, obj, project_root, now) {
243                    return intent;
244                }
245            }
246        }
247    }
248
249    // Plain text → deterministic intent classification (short, no reads).
250    let multi = crate::core::intent_engine::detect_multi_intent(q);
251    let primary = multi.first();
252    let (intent_type, confidence) = if let Some(p) = primary {
253        (
254            IntentType::Task,
255            (p.confidence as f32).clamp(0.0, 1.0).max(0.6),
256        )
257    } else {
258        (IntentType::Task, 0.6)
259    };
260
261    let assertion = truncate_one_line(q, 220);
262    IntentRecord {
263        id: stable_id("ctx_intent", Some("query"), &assertion),
264        source: IntentSource::Explicit,
265        intent_type,
266        subject: IntentSubject::Project {
267            root: project_root.map(std::string::ToString::to_string),
268        },
269        assertion,
270        confidence,
271        evidence_keys: evidence_keys_for("ctx_intent", Some("query")),
272        occurrences: 1,
273        timestamp: now,
274    }
275}
276
277pub fn apply_side_effects(
278    intent: &IntentRecord,
279    project_root: Option<&str>,
280    session_id: &str,
281) -> Result<(), String> {
282    let Some(root) = project_root else {
283        return Ok(());
284    };
285
286    let IntentSubject::KnowledgeFact {
287        category,
288        key,
289        value,
290    } = &intent.subject
291    else {
292        return Ok(());
293    };
294
295    let policy = crate::core::config::Config::load()
296        .memory_policy_effective()
297        .map_err(|e| format!("invalid memory policy: {e}"))?;
298    // Load-modify-save under the shared lock so an intent side effect never
299    // clobbers a fact a concurrent (possibly background) writer commits in
300    // between (issue #326): a bare load + save would lose that update.
301    let confidence = intent.confidence.clamp(0.0, 1.0);
302    crate::core::knowledge::ProjectKnowledge::mutate_locked(root, |knowledge| {
303        let _ = knowledge.remember(category, key, value, session_id, confidence, &policy);
304        let _ = knowledge.run_memory_lifecycle(&policy);
305    })?;
306    Ok(())
307}
308
309fn intent_from_json(
310    intent_type: &str,
311    obj: &serde_json::Map<String, Value>,
312    project_root: Option<&str>,
313    now: DateTime<Utc>,
314) -> Option<IntentRecord> {
315    match intent_type {
316        "knowledge_fact" => {
317            let category = obj.get("category")?.as_str()?.to_string();
318            let key = obj.get("key")?.as_str()?.to_string();
319            let value = obj.get("value")?.as_str()?.to_string();
320            let assertion = truncate_one_line(&format!("{category}:{key}={value}"), 220);
321            Some(IntentRecord {
322                id: stable_id(
323                    "ctx_intent",
324                    Some("knowledge_fact"),
325                    &format!("{category}/{key}"),
326                ),
327                source: IntentSource::Explicit,
328                intent_type: IntentType::KnowledgeFact,
329                subject: IntentSubject::KnowledgeFact {
330                    category,
331                    key,
332                    value,
333                },
334                assertion,
335                confidence: obj
336                    .get("confidence")
337                    .and_then(serde_json::Value::as_f64)
338                    .unwrap_or(0.8)
339                    .clamp(0.0, 1.0) as f32,
340                evidence_keys: evidence_keys_for("ctx_intent", Some("knowledge_fact")),
341                occurrences: 1,
342                timestamp: now,
343            })
344        }
345        "task" => {
346            let assertion = obj
347                .get("assertion")
348                .and_then(|v| v.as_str())
349                .unwrap_or("")
350                .to_string();
351            if assertion.trim().is_empty() {
352                return None;
353            }
354            Some(IntentRecord {
355                id: stable_id("ctx_intent", Some("task"), &assertion),
356                source: IntentSource::Explicit,
357                intent_type: IntentType::Task,
358                subject: IntentSubject::Project {
359                    root: project_root.map(std::string::ToString::to_string),
360                },
361                assertion: truncate_one_line(&assertion, 220),
362                confidence: obj
363                    .get("confidence")
364                    .and_then(serde_json::Value::as_f64)
365                    .unwrap_or(0.75)
366                    .clamp(0.0, 1.0) as f32,
367                evidence_keys: evidence_keys_for("ctx_intent", Some("task")),
368                occurrences: 1,
369                timestamp: now,
370            })
371        }
372        _ => None,
373    }
374}
375
376fn evidence_keys_for(tool: &str, action: Option<&str>) -> Vec<String> {
377    let mut keys = vec![format!("tool:{tool}")];
378    if let Some(a) = action {
379        if !a.is_empty() {
380            keys.push(format!("tool:{tool}:{a}"));
381        }
382    }
383    keys
384}
385
386fn stable_id(tool: &str, action: Option<&str>, seed: &str) -> String {
387    let mut hasher = DefaultHasher::new();
388    tool.hash(&mut hasher);
389    action.unwrap_or("").hash(&mut hasher);
390    seed.hash(&mut hasher);
391    format!("{:016x}", hasher.finish())
392}
393
394fn get_str<'a>(m: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a str> {
395    m.get(key).and_then(|v| v.as_str())
396}
397
398fn truncate_one_line(s: &str, max: usize) -> String {
399    let mut t = s.replace(['\n', '\r'], " ").replace('`', "");
400    while t.contains("  ") {
401        t = t.replace("  ", " ");
402    }
403    let t = t.trim();
404    if t.chars().count() <= max {
405        return t.to_string();
406    }
407    let mut out = String::new();
408    for (i, ch) in t.chars().enumerate() {
409        if i + 1 >= max {
410            break;
411        }
412        out.push(ch);
413    }
414    out.push('…');
415    out
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn infer_execute() {
424        let mut args = serde_json::Map::new();
425        args.insert(
426            "command".to_string(),
427            Value::String("cargo test".to_string()),
428        );
429        let i = infer_from_tool_call("ctx_execute", None, &args, Some(".")).expect("intent");
430        assert_eq!(i.intent_type, IntentType::Execute);
431    }
432}