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