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(|s| s.to_string()),
179                        query: get_str(args, "query").map(|s| s.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(|s| s.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(|s| s.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(intent: &IntentRecord, project_root: Option<&str>, session_id: &str) {
278    let Some(root) = project_root else {
279        return;
280    };
281
282    let IntentSubject::KnowledgeFact {
283        category,
284        key,
285        value,
286    } = &intent.subject
287    else {
288        return;
289    };
290
291    let mut knowledge = crate::core::knowledge::ProjectKnowledge::load(root)
292        .unwrap_or_else(|| crate::core::knowledge::ProjectKnowledge::new(root));
293    let _ = knowledge.remember(
294        category,
295        key,
296        value,
297        session_id,
298        intent.confidence.clamp(0.0, 1.0),
299    );
300    let _ = knowledge.run_memory_lifecycle();
301    let _ = knowledge.save();
302}
303
304fn intent_from_json(
305    intent_type: &str,
306    obj: &serde_json::Map<String, Value>,
307    project_root: Option<&str>,
308    now: DateTime<Utc>,
309) -> Option<IntentRecord> {
310    match intent_type {
311        "knowledge_fact" => {
312            let category = obj.get("category")?.as_str()?.to_string();
313            let key = obj.get("key")?.as_str()?.to_string();
314            let value = obj.get("value")?.as_str()?.to_string();
315            let assertion = truncate_one_line(&format!("{category}:{key}={value}"), 220);
316            Some(IntentRecord {
317                id: stable_id(
318                    "ctx_intent",
319                    Some("knowledge_fact"),
320                    &format!("{category}/{key}"),
321                ),
322                source: IntentSource::Explicit,
323                intent_type: IntentType::KnowledgeFact,
324                subject: IntentSubject::KnowledgeFact {
325                    category,
326                    key,
327                    value,
328                },
329                assertion,
330                confidence: obj
331                    .get("confidence")
332                    .and_then(|v| v.as_f64())
333                    .unwrap_or(0.8)
334                    .clamp(0.0, 1.0) as f32,
335                evidence_keys: evidence_keys_for("ctx_intent", Some("knowledge_fact")),
336                occurrences: 1,
337                timestamp: now,
338            })
339        }
340        "task" => {
341            let assertion = obj
342                .get("assertion")
343                .and_then(|v| v.as_str())
344                .unwrap_or("")
345                .to_string();
346            if assertion.trim().is_empty() {
347                return None;
348            }
349            Some(IntentRecord {
350                id: stable_id("ctx_intent", Some("task"), &assertion),
351                source: IntentSource::Explicit,
352                intent_type: IntentType::Task,
353                subject: IntentSubject::Project {
354                    root: project_root.map(|s| s.to_string()),
355                },
356                assertion: truncate_one_line(&assertion, 220),
357                confidence: obj
358                    .get("confidence")
359                    .and_then(|v| v.as_f64())
360                    .unwrap_or(0.75)
361                    .clamp(0.0, 1.0) as f32,
362                evidence_keys: evidence_keys_for("ctx_intent", Some("task")),
363                occurrences: 1,
364                timestamp: now,
365            })
366        }
367        _ => None,
368    }
369}
370
371fn evidence_keys_for(tool: &str, action: Option<&str>) -> Vec<String> {
372    let mut keys = vec![format!("tool:{tool}")];
373    if let Some(a) = action {
374        if !a.is_empty() {
375            keys.push(format!("tool:{tool}:{a}"));
376        }
377    }
378    keys
379}
380
381fn stable_id(tool: &str, action: Option<&str>, seed: &str) -> String {
382    let mut hasher = DefaultHasher::new();
383    tool.hash(&mut hasher);
384    action.unwrap_or("").hash(&mut hasher);
385    seed.hash(&mut hasher);
386    format!("{:016x}", hasher.finish())
387}
388
389fn get_str<'a>(m: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a str> {
390    m.get(key).and_then(|v| v.as_str())
391}
392
393fn truncate_one_line(s: &str, max: usize) -> String {
394    let mut t = s.replace(['\n', '\r'], " ").replace('`', "");
395    while t.contains("  ") {
396        t = t.replace("  ", " ");
397    }
398    let t = t.trim();
399    if t.chars().count() <= max {
400        return t.to_string();
401    }
402    let mut out = String::new();
403    for (i, ch) in t.chars().enumerate() {
404        if i + 1 >= max {
405            break;
406        }
407        out.push(ch);
408    }
409    out.push('…');
410    out
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416
417    #[test]
418    fn infer_execute() {
419        let mut args = serde_json::Map::new();
420        args.insert(
421            "command".to_string(),
422            Value::String("cargo test".to_string()),
423        );
424        let i = infer_from_tool_call("ctx_execute", None, &args, Some(".")).expect("intent");
425        assert_eq!(i.intent_type, IntentType::Execute);
426    }
427}