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_knowledge" => {
131            let a = action
132                .or_else(|| get_str(args, "action"))
133                .unwrap_or("unknown");
134            match a {
135                "remember" => {
136                    let category = get_str(args, "category")?.to_string();
137                    let key = get_str(args, "key")?.to_string();
138                    let value = get_str(args, "value")?.to_string();
139                    Some(IntentRecord {
140                        id: stable_id(tool, Some(a), &format!("{category}/{key}")),
141                        source: IntentSource::Inferred,
142                        intent_type: IntentType::KnowledgeFact,
143                        subject: IntentSubject::KnowledgeFact {
144                            category: category.clone(),
145                            key: key.clone(),
146                            value: value.clone(),
147                        },
148                        assertion: truncate_one_line(&format!("{category}:{key}={value}"), 180),
149                        confidence: 0.9,
150                        evidence_keys: evidence_keys_for(tool, Some(a)),
151                        occurrences: 1,
152                        timestamp: Utc::now(),
153                    })
154                }
155                "recall" => Some(IntentRecord {
156                    id: stable_id(tool, Some(a), get_str(args, "query").unwrap_or("")),
157                    source: IntentSource::Inferred,
158                    intent_type: IntentType::KnowledgeRecall,
159                    subject: IntentSubject::KnowledgeQuery {
160                        category: get_str(args, "category").map(std::string::ToString::to_string),
161                        query: get_str(args, "query").map(std::string::ToString::to_string),
162                    },
163                    assertion: truncate_one_line(get_str(args, "query").unwrap_or(""), 180),
164                    confidence: 0.7,
165                    evidence_keys: evidence_keys_for(tool, Some(a)),
166                    occurrences: 1,
167                    timestamp: Utc::now(),
168                }),
169                _ => None,
170            }
171        }
172        "ctx_session" => {
173            let a = action
174                .or_else(|| get_str(args, "action"))
175                .unwrap_or("unknown");
176            if a != "task" {
177                return None;
178            }
179            let v = get_str(args, "value").unwrap_or("").trim().to_string();
180            if v.is_empty() {
181                return None;
182            }
183            Some(IntentRecord {
184                id: stable_id(tool, Some(a), &v),
185                source: IntentSource::Inferred,
186                intent_type: IntentType::Task,
187                subject: IntentSubject::Project {
188                    root: project_root.map(std::string::ToString::to_string),
189                },
190                assertion: truncate_one_line(&v, 220),
191                confidence: 0.8,
192                evidence_keys: evidence_keys_for(tool, Some(a)),
193                occurrences: 1,
194                timestamp: Utc::now(),
195            })
196        }
197        "setup" | "doctor" | "bootstrap" => Some(IntentRecord {
198            id: stable_id(tool, action, tool),
199            source: IntentSource::Inferred,
200            intent_type: IntentType::Setup,
201            subject: IntentSubject::Tool {
202                name: tool.to_string(),
203            },
204            assertion: tool.to_string(),
205            confidence: 0.8,
206            evidence_keys: evidence_keys_for(tool, action),
207            occurrences: 1,
208            timestamp: Utc::now(),
209        }),
210        _ => None,
211    }
212}
213
214fn evidence_keys_for(tool: &str, action: Option<&str>) -> Vec<String> {
215    let mut keys = vec![format!("tool:{tool}")];
216    if let Some(a) = action
217        && !a.is_empty()
218    {
219        keys.push(format!("tool:{tool}:{a}"));
220    }
221    keys
222}
223
224fn stable_id(tool: &str, action: Option<&str>, seed: &str) -> String {
225    let mut hasher = DefaultHasher::new();
226    tool.hash(&mut hasher);
227    action.unwrap_or("").hash(&mut hasher);
228    seed.hash(&mut hasher);
229    format!("{:016x}", hasher.finish())
230}
231
232fn get_str<'a>(m: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a str> {
233    m.get(key).and_then(|v| v.as_str())
234}
235
236fn truncate_one_line(s: &str, max: usize) -> String {
237    let mut t = s.replace(['\n', '\r'], " ").replace('`', "");
238    while t.contains("  ") {
239        t = t.replace("  ", " ");
240    }
241    let t = t.trim();
242    if t.chars().count() <= max {
243        return t.to_string();
244    }
245    let mut out = String::new();
246    for (i, ch) in t.chars().enumerate() {
247        if i + 1 >= max {
248            break;
249        }
250        out.push(ch);
251    }
252    out.push('…');
253    out
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn infer_execute() {
262        let mut args = serde_json::Map::new();
263        args.insert(
264            "command".to_string(),
265            Value::String("cargo test".to_string()),
266        );
267        let i = infer_from_tool_call("ctx_execute", None, &args, Some(".")).expect("intent");
268        assert_eq!(i.intent_type, IntentType::Execute);
269    }
270}