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}