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 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}