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}