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