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