1use crate::core::session::SessionState;
2
3pub fn handle(
4 session: &mut SessionState,
5 tool_calls: &[(String, u64)],
6 action: &str,
7 value: Option<&str>,
8 session_id: Option<&str>,
9) -> String {
10 match action {
11 "status" => session.format_compact(),
12
13 "load" => {
14 let loaded = if let Some(id) = session_id {
15 SessionState::load_by_id(id)
16 } else {
17 SessionState::load_latest()
18 };
19
20 if let Some(prev) = loaded {
21 let summary = prev.format_compact();
22 *session = prev;
23 format!("Session loaded.\n{summary}")
24 } else {
25 let id_str = session_id.unwrap_or("latest");
26 format!("No session found (id: {id_str}). Starting fresh.")
27 }
28 }
29
30 "save" => {
31 match session.save() {
32 Ok(()) => format!("Session {} saved (v{}).", session.id, session.version),
33 Err(e) => format!("Save failed: {e}"),
34 }
35 }
36
37 "task" => {
38 let desc = value.unwrap_or("(no description)");
39 session.set_task(desc, None);
40 format!("Task set: {desc}")
41 }
42
43 "finding" => {
44 let summary = value.unwrap_or("(no summary)");
45 let (file, line, text) = parse_finding_value(summary);
46 session.add_finding(file.as_deref(), line, text);
47 format!("Finding added: {summary}")
48 }
49
50 "decision" => {
51 let desc = value.unwrap_or("(no description)");
52 session.add_decision(desc, None);
53 format!("Decision recorded: {desc}")
54 }
55
56 "reset" => {
57 let _ = session.save();
58 let old_id = session.id.clone();
59 *session = SessionState::new();
60 crate::core::budget_tracker::BudgetTracker::global().reset();
61 format!("Session reset. Previous: {old_id}. New: {}", session.id)
62 }
63
64 "list" => {
65 let sessions = SessionState::list_sessions();
66 if sessions.is_empty() {
67 return "No sessions found.".to_string();
68 }
69 let mut lines = vec![format!("Sessions ({}):", sessions.len())];
70 for s in sessions.iter().take(10) {
71 let task = s.task.as_deref().unwrap_or("(no task)");
72 let task_short: String = task.chars().take(40).collect();
73 lines.push(format!(
74 " {} v{} | {} calls | {} tok | {}",
75 s.id, s.version, s.tool_calls, s.tokens_saved, task_short
76 ));
77 }
78 if sessions.len() > 10 {
79 lines.push(format!(" ... +{} more", sessions.len() - 10));
80 }
81 lines.join("\n")
82 }
83
84 "cleanup" => {
85 let removed = SessionState::cleanup_old_sessions(7);
86 format!("Cleaned up {removed} old session(s) (>7 days).")
87 }
88
89 "snapshot" => match session.save_compaction_snapshot() {
90 Ok(snapshot) => {
91 format!(
92 "Compaction snapshot saved ({} bytes).\n{snapshot}",
93 snapshot.len()
94 )
95 }
96 Err(e) => format!("Snapshot failed: {e}"),
97 },
98
99 "restore" => {
100 let snapshot = if let Some(id) = session_id {
101 SessionState::load_compaction_snapshot(id)
102 } else {
103 SessionState::load_latest_snapshot()
104 };
105 match snapshot {
106 Some(s) => format!("Session restored from compaction snapshot:\n{s}"),
107 None => "No compaction snapshot found. Session continues fresh.".to_string(),
108 }
109 }
110
111 "resume" => session.build_resume_block(),
112
113 "profile" => {
114 use crate::core::profiles;
115 if let Some(name) = value {
116 if let Ok(p) = profiles::set_active_profile(name) {
117 format!(
118 "Profile switched to '{name}'.\n\
119 Read mode: {}, Budget: {} tokens, CRP: {}, Density: {}",
120 p.read.default_mode,
121 p.budget.max_context_tokens,
122 p.compression.crp_mode,
123 p.compression.output_density,
124 )
125 } else {
126 let available: Vec<String> =
127 profiles::list_profiles().iter().map(|p| p.name.clone()).collect();
128 format!(
129 "Profile '{name}' not found. Available: {}",
130 available.join(", ")
131 )
132 }
133 } else {
134 let name = profiles::active_profile_name();
135 let p = profiles::active_profile();
136 let list = profiles::list_profiles();
137 let mut out = format!(
138 "Active profile: {name}\n\
139 Read: {}, Budget: {} tok, CRP: {}, Density: {}\n\n\
140 Available profiles:",
141 p.read.default_mode,
142 p.budget.max_context_tokens,
143 p.compression.crp_mode,
144 p.compression.output_density,
145 );
146 for info in &list {
147 let marker = if info.name == name { " *" } else { " " };
148 out.push_str(&format!(
149 "\n{marker} {:<14} ({}) {}",
150 info.name, info.source, info.description
151 ));
152 }
153 out.push_str("\n\nSwitch: ctx_session action=profile value=<name>");
154 out
155 }
156 }
157
158 "budget" => {
159 use crate::core::budget_tracker::BudgetTracker;
160 let snap = BudgetTracker::global().check();
161 snap.format_compact()
162 }
163
164 "role" => {
165 use crate::core::roles;
166 if let Some(name) = value {
167 match roles::set_active_role(name) {
168 Ok(r) => {
169 crate::core::budget_tracker::BudgetTracker::global().reset();
170 format!(
171 "Role switched to '{name}'.\n\
172 Shell: {}, Budget: {} tokens / {} shell / ${:.2}\n\
173 Tools: {}",
174 r.role.shell_policy,
175 r.limits.max_context_tokens,
176 r.limits.max_shell_invocations,
177 r.limits.max_cost_usd,
178 if r.tools.allowed.iter().any(|a| a == "*") {
179 let denied = if r.tools.denied.is_empty() {
180 "none".to_string()
181 } else {
182 format!("denied: {}", r.tools.denied.join(", "))
183 };
184 format!("* (all), {denied}")
185 } else {
186 r.tools.allowed.join(", ")
187 }
188 )
189 }
190 Err(e) => {
191 let available: Vec<String> =
192 roles::list_roles().iter().map(|r| r.name.clone()).collect();
193 format!("{e}. Available: {}", available.join(", "))
194 }
195 }
196 } else {
197 let name = roles::active_role_name();
198 let r = roles::active_role();
199 let list = roles::list_roles();
200 let mut out = format!(
201 "Active role: {name}\n\
202 Description: {}\n\
203 Shell policy: {}, Budget: {} tokens / {} shell / ${:.2}\n\n\
204 Available roles:",
205 r.role.description,
206 r.role.shell_policy,
207 r.limits.max_context_tokens,
208 r.limits.max_shell_invocations,
209 r.limits.max_cost_usd,
210 );
211 for info in &list {
212 let marker = if info.is_active { " *" } else { " " };
213 out.push_str(&format!(
214 "\n{marker} {:<14} ({}) {}",
215 info.name, info.source, info.description
216 ));
217 }
218 out.push_str("\n\nSwitch: ctx_session action=role value=<name>");
219 out
220 }
221 }
222
223 "diff" => {
224 let parts: Vec<&str> = value.unwrap_or("").split_whitespace().collect();
225 if parts.len() < 2 {
226 return "Usage: ctx_session diff <session_id_a> <session_id_b> [format]\n\
227 Formats: summary (default), json\n\
228 Example: ctx_session diff abc123 def456 json"
229 .to_string();
230 }
231 let id_a = parts[0];
232 let id_b = parts[1];
233 let format = parts.get(2).copied().unwrap_or("summary");
234
235 let sess_a = SessionState::load_by_id(id_a);
236 let sess_b = SessionState::load_by_id(id_b);
237
238 match (sess_a, sess_b) {
239 (Some(a), Some(b)) => {
240 let d = crate::core::session_diff::diff_sessions(&a, &b);
241 match format {
242 "json" => d.format_json(),
243 _ => d.format_summary(),
244 }
245 }
246 (None, _) => format!("Session not found: {id_a}"),
247 (_, None) => format!("Session not found: {id_b}"),
248 }
249 }
250
251 "slo" => {
252 match value {
253 Some("reload") => {
254 crate::core::slo::reload();
255 "SLO definitions reloaded from disk.".to_string()
256 }
257 Some("history") => {
258 let hist = crate::core::slo::violation_history(20);
259 if hist.is_empty() {
260 "No SLO violations recorded.".to_string()
261 } else {
262 let mut out = format!("SLO violations (last {}):\n", hist.len());
263 for v in &hist {
264 out.push_str(&format!(
265 " {} {} ({}) {:.2} vs {:.2} → {}\n",
266 v.timestamp, v.slo_name, v.metric, v.actual, v.threshold, v.action
267 ));
268 }
269 out
270 }
271 }
272 Some("clear") => {
273 crate::core::slo::clear_violations();
274 "SLO violation history cleared.".to_string()
275 }
276 _ => {
277 let snap = crate::core::slo::evaluate_quiet();
278 snap.format_compact()
279 }
280 }
281 }
282
283 "verify" => {
284 let snap = crate::core::output_verification::stats_snapshot();
285 snap.format_compact()
286 }
287
288 "episodes" => {
289 let project_root = session.project_root.clone().unwrap_or_else(|| {
290 std::env::current_dir().map_or_else(
291 |_| "unknown".to_string(),
292 |p| p.to_string_lossy().to_string(),
293 )
294 });
295 let policy = match crate::core::config::Config::load().memory_policy_effective() {
296 Ok(p) => p,
297 Err(e) => {
298 let path = crate::core::config::Config::path().map_or_else(
299 || "~/.lean-ctx/config.toml".to_string(),
300 |p| p.display().to_string(),
301 );
302 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
303 }
304 };
305 let hash = crate::core::project_hash::hash_project_root(&project_root);
306 let mut store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
307
308 match value {
309 Some("record") => {
310 let ep = crate::core::episodic_memory::create_episode_from_session(
311 session,
312 tool_calls,
313 );
314 let id = ep.id.clone();
315 store.record_episode(ep, &policy.episodic);
316 if let Err(e) = store.save() {
317 return format!("Episode record failed: {e}");
318 }
319 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
320 category: "episodic".to_string(),
321 key: id.clone(),
322 action: "record".to_string(),
323 });
324 format!("Episode recorded: {id}")
325 }
326 Some(v) if v.starts_with("search ") => {
327 let q = v.trim_start_matches("search ").trim();
328 let hits = store.search(q);
329 if hits.is_empty() {
330 return "No episodes matched.".to_string();
331 }
332 let mut out = format!("Episodes matched ({}):", hits.len());
333 for ep in hits.into_iter().take(10) {
334 let task: String = ep.task_description.chars().take(50).collect();
335 out.push_str(&format!(
336 "\n {} | {} | {} | {}",
337 ep.id,
338 ep.timestamp,
339 ep.outcome.label(),
340 task
341 ));
342 }
343 out
344 }
345 Some(v) if v.starts_with("file ") => {
346 let f = v.trim_start_matches("file ").trim();
347 let hits = store.by_file(f);
348 let mut out = format!("Episodes for file match '{f}' ({}):", hits.len());
349 for ep in hits.into_iter().take(10) {
350 let task: String = ep.task_description.chars().take(50).collect();
351 out.push_str(&format!(
352 "\n {} | {} | {} | {}",
353 ep.id,
354 ep.timestamp,
355 ep.outcome.label(),
356 task
357 ));
358 }
359 out
360 }
361 Some(v) if v.starts_with("outcome ") => {
362 let label = v.trim_start_matches("outcome ").trim();
363 let hits = store.by_outcome(label);
364 let mut out = format!("Episodes outcome '{label}' ({}):", hits.len());
365 for ep in hits.into_iter().take(10) {
366 let task: String = ep.task_description.chars().take(50).collect();
367 out.push_str(&format!("\n {} | {} | {}", ep.id, ep.timestamp, task));
368 }
369 out
370 }
371 _ => {
372 let stats = store.stats();
373 let recent = store.recent(10);
374 let mut out = format!(
375 "Episodic memory: {} episodes, success_rate={:.0}%, tokens_total={}\n\nRecent:",
376 stats.total_episodes,
377 stats.success_rate * 100.0,
378 stats.total_tokens
379 );
380 for ep in recent {
381 let task: String = ep.task_description.chars().take(60).collect();
382 out.push_str(&format!(
383 "\n {} | {} | {} | {}",
384 ep.id,
385 ep.timestamp,
386 ep.outcome.label(),
387 task
388 ));
389 }
390 out.push_str("\n\nActions: ctx_session action=episodes value=record|\"search <q>\"|\"file <path>\"|\"outcome success|failure|partial|unknown\"");
391 out
392 }
393 }
394 }
395
396 "procedures" => {
397 let project_root = session.project_root.clone().unwrap_or_else(|| {
398 std::env::current_dir().map_or_else(
399 |_| "unknown".to_string(),
400 |p| p.to_string_lossy().to_string(),
401 )
402 });
403 let policy = match crate::core::config::Config::load().memory_policy_effective() {
404 Ok(p) => p,
405 Err(e) => {
406 let path = crate::core::config::Config::path().map_or_else(
407 || "~/.lean-ctx/config.toml".to_string(),
408 |p| p.display().to_string(),
409 );
410 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
411 }
412 };
413 let hash = crate::core::project_hash::hash_project_root(&project_root);
414 let episodes = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
415 let mut procs = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
416
417 match value {
418 Some("detect") => {
419 procs.detect_patterns(&episodes.episodes, &policy.procedural);
420 if let Err(e) = procs.save() {
421 return format!("Procedure detect failed: {e}");
422 }
423 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
424 category: "procedural".to_string(),
425 key: hash.clone(),
426 action: "detect".to_string(),
427 });
428 format!(
429 "Procedures updated. Total procedures: {} (episodes: {}).",
430 procs.procedures.len(),
431 episodes.episodes.len()
432 )
433 }
434 Some(v) if v.starts_with("suggest ") => {
435 let task = v.trim_start_matches("suggest ").trim();
436 let hits = procs.suggest(task);
437 if hits.is_empty() {
438 return "No procedures matched.".to_string();
439 }
440 let mut out = format!("Procedures suggested ({}):", hits.len());
441 for p in hits.into_iter().take(10) {
442 out.push_str(&format!(
443 "\n {} | conf={:.0}% | success={:.0}% | steps={}",
444 p.name,
445 p.confidence * 100.0,
446 p.success_rate() * 100.0,
447 p.steps.len()
448 ));
449 }
450 out
451 }
452 _ => {
453 let task = session
454 .task
455 .as_ref()
456 .map(|t| t.description.clone())
457 .unwrap_or_default();
458 let suggestions = if task.is_empty() {
459 Vec::new()
460 } else {
461 procs.suggest(&task)
462 };
463
464 let mut out = format!(
465 "Procedural memory: {} procedures (episodes: {})",
466 procs.procedures.len(),
467 episodes.episodes.len()
468 );
469
470 if !task.is_empty() {
471 out.push_str(&format!("\nTask: {}", task.chars().take(80).collect::<String>()));
472 if !suggestions.is_empty() {
473 out.push_str("\n\nSuggested:");
474 for p in suggestions.into_iter().take(5) {
475 out.push_str(&format!(
476 "\n {} | conf={:.0}% | success={:.0}% | steps={}",
477 p.name,
478 p.confidence * 100.0,
479 p.success_rate() * 100.0,
480 p.steps.len()
481 ));
482 }
483 }
484 }
485
486 out.push_str("\n\nActions: ctx_session action=procedures value=detect|\"suggest <task>\"");
487 out
488 }
489 }
490 }
491
492 _ => format!("Unknown action: {action}. Use: status, load, save, task, finding, decision, reset, list, cleanup, snapshot, restore, resume, profile, role, budget, slo, diff, verify, episodes, procedures"),
493 }
494}
495
496fn parse_finding_value(value: &str) -> (Option<String>, Option<u32>, &str) {
497 if let Some(dash_pos) = value.find(" \u{2014} ").or_else(|| value.find(" - ")) {
499 let location = &value[..dash_pos];
500 let sep_len = 3;
501 let text = &value[dash_pos + sep_len..];
502
503 if let Some(colon_pos) = location.rfind(':') {
504 let file = &location[..colon_pos];
505 if let Ok(line) = location[colon_pos + 1..].parse::<u32>() {
506 return (Some(file.to_string()), Some(line), text);
507 }
508 }
509 return (Some(location.to_string()), None, text);
510 }
511 (None, None, value)
512}