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