1use crate::core::session::SessionState;
2
3#[derive(Clone, Copy, Debug)]
4pub struct SessionToolOptions<'a> {
5 pub format: Option<&'a str>,
6 pub path: Option<&'a str>,
7 pub write: bool,
8 pub privacy: Option<&'a str>,
9 pub terse: Option<bool>,
11 pub agent_id: Option<&'a str>,
12}
13
14pub fn handle(
15 session: &mut SessionState,
16 tool_calls: &[(String, u64)],
17 action: &str,
18 value: Option<&str>,
19 session_id: Option<&str>,
20 opts: SessionToolOptions<'_>,
21) -> String {
22 match action {
23 "status" | "show" => handle_status(session),
24 "load" => handle_load(session, session_id),
25 "save" => handle_save(session),
26 "export" => handle_export(session, value, opts),
27 "import" => handle_import(session, value, opts),
28 "task" => handle_task(session, tool_calls, value, opts),
29 "finding" => handle_finding(session, value),
30 "decision" => handle_decision(session, value),
31 "reset" => handle_reset(session),
32 "list" => handle_list(),
33 "cleanup" => handle_cleanup(),
34 "configure" => handle_configure(session, opts),
35 "snapshot" => handle_snapshot(session),
36 "restore" => handle_restore(session_id),
37 "resume" => handle_resume(session),
38 "profile" => handle_profile(value),
39 "budget" => handle_budget(),
40 "role" => handle_role(value),
41 "diff" => handle_diff(value),
42 "slo" => handle_slo(value),
43 "output_stats" => handle_output_stats(),
44 "verify" => handle_verify(),
45 "episodes" => handle_episodes(session, tool_calls, value, opts),
46 "procedures" => handle_procedures(session, value),
47 _ => handle_unknown(action),
48 }
49}
50
51fn handle_status(session: &mut SessionState) -> String {
52 session.format_compact()
53}
54
55fn handle_load(session: &mut SessionState, session_id: Option<&str>) -> String {
56 {
57 let loaded = if let Some(id) = session_id {
58 SessionState::load_by_id(id)
59 } else {
60 SessionState::load_latest()
61 };
62
63 if let Some(prev) = loaded {
64 let summary = prev.format_compact();
65 *session = prev;
66 format!("Session loaded.\n{summary}")
67 } else {
68 let id_str = session_id.unwrap_or("latest");
69 format!("No session found (id: {id_str}). Starting fresh.")
70 }
71 }
72}
73
74fn handle_save(session: &mut SessionState) -> String {
75 match session.save() {
76 Ok(()) => format!("Session {} saved (v{}).", session.id, session.version),
77 Err(e) => format!("Save failed: {e}"),
78 }
79}
80
81fn handle_export(
82 session: &mut SessionState,
83 value: Option<&str>,
84 opts: SessionToolOptions<'_>,
85) -> String {
86 {
87 let requested_privacy =
88 crate::core::ccp_session_bundle::BundlePrivacyV1::parse(opts.privacy);
89 if requested_privacy == crate::core::ccp_session_bundle::BundlePrivacyV1::Full
90 && crate::core::roles::active_role_name() != "admin"
91 {
92 return "ERROR: privacy=full requires role 'admin'.".to_string();
93 }
94
95 let bundle = crate::core::ccp_session_bundle::build_bundle_v1(session, requested_privacy);
96 let json = match crate::core::ccp_session_bundle::serialize_bundle_v1_pretty(&bundle) {
97 Ok(s) => s,
98 Err(e) => return e,
99 };
100
101 let format = opts
102 .format
103 .unwrap_or(if opts.write { "summary" } else { "json" });
104 let root = session.project_root.clone().unwrap_or_else(|| {
105 std::env::current_dir()
106 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
107 });
108 let root_path = std::path::PathBuf::from(&root);
109
110 let mut written: Option<String> = None;
111 if opts.write || opts.path.is_some() {
112 let ts = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
113 let candidate = if let Some(p) = opts.path.or(value) {
114 let p = std::path::PathBuf::from(p);
115 if p.is_absolute() {
116 p
117 } else {
118 root_path.join(p)
119 }
120 } else {
121 root_path
122 .join(".lean-ctx")
123 .join("session_bundles")
124 .join(format!(
125 "ccp-session-bundle-v1_{}_{}.json",
126 bundle.session.id, ts
127 ))
128 };
129
130 let jailed = match crate::core::io_boundary::jail_and_check_path(
131 "ctx_session.export",
132 candidate.as_path(),
133 root_path.as_path(),
134 ) {
135 Ok((p, _warning)) => p,
136 Err(e) => return e,
137 };
138
139 if let Err(e) = crate::core::pathjail::enforce_writable(&jailed) {
142 return format!("Export write failed: {e}");
143 }
144 if let Err(e) = crate::core::ccp_session_bundle::write_bundle_v1(&jailed, &json) {
145 return format!("Export write failed: {e}");
146 }
147 written = Some(jailed.to_string_lossy().to_string());
148 }
149
150 match format {
151 "summary" => {
152 let mut out = format!(
153 "CCP session bundle exported (v{}).\n\
154schema_version: {}\n\
155session_id: {}\n\
156bytes: {}\n",
157 bundle.session.version,
158 bundle.schema_version,
159 bundle.session.id,
160 json.len()
161 );
162 if let Some(p) = written {
163 out.push_str(&format!("path: {p}\n"));
164 }
165 if let Some(h) = bundle.project.project_root_hash {
166 out.push_str(&format!("project_root_hash: {h}\n"));
167 }
168 if let Some(h) = bundle.project.project_identity_hash {
169 out.push_str(&format!("project_identity_hash: {h}\n"));
170 }
171 out
172 }
173 _ => {
174 if let Some(p) = written {
175 format!("{json}\n\npath: {p}")
176 } else {
177 json
178 }
179 }
180 }
181 }
182}
183
184fn handle_import(
185 session: &mut SessionState,
186 value: Option<&str>,
187 opts: SessionToolOptions<'_>,
188) -> String {
189 {
190 let root = session.project_root.clone().unwrap_or_else(|| {
191 std::env::current_dir()
192 .map_or_else(|_| ".".to_string(), |p| p.to_string_lossy().to_string())
193 });
194 let root_path = std::path::PathBuf::from(&root);
195
196 let Some(p) = opts.path.or(value) else {
197 return "ERROR: path is required for action=import".to_string();
198 };
199
200 let candidate = {
201 let p = std::path::PathBuf::from(p);
202 if p.is_absolute() {
203 p
204 } else {
205 root_path.join(p)
206 }
207 };
208 let jailed = match crate::core::io_boundary::jail_and_check_path(
209 "ctx_session.import",
210 candidate.as_path(),
211 root_path.as_path(),
212 ) {
213 Ok((p, _warning)) => p,
214 Err(e) => return e,
215 };
216
217 let bundle = match crate::core::ccp_session_bundle::read_bundle_v1(&jailed) {
218 Ok(b) => b,
219 Err(e) => return format!("Import failed: {e}"),
220 };
221
222 let current_root_hash = crate::core::project_hash::hash_project_root(&root);
224 let current_identity_hash = crate::core::project_hash::project_identity(&root)
225 .as_deref()
226 .map(|s| {
227 use md5::{Digest, Md5};
228 let mut h = Md5::new();
229 h.update(s.as_bytes());
230 crate::core::agent_identity::hex_encode(&h.finalize())
231 });
232
233 let mut warning: Option<String> = None;
234 if let Some(ref exported) = bundle.project.project_root_hash
235 && exported != ¤t_root_hash
236 {
237 warning = Some(
238 "WARNING: project_root_hash mismatch (importing into different project root)."
239 .to_string(),
240 );
241 }
242 if let (Some(exported), Some(current)) = (
243 bundle.project.project_identity_hash.as_ref(),
244 current_identity_hash.as_ref(),
245 ) && exported != current
246 {
247 warning = Some("WARNING: project_identity_hash mismatch (importing into different project identity).".to_string());
248 }
249
250 let report = crate::core::ccp_session_bundle::import_bundle_v1_into_session(
251 session,
252 &bundle,
253 Some(&root),
254 );
255 let _ = session.save();
256
257 let mut out = format!(
258 "CCP session bundle imported.\n\
259session_id: {}\n\
260version: {}\n\
261files_touched: {}\n\
262stale_files: {}\n",
263 report.session_id, report.version, report.files_touched, report.stale_files
264 );
265 if let Some(w) = warning {
266 out.push_str(&format!("{w}\n"));
267 }
268 out
269 }
270}
271
272fn handle_task(
273 session: &mut SessionState,
274 tool_calls: &[(String, u64)],
275 value: Option<&str>,
276 opts: SessionToolOptions<'_>,
277) -> String {
278 {
279 let desc = value.unwrap_or("(no description)");
280 session.set_task(desc, None);
281 let lower = desc.to_lowercase();
285 let completed =
286 desc.contains("[100%]") || lower.contains("[done]") || lower.contains("[complete]");
287 let mut note = String::new();
288 if completed {
289 match auto_record_episode(session, tool_calls, opts.agent_id) {
290 Ok(Some(id)) => {
291 note = format!("\nEpisode auto-recorded: {id}");
292 }
293 Ok(None) => {} Err(e) => {
295 note = format!("\n(episode auto-record skipped: {e})");
296 }
297 }
298 }
299 format!("Task set: {desc}{note}")
300 }
301}
302
303fn handle_finding(session: &mut SessionState, value: Option<&str>) -> String {
304 {
305 let summary = value.unwrap_or("(no summary)");
306 let (file, line, text) = parse_finding_value(summary);
307 session.add_finding(file.as_deref(), line, text);
308 format!("Finding added: {summary}")
309 }
310}
311
312fn handle_decision(session: &mut SessionState, value: Option<&str>) -> String {
313 {
314 let desc = value.unwrap_or("(no description)");
315 session.add_decision(desc, None);
316 format!("Decision recorded: {desc}")
317 }
318}
319
320fn handle_reset(session: &mut SessionState) -> String {
321 {
322 let _ = session.save();
323 let old_id = session.id.clone();
324 *session = SessionState::new();
325 crate::core::budget_tracker::BudgetTracker::global().reset();
326 let mut ledger = crate::core::context_ledger::ContextLedger::load();
328 ledger.reset();
329 ledger.save();
330 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
331 let radar_path = data_dir.join("context_radar.jsonl");
332 let prev = data_dir.join("context_radar.prev.jsonl");
333 let _ = std::fs::rename(&radar_path, &prev);
334 }
335 format!(
336 "Session reset. Previous: {old_id}. New: {}. Ledger cleared (0% pressure).",
337 session.id
338 )
339 }
340}
341
342fn handle_list() -> String {
343 {
344 let sessions = SessionState::list_sessions();
345 if sessions.is_empty() {
346 return "No sessions found.".to_string();
347 }
348 let mut lines = vec![format!("Sessions ({}):", sessions.len())];
349 for s in sessions.iter().take(10) {
350 let task = s.task.as_deref().unwrap_or("(no task)");
351 let task_short: String = task.chars().take(40).collect();
352 lines.push(format!(
353 " {} v{} | {} calls | {} tok | {}",
354 s.id, s.version, s.tool_calls, s.tokens_saved, task_short
355 ));
356 }
357 if sessions.len() > 10 {
358 lines.push(format!(" ... +{} more", sessions.len() - 10));
359 }
360 lines.join("\n")
361 }
362}
363
364fn handle_cleanup() -> String {
365 {
366 let removed = SessionState::cleanup_old_sessions(7);
367 format!("Cleaned up {removed} old session(s) (>7 days).")
368 }
369}
370
371fn handle_configure(session: &mut SessionState, opts: SessionToolOptions<'_>) -> String {
372 match opts.terse {
373 Some(enabled) => {
374 session.terse_mode = enabled;
375 session.increment();
376 format!("Session configured: terse_mode={enabled}")
377 }
378 None => format!("Session config: terse_mode={}", session.terse_mode),
379 }
380}
381
382fn handle_snapshot(session: &mut SessionState) -> String {
383 match session.save_compaction_snapshot() {
384 Ok(snapshot) => {
385 format!(
386 "Compaction snapshot saved ({} bytes).\n{snapshot}",
387 snapshot.len()
388 )
389 }
390 Err(e) => format!("Snapshot failed: {e}"),
391 }
392}
393
394fn handle_restore(session_id: Option<&str>) -> String {
395 {
396 let snapshot = if let Some(id) = session_id {
397 SessionState::load_compaction_snapshot(id)
398 } else {
399 SessionState::load_latest_snapshot()
400 };
401 match snapshot {
402 Some(s) => format!("Session restored from compaction snapshot:\n{s}"),
403 None => "No compaction snapshot found. Session continues fresh.".to_string(),
404 }
405 }
406}
407
408fn handle_resume(session: &mut SessionState) -> String {
409 session.build_resume_block()
410}
411
412fn handle_profile(value: Option<&str>) -> String {
413 {
414 use crate::core::profiles;
415 if let Some(name) = value {
416 if let Ok(p) = profiles::set_active_profile(name) {
417 format!(
418 "Profile switched to '{name}'.\n\
419 Read mode: {}, Budget: {} tokens, CRP: {}, Density: {}",
420 p.read.default_mode_effective(),
421 p.budget.max_context_tokens_effective(),
422 p.compression.crp_mode_effective(),
423 p.compression.output_density_effective(),
424 )
425 } else {
426 let available: Vec<String> = profiles::list_profiles()
427 .iter()
428 .map(|p| p.name.clone())
429 .collect();
430 format!(
431 "Profile '{name}' not found. Available: {}",
432 available.join(", ")
433 )
434 }
435 } else {
436 let name = profiles::active_profile_name();
437 let p = profiles::active_profile();
438 let list = profiles::list_profiles();
439 let mut out = format!(
440 "Active profile: {name}\n\
441 Read: {}, Budget: {} tok, CRP: {}, Density: {}\n\n\
442 Available profiles:",
443 p.read.default_mode_effective(),
444 p.budget.max_context_tokens_effective(),
445 p.compression.crp_mode_effective(),
446 p.compression.output_density_effective(),
447 );
448 for info in &list {
449 let marker = if info.name == name { " *" } else { " " };
450 out.push_str(&format!(
451 "\n{marker} {:<14} ({}) {}",
452 info.name, info.source, info.description
453 ));
454 }
455 out.push_str("\n\nSwitch: ctx_session action=profile value=<name>");
456 out
457 }
458 }
459}
460
461fn handle_budget() -> String {
462 {
463 use crate::core::budget_tracker::BudgetTracker;
464 let snap = BudgetTracker::global().check();
465 let mut out = snap.format_compact();
466
467 if let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() {
468 let window = crate::core::context_radar::default_window_for_client("cursor");
469 let radar = crate::core::context_radar::ContextRadar::load(&data_dir, window);
470 let radar_display = radar.format_display();
471 if !radar_display.is_empty() {
472 out.push_str("\n\n");
473 out.push_str(&radar_display);
474 }
475 }
476 out
477 }
478}
479
480fn handle_role(value: Option<&str>) -> String {
481 {
482 use crate::core::roles;
483 if let Some(name) = value {
484 match roles::set_active_role(name) {
485 Ok(r) => {
486 crate::core::budget_tracker::BudgetTracker::global().reset();
487 format!(
488 "Role switched to '{name}'.\n\
489 Shell: {}, Budget: {} tokens / {} shell / ${:.2}\n\
490 Tools: {}",
491 r.role.shell_policy,
492 r.limits.max_context_tokens,
493 r.limits.max_shell_invocations,
494 r.limits.max_cost_usd,
495 if r.tools.allowed.iter().any(|a| a == "*") {
496 let denied = if r.tools.denied.is_empty() {
497 "none".to_string()
498 } else {
499 format!("denied: {}", r.tools.denied.join(", "))
500 };
501 format!("* (all), {denied}")
502 } else {
503 r.tools.allowed.join(", ")
504 }
505 )
506 }
507 Err(e) => {
508 let available: Vec<String> =
509 roles::list_roles().iter().map(|r| r.name.clone()).collect();
510 format!("{e}. Available: {}", available.join(", "))
511 }
512 }
513 } else {
514 let name = roles::active_role_name();
515 let r = roles::active_role();
516 let list = roles::list_roles();
517 let mut out = format!(
518 "Active role: {name}\n\
519 Description: {}\n\
520 Shell policy: {}, Budget: {} tokens / {} shell / ${:.2}\n\n\
521 Available roles:",
522 r.role.description,
523 r.role.shell_policy,
524 r.limits.max_context_tokens,
525 r.limits.max_shell_invocations,
526 r.limits.max_cost_usd,
527 );
528 for info in &list {
529 let marker = if info.is_active { " *" } else { " " };
530 out.push_str(&format!(
531 "\n{marker} {:<14} ({}) {}",
532 info.name, info.source, info.description
533 ));
534 }
535 out.push_str("\n\nSwitch: ctx_session action=role value=<name>");
536 out
537 }
538 }
539}
540
541fn handle_diff(value: Option<&str>) -> String {
542 {
543 let parts: Vec<&str> = value.unwrap_or("").split_whitespace().collect();
544 if parts.len() < 2 {
545 return "Usage: ctx_session diff <session_id_a> <session_id_b> [format]\n\
546 Formats: summary (default), json\n\
547 Example: ctx_session diff abc123 def456 json"
548 .to_string();
549 }
550 let id_a = parts[0];
551 let id_b = parts[1];
552 let format = parts.get(2).copied().unwrap_or("summary");
553
554 let sess_a = SessionState::load_by_id(id_a);
555 let sess_b = SessionState::load_by_id(id_b);
556
557 match (sess_a, sess_b) {
558 (Some(a), Some(b)) => {
559 let d = crate::core::session_diff::diff_sessions(&a, &b);
560 match format {
561 "json" => d.format_json(),
562 _ => d.format_summary(),
563 }
564 }
565 (None, _) => format!("Session not found: {id_a}"),
566 (_, None) => format!("Session not found: {id_b}"),
567 }
568 }
569}
570
571fn handle_slo(value: Option<&str>) -> String {
572 match value {
573 Some("reload") => {
574 crate::core::slo::reload();
575 "SLO definitions reloaded from disk.".to_string()
576 }
577 Some("history") => {
578 let hist = crate::core::slo::violation_history(20);
579 if hist.is_empty() {
580 "No SLO violations recorded.".to_string()
581 } else {
582 let mut out = format!("SLO violations (last {}):\n", hist.len());
583 for v in &hist {
584 out.push_str(&format!(
585 " {} {} ({}) {:.2} vs {:.2} → {}\n",
586 v.timestamp, v.slo_name, v.metric, v.actual, v.threshold, v.action
587 ));
588 }
589 out
590 }
591 }
592 Some("clear") => {
593 crate::core::slo::clear_violations();
594 "SLO violation history cleared.".to_string()
595 }
596 _ => {
597 let snap = crate::core::slo::evaluate_quiet();
598 snap.format_compact()
599 }
600 }
601}
602
603fn handle_output_stats() -> String {
604 {
605 let snap = crate::core::output_verification::stats_snapshot();
606 let mut out = snap.format_compact();
607 let echo = crate::core::output_echo::load_stats();
609 if !echo.reports.is_empty() {
610 out.push_str(&format!(
611 "\nOutput echo: {:.0}% avg over last {} replies ({} analyzed total)",
612 echo.avg_ratio(50) * 100.0,
613 echo.reports.len(),
614 echo.total_analyzed
615 ));
616 }
617 out
618 }
619}
620
621fn handle_verify() -> String {
622 {
623 let snap = crate::core::output_verification::stats_snapshot();
624 format!(
625 "DEPRECATION: action=\"verify\" is renamed to action=\"output_stats\" (ctx_verify is the full observability stack).\n{}",
626 snap.format_compact()
627 )
628 }
629}
630
631fn handle_episodes(
632 session: &mut SessionState,
633 tool_calls: &[(String, u64)],
634 value: Option<&str>,
635 opts: SessionToolOptions<'_>,
636) -> String {
637 {
638 let project_root = session.project_root.clone().unwrap_or_else(|| {
639 std::env::current_dir().map_or_else(
640 |_| "unknown".to_string(),
641 |p| p.to_string_lossy().to_string(),
642 )
643 });
644 let policy = match crate::core::config::Config::load().memory_policy_effective() {
645 Ok(p) => p,
646 Err(e) => {
647 let path = crate::core::config::Config::path().map_or_else(
648 || "~/.lean-ctx/config.toml".to_string(),
649 |p| p.display().to_string(),
650 );
651 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
652 }
653 };
654 let hash = crate::core::project_hash::hash_project_root(&project_root);
655 match value {
656 Some("record") => {
657 let id = match crate::core::episodic_memory::record_session_episode(
658 &hash,
659 session,
660 tool_calls,
661 opts.agent_id,
662 &policy.episodic,
663 false,
664 ) {
665 Ok(Some(id)) => id,
666 Ok(None) => return "Episode already recorded.".to_string(),
667 Err(e) => return format!("Episode record failed: {e}"),
668 };
669 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
670 category: "episodic".to_string(),
671 key: id.clone(),
672 action: "record".to_string(),
673 });
674 let learned = crate::core::procedural_memory::auto_detect_from_episodes(
677 &hash,
678 &policy.procedural,
679 );
680 match learned {
681 Some(n) if n > 0 => {
682 format!(
683 "Episode recorded: {id} (procedures auto-updated: {n} known workflows)"
684 )
685 }
686 _ => format!("Episode recorded: {id}"),
687 }
688 }
689 Some(v) if v.starts_with("search ") => {
690 let q = v.trim_start_matches("search ").trim();
691 let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
692 let hits = store.search(q);
693 if hits.is_empty() {
694 return "No episodes matched.".to_string();
695 }
696 let mut out = format!("Episodes matched ({}):", hits.len());
697 for ep in hits.into_iter().take(10) {
698 let task: String = ep.task_description.chars().take(50).collect();
699 out.push_str(&format!(
700 "\n {} | {} | {} | {}",
701 ep.id,
702 ep.timestamp,
703 ep.outcome.label(),
704 task
705 ));
706 }
707 out
708 }
709 Some(v) if v.starts_with("file ") => {
710 let f = v.trim_start_matches("file ").trim();
711 let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
712 let hits = store.by_file(f);
713 let mut out = format!("Episodes for file match '{f}' ({}):", hits.len());
714 for ep in hits.into_iter().take(10) {
715 let task: String = ep.task_description.chars().take(50).collect();
716 out.push_str(&format!(
717 "\n {} | {} | {} | {}",
718 ep.id,
719 ep.timestamp,
720 ep.outcome.label(),
721 task
722 ));
723 }
724 out
725 }
726 Some(v) if v.starts_with("outcome ") => {
727 let label = v.trim_start_matches("outcome ").trim();
728 let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
729 let hits = store.by_outcome(label);
730 let mut out = format!("Episodes outcome '{label}' ({}):", hits.len());
731 for ep in hits.into_iter().take(10) {
732 let task: String = ep.task_description.chars().take(50).collect();
733 out.push_str(&format!("\n {} | {} | {}", ep.id, ep.timestamp, task));
734 }
735 out
736 }
737 _ => {
738 let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
739 let stats = store.stats();
740 let recent = store.recent(10);
741 let mut out = format!(
742 "Episodic memory: {} episodes, success_rate={:.0}%, tokens_total={}\n\nRecent:",
743 stats.total_episodes,
744 stats.success_rate * 100.0,
745 stats.total_tokens
746 );
747 for ep in recent {
748 let task: String = ep.task_description.chars().take(60).collect();
749 out.push_str(&format!(
750 "\n {} | {} | {} | {}",
751 ep.id,
752 ep.timestamp,
753 ep.outcome.label(),
754 task
755 ));
756 }
757 out.push_str("\n\nActions: ctx_session action=episodes value=record|\"search <q>\"|\"file <path>\"|\"outcome success|failure|partial|unknown\"");
758 out
759 }
760 }
761 }
762}
763
764fn handle_procedures(session: &mut SessionState, value: Option<&str>) -> String {
765 {
766 let project_root = session.project_root.clone().unwrap_or_else(|| {
767 std::env::current_dir().map_or_else(
768 |_| "unknown".to_string(),
769 |p| p.to_string_lossy().to_string(),
770 )
771 });
772 let policy = match crate::core::config::Config::load().memory_policy_effective() {
773 Ok(p) => p,
774 Err(e) => {
775 let path = crate::core::config::Config::path().map_or_else(
776 || "~/.lean-ctx/config.toml".to_string(),
777 |p| p.display().to_string(),
778 );
779 return format!("Error: invalid memory policy: {e}\nFix: edit {path}");
780 }
781 };
782 let hash = crate::core::project_hash::hash_project_root(&project_root);
783 let episodes = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
784 let mut procs = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
785
786 match value {
787 Some("detect") => {
788 procs.detect_patterns(&episodes.episodes, &policy.procedural);
789 if let Err(e) = procs.save() {
790 return format!("Procedure detect failed: {e}");
791 }
792 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
793 category: "procedural".to_string(),
794 key: hash.clone(),
795 action: "detect".to_string(),
796 });
797 format!(
798 "Procedures updated. Total procedures: {} (episodes: {}).",
799 procs.procedures.len(),
800 episodes.episodes.len()
801 )
802 }
803 Some(v) if v.starts_with("suggest ") => {
804 let task = v.trim_start_matches("suggest ").trim();
805 let hits = procs.suggest(task);
806 if hits.is_empty() {
807 return "No procedures matched.".to_string();
808 }
809 let mut out = format!("Procedures suggested ({}):", hits.len());
810 for p in hits.into_iter().take(10) {
811 out.push_str(&format!(
812 "\n {} | conf={:.0}% | success={:.0}% | steps={}",
813 p.name,
814 p.confidence * 100.0,
815 p.success_rate() * 100.0,
816 p.steps.len()
817 ));
818 }
819 out
820 }
821 _ => {
822 let task = session
823 .task
824 .as_ref()
825 .map(|t| t.description.clone())
826 .unwrap_or_default();
827 let suggestions = if task.is_empty() {
828 Vec::new()
829 } else {
830 procs.suggest(&task)
831 };
832
833 let mut out = format!(
834 "Procedural memory: {} procedures (episodes: {})",
835 procs.procedures.len(),
836 episodes.episodes.len()
837 );
838
839 if !task.is_empty() {
840 out.push_str(&format!(
841 "\nTask: {}",
842 task.chars().take(80).collect::<String>()
843 ));
844 if !suggestions.is_empty() {
845 out.push_str("\n\nSuggested:");
846 for p in suggestions.into_iter().take(5) {
847 out.push_str(&format!(
848 "\n {} | conf={:.0}% | success={:.0}% | steps={}",
849 p.name,
850 p.confidence * 100.0,
851 p.success_rate() * 100.0,
852 p.steps.len()
853 ));
854 }
855 }
856 }
857
858 out.push_str(
859 "\n\nActions: ctx_session action=procedures value=detect|\"suggest <task>\"",
860 );
861 out
862 }
863 }
864 }
865}
866
867fn handle_unknown(action: &str) -> String {
868 format!(
869 "Unknown action: {action}. Use: status, load, save, task, finding, decision, reset, list, cleanup, snapshot, restore, resume, configure, profile, role, budget, slo, diff, output_stats, verify, export, import, episodes, procedures"
870 )
871}
872
873fn auto_record_episode(
878 session: &SessionState,
879 tool_calls: &[(String, u64)],
880 agent_id: Option<&str>,
881) -> Result<Option<String>, String> {
882 let project_root = session.project_root.clone().unwrap_or_else(|| {
883 std::env::current_dir().map_or_else(
884 |_| "unknown".to_string(),
885 |p| p.to_string_lossy().to_string(),
886 )
887 });
888 let policy = crate::core::config::Config::load()
889 .memory_policy_effective()
890 .map_err(|e| format!("invalid memory policy: {e}"))?;
891 let hash = crate::core::project_hash::hash_project_root(&project_root);
892 let Some(id) = crate::core::episodic_memory::record_session_episode(
893 &hash,
894 session,
895 tool_calls,
896 agent_id,
897 &policy.episodic,
898 true,
899 )?
900 else {
901 return Ok(None);
902 };
903 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
904 category: "episodic".to_string(),
905 key: id.clone(),
906 action: "auto_record".to_string(),
907 });
908
909 let store = crate::core::episodic_memory::EpisodicStore::load_or_create(&hash);
913 let episodes: Vec<crate::core::episodic_memory::Episode> =
914 store.recent(50).into_iter().cloned().collect();
915 let mut procs = crate::core::procedural_memory::ProceduralStore::load_or_create(&hash);
916 let before = procs.procedures.len();
917 procs.detect_patterns(&episodes, &policy.procedural);
918 if procs.procedures.len() > before && procs.save().is_ok() {
919 crate::core::events::emit(crate::core::events::EventKind::KnowledgeUpdate {
920 category: "procedural".to_string(),
921 key: format!("{} new", procs.procedures.len() - before),
922 action: "auto_learn".to_string(),
923 });
924 }
925
926 Ok(Some(id))
927}
928
929fn parse_finding_value(value: &str) -> (Option<String>, Option<u32>, &str) {
930 const EM_DASH_SEP: &str = " \u{2014} ";
931 const ASCII_SEP: &str = " - ";
932
933 let (dash_pos, sep) = if let Some(p) = value.find(EM_DASH_SEP) {
934 (Some(p), EM_DASH_SEP)
935 } else {
936 (value.find(ASCII_SEP), ASCII_SEP)
937 };
938
939 if let Some(pos) = dash_pos {
940 let location = &value[..pos];
941 let text = &value[pos + sep.len()..];
942
943 if let Some(colon_pos) = location.rfind(':') {
944 let file = &location[..colon_pos];
945 if let Ok(line) = location[colon_pos + 1..].parse::<u32>() {
946 return (Some(file.to_string()), Some(line), text);
947 }
948 }
949 return (Some(location.to_string()), None, text);
950 }
951 (None, None, value)
952}
953
954#[cfg(test)]
955mod tests {
956 use super::parse_finding_value;
957
958 #[test]
959 fn finding_with_em_dash_and_file_line() {
960 let (file, line, text) =
961 parse_finding_value("auth.rs:42 \u{2014} missing token validation");
962 assert_eq!(file.as_deref(), Some("auth.rs"));
963 assert_eq!(line, Some(42));
964 assert_eq!(text, "missing token validation");
965 }
966
967 #[test]
968 fn finding_with_ascii_dash_and_file_line() {
969 let (file, line, text) = parse_finding_value("auth.rs:42 - missing token validation");
970 assert_eq!(file.as_deref(), Some("auth.rs"));
971 assert_eq!(line, Some(42));
972 assert_eq!(text, "missing token validation");
973 }
974
975 #[test]
976 fn finding_with_em_dash_no_line() {
977 let (file, line, text) = parse_finding_value("auth module \u{2014} needs refactoring");
978 assert_eq!(file.as_deref(), Some("auth module"));
979 assert_eq!(line, None);
980 assert_eq!(text, "needs refactoring");
981 }
982
983 #[test]
984 fn finding_plain_text() {
985 let (file, line, text) = parse_finding_value("plain text finding");
986 assert_eq!(file, None);
987 assert_eq!(line, None);
988 assert_eq!(text, "plain text finding");
989 }
990
991 #[test]
992 fn finding_cyrillic_with_em_dash_issue_272() {
993 let value = "ruff: pyproject.toml dev-group \u{2014} >=0.15.14,<0.16.0 (был 0.14.x)";
994 let (file, line, text) = parse_finding_value(value);
995 assert_eq!(file.as_deref(), Some("ruff: pyproject.toml dev-group"));
996 assert_eq!(line, None);
997 assert_eq!(text, ">=0.15.14,<0.16.0 (был 0.14.x)");
998 }
999
1000 #[test]
1001 fn finding_em_dash_at_start() {
1002 let (file, line, text) = parse_finding_value("src/main.rs:1 \u{2014} entry point");
1003 assert_eq!(file.as_deref(), Some("src/main.rs"));
1004 assert_eq!(line, Some(1));
1005 assert_eq!(text, "entry point");
1006 }
1007}