1use crate::agent_sessions::{
31 AgentSession, AgentSessionStore, FileAgentSessionStore, SessionBlockerStatus, SessionFilter,
32 SessionMemoryEntryKind, SessionMemoryPromotionCandidate, SessionMemoryResourceKind,
33 SessionWorkingMemory,
34};
35use crate::error::AppError;
36use crate::memory_bank::{
37 MemoryBankEntry, MemoryBankError, MemoryBankQuery, MemoryGovernanceRefreshReport,
38 MemoryGovernanceState, MemoryGovernanceSuggestion, MemoryKind, MemoryScope, MemoryType,
39 ReflectionMemoryState, clear_memory_bank, delete_memory_bank_entry, list_memory_bank,
40 load_from_memory_bank, refresh_memory_bank_governance, save_to_memory_bank,
41 search_memory_bank_with_query, update_memory_bank_entry,
42};
43use crate::tasks::{TaskError, TaskManager, TaskMemoryEvent, TaskMemoryLifecycle, TaskMemoryPhase};
44use chrono::{DateTime, Utc};
45use serde::{Deserialize, Serialize};
46use std::collections::BTreeMap;
47use std::path::{Path, PathBuf};
48
49pub type MemoryConsoleResult<T> = Result<T, MemoryConsoleError>;
51
52#[derive(Debug, thiserror::Error)]
54pub enum MemoryConsoleError {
55 #[error(transparent)]
57 MemoryBank(#[from] MemoryBankError),
58 #[error(transparent)]
60 Task(#[from] TaskError),
61 #[error(transparent)]
63 Session(#[from] AppError),
64 #[error("No workspace directory configured for the selected session")]
66 MissingWorkspace,
67 #[error("Memory entry is missing its file path")]
69 MissingFilePath,
70 #[error("Session not found: {0}")]
72 SessionNotFound(String),
73 #[error("Task memory lifecycle not found for task: {0}")]
75 TaskLifecycleNotFound(String),
76 #[error("Invalid memory console input: {0}")]
78 InvalidInput(String),
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct MemoryConsoleSessionSummary {
84 pub session_id: String,
86 pub title: String,
88 pub last_active: DateTime<Utc>,
90 pub message_count: usize,
92 pub workspace_dir: Option<String>,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct MemoryConsoleCount {
99 pub key: String,
101 pub count: usize,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct MemoryConsoleQuery {
108 pub text: Option<String>,
110 pub limit: usize,
112 pub include_working_memory: bool,
114 pub include_durable_memory: bool,
116 pub include_archived: bool,
118 pub kinds: Vec<MemoryKind>,
120 pub memory_types: Vec<MemoryType>,
122 pub scopes: Vec<MemoryScope>,
124 pub session_id: Option<String>,
126 pub task_id: Option<String>,
128 pub directive_id: Option<String>,
130 pub agent_id: Option<String>,
132 pub category: Option<String>,
134 pub tags: Vec<String>,
136 pub min_confidence: Option<f32>,
138}
139
140impl Default for MemoryConsoleQuery {
141 fn default() -> Self {
142 Self {
143 text: None,
144 limit: 12,
145 include_working_memory: true,
146 include_durable_memory: true,
147 include_archived: false,
148 kinds: Vec::new(),
149 memory_types: Vec::new(),
150 scopes: Vec::new(),
151 session_id: None,
152 task_id: None,
153 directive_id: None,
154 agent_id: None,
155 category: None,
156 tags: Vec::new(),
157 min_confidence: None,
158 }
159 }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct WorkingMemoryMatch {
165 pub id: String,
167 pub section: String,
169 pub summary: String,
171 pub detail: Option<String>,
173 pub tags: Vec<String>,
175 pub status: Option<String>,
177 pub score: f32,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct MemoryConsoleEntrySummary {
184 pub entry_id: String,
186 pub timestamp: DateTime<Utc>,
188 pub memory_kind: MemoryKind,
190 pub memory_type: MemoryType,
192 pub scope: MemoryScope,
194 pub session_id: String,
196 pub category: Option<String>,
198 pub task_id: Option<String>,
200 pub directive_id: Option<String>,
202 pub agent_id: Option<String>,
204 pub tags: Vec<String>,
206 pub confidence: f32,
208 pub summary: String,
210 pub file_path: Option<String>,
212 pub archived: bool,
214 pub governance_state: MemoryGovernanceState,
216 pub reflection_state: Option<ReflectionMemoryState>,
218 pub governance_issue_count: usize,
220 pub score: Option<f32>,
222 pub matched_fields: Vec<String>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct MemoryConsoleEntryDetail {
229 #[serde(flatten)]
231 pub summary: MemoryConsoleEntrySummary,
232 pub content: String,
234 pub promoted_from_session_id: Option<String>,
236 pub promotion_reason: Option<String>,
238 pub governance_note: Option<String>,
240 pub governance_suggestions: Vec<MemoryGovernanceSuggestion>,
242 pub reflection_id: Option<String>,
244 pub strategy_key: Option<String>,
246 pub success_count: u16,
248 pub failure_count: u16,
250 pub outcome_summary: Option<String>,
252 pub outcome_labels: Vec<String>,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct MemoryConsoleOverview {
259 pub workspace_dir: String,
261 pub session: Option<MemoryConsoleSessionSummary>,
263 pub durable_total: usize,
265 pub open_blocker_count: usize,
267 pub promotion_candidate_count: usize,
269 pub working_resource_count: usize,
271 pub working_decision_count: usize,
273 pub governance_review_count: usize,
275 pub governance_issue_count: usize,
277 pub working_summary: Option<String>,
279 pub recent_entries: Vec<MemoryConsoleEntrySummary>,
281 pub counts_by_kind: Vec<MemoryConsoleCount>,
283 pub counts_by_type: Vec<MemoryConsoleCount>,
285 pub counts_by_scope: Vec<MemoryConsoleCount>,
287 pub counts_by_category: Vec<MemoryConsoleCount>,
289 pub counts_by_governance: Vec<MemoryConsoleCount>,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct MemoryConsoleSearchResponse {
296 pub query: MemoryConsoleQuery,
298 pub working_memory: Vec<WorkingMemoryMatch>,
300 pub durable_memory: Vec<MemoryConsoleEntrySummary>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct PromoteMemoryCandidateRequest {
307 pub summary: String,
309 pub detail: Option<String>,
311 pub category: Option<String>,
313 pub memory_kind: MemoryKind,
315 pub memory_type: MemoryType,
317 pub scope: MemoryScope,
319 pub task_id: Option<String>,
321 pub directive_id: Option<String>,
323 pub agent_id: Option<String>,
325 pub tags: Vec<String>,
327 pub confidence: f32,
329 pub promotion_reason: Option<String>,
331}
332
333#[derive(Debug, Clone, Serialize, Deserialize, Default)]
335pub struct UpdateMemoryEntryRequest {
336 pub summary: Option<String>,
338 pub content: Option<String>,
340 pub category: Option<Option<String>>,
342 pub memory_kind: Option<MemoryKind>,
344 pub memory_type: Option<MemoryType>,
346 pub scope: Option<MemoryScope>,
348 pub task_id: Option<Option<String>>,
350 pub directive_id: Option<Option<String>>,
352 pub agent_id: Option<Option<String>>,
354 pub tags: Option<Vec<String>>,
356 pub confidence: Option<f32>,
358 pub governance_state: Option<MemoryGovernanceState>,
360 pub governance_note: Option<Option<String>>,
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct TaskMemoryConsoleDetail {
367 pub task_id: String,
369 pub lifecycle: TaskMemoryLifecycle,
371}
372
373pub fn list_memory_console_sessions(
375 store: &FileAgentSessionStore,
376 limit: usize,
377) -> MemoryConsoleResult<Vec<MemoryConsoleSessionSummary>> {
378 let infos = store.list(SessionFilter::All)?;
379 let mut sessions = Vec::new();
380 for info in infos.into_iter().take(limit.max(1)) {
381 let workspace_dir = store.load(&info.id).ok().and_then(|session| {
382 session
383 .workspace_dir()
384 .map(|path| path.display().to_string())
385 });
386 sessions.push(MemoryConsoleSessionSummary {
387 session_id: info.id,
388 title: info.title,
389 last_active: info.last_active,
390 message_count: info.message_count,
391 workspace_dir,
392 });
393 }
394 Ok(sessions)
395}
396
397pub fn load_memory_console_session(
399 store: &FileAgentSessionStore,
400 session_ref: &str,
401) -> MemoryConsoleResult<AgentSession> {
402 if session_ref == "last" {
403 return store
404 .load_last()?
405 .ok_or_else(|| MemoryConsoleError::SessionNotFound("last".to_string()));
406 }
407
408 if let Ok(session) = store.load(session_ref) {
409 return Ok(session);
410 }
411
412 let Some(resolved) = store.find_by_prefix(session_ref)? else {
413 return Err(MemoryConsoleError::SessionNotFound(session_ref.to_string()));
414 };
415 Ok(store.load(&resolved)?)
416}
417
418pub async fn get_memory_console_overview(
420 workspace_dir: &Path,
421 session: Option<&AgentSession>,
422) -> MemoryConsoleResult<MemoryConsoleOverview> {
423 let entries = list_memory_bank(workspace_dir).await?;
424 let filtered_entries: Vec<MemoryBankEntry> = entries
425 .into_iter()
426 .filter(|entry| !entry.is_archived())
427 .collect();
428
429 let mut kinds: BTreeMap<String, usize> = BTreeMap::new();
430 let mut scopes: BTreeMap<String, usize> = BTreeMap::new();
431 let mut types: BTreeMap<String, usize> = BTreeMap::new();
432 let mut categories: BTreeMap<String, usize> = BTreeMap::new();
433 let mut governance: BTreeMap<String, usize> = BTreeMap::new();
434 for entry in &filtered_entries {
435 *kinds.entry(entry.memory_kind.to_string()).or_default() += 1;
436 *scopes.entry(entry.scope.to_string()).or_default() += 1;
437 *types.entry(entry.memory_type.to_string()).or_default() += 1;
438 *categories
439 .entry(
440 entry
441 .category
442 .clone()
443 .unwrap_or_else(|| "uncategorized".to_string()),
444 )
445 .or_default() += 1;
446 *governance
447 .entry(entry.governance_state.to_string())
448 .or_default() += 1;
449 }
450
451 let session_summary = session.map(session_summary_from_session);
452 let working_memory = session.map(|current| ¤t.state.working_memory);
453 let recent_entries = filtered_entries
454 .iter()
455 .take(8)
456 .map(|entry| entry_summary_from_entry(workspace_dir, entry, None, Vec::new()))
457 .collect();
458
459 Ok(MemoryConsoleOverview {
460 workspace_dir: workspace_dir.display().to_string(),
461 session: session_summary,
462 durable_total: filtered_entries.len(),
463 open_blocker_count: working_memory
464 .map(|wm| {
465 wm.blockers
466 .iter()
467 .filter(|b| matches!(b.status, SessionBlockerStatus::Open))
468 .count()
469 })
470 .unwrap_or(0),
471 promotion_candidate_count: session
472 .map(|s| s.state.promotion_candidates(12).len())
473 .unwrap_or(0),
474 working_resource_count: working_memory.map(|wm| wm.resources.len()).unwrap_or(0),
475 working_decision_count: working_memory.map(|wm| wm.decisions.len()).unwrap_or(0),
476 governance_review_count: filtered_entries
477 .iter()
478 .filter(|entry| entry.governance_state == MemoryGovernanceState::NeedsReview)
479 .count(),
480 governance_issue_count: filtered_entries
481 .iter()
482 .map(|entry| entry.governance_suggestions.len())
483 .sum(),
484 working_summary: working_memory.and_then(|wm| wm.summary.clone()),
485 recent_entries,
486 counts_by_kind: counts_to_vec(kinds),
487 counts_by_type: counts_to_vec(types),
488 counts_by_scope: counts_to_vec(scopes),
489 counts_by_category: counts_to_vec(categories),
490 counts_by_governance: counts_to_vec(governance),
491 })
492}
493
494pub fn get_working_memory_snapshot(session: &AgentSession) -> SessionWorkingMemory {
496 session.state.working_memory.clone()
497}
498
499pub fn get_memory_promotion_candidates(
501 session: &AgentSession,
502 limit: usize,
503) -> Vec<SessionMemoryPromotionCandidate> {
504 session.state.promotion_candidates(limit.max(1))
505}
506
507pub async fn get_memory_entry_detail(
509 workspace_dir: &Path,
510 entry_id: &str,
511) -> MemoryConsoleResult<MemoryConsoleEntryDetail> {
512 let path = resolve_entry_id(workspace_dir, entry_id);
513 let entry = load_from_memory_bank(&path).await?;
514 Ok(entry_detail_from_entry(
515 workspace_dir,
516 &entry,
517 None,
518 Vec::new(),
519 ))
520}
521
522pub async fn search_memory_console(
524 workspace_dir: &Path,
525 session: Option<&AgentSession>,
526 mut query: MemoryConsoleQuery,
527) -> MemoryConsoleResult<MemoryConsoleSearchResponse> {
528 query.limit = query.limit.max(1);
529
530 let working_memory = if query.include_working_memory {
531 session
532 .map(|current| search_working_memory(current, &query))
533 .unwrap_or_default()
534 } else {
535 Vec::new()
536 };
537
538 let durable_memory = if query.include_durable_memory {
539 let bank_query = MemoryBankQuery {
540 text: query.text.clone(),
541 limit: query.limit,
542 kinds: query.kinds.clone(),
543 memory_types: query.memory_types.clone(),
544 scopes: query.scopes.clone(),
545 session_id: query.session_id.clone(),
546 task_id: query.task_id.clone(),
547 directive_id: query.directive_id.clone(),
548 agent_id: query.agent_id.clone(),
549 category: query.category.clone(),
550 tags: query.tags.clone(),
551 min_confidence: query.min_confidence,
552 include_archived: query.include_archived,
553 };
554
555 search_memory_bank_with_query(workspace_dir, &bank_query)
556 .await?
557 .into_iter()
558 .map(|result| {
559 entry_summary_from_entry(
560 workspace_dir,
561 &result.entry,
562 Some(result.score),
563 result.matched_fields,
564 )
565 })
566 .collect()
567 } else {
568 Vec::new()
569 };
570
571 Ok(MemoryConsoleSearchResponse {
572 query,
573 working_memory,
574 durable_memory,
575 })
576}
577
578pub fn get_task_memory_console_detail(
580 task_manager: &TaskManager,
581 session_id: &str,
582 task_id: &str,
583) -> MemoryConsoleResult<TaskMemoryConsoleDetail> {
584 let Some(lifecycle) = task_manager.get_memory_lifecycle(session_id, task_id)? else {
585 return Err(MemoryConsoleError::TaskLifecycleNotFound(
586 task_id.to_string(),
587 ));
588 };
589 Ok(TaskMemoryConsoleDetail {
590 task_id: task_id.to_string(),
591 lifecycle,
592 })
593}
594
595pub async fn refresh_memory_console_governance(
597 workspace_dir: &Path,
598) -> MemoryConsoleResult<MemoryGovernanceRefreshReport> {
599 Ok(refresh_memory_bank_governance(workspace_dir).await?)
600}
601
602pub async fn promote_memory_candidate(
604 workspace_dir: &Path,
605 session: &AgentSession,
606 request: PromoteMemoryCandidateRequest,
607 task_manager: Option<&TaskManager>,
608) -> MemoryConsoleResult<MemoryConsoleEntryDetail> {
609 let mut entry = MemoryBankEntry::new(
610 session.id.clone(),
611 request.summary.clone(),
612 request
613 .detail
614 .clone()
615 .unwrap_or_else(|| request.summary.clone()),
616 )
617 .with_memory_type(request.memory_type)
618 .with_scope(request.scope)
619 .with_provenance(
620 request.task_id.clone(),
621 request.directive_id.clone(),
622 request.agent_id.clone(),
623 )
624 .with_tags(normalize_tags(request.tags))
625 .with_confidence(request.confidence);
626
627 entry.memory_kind = request.memory_kind;
628 entry.category = request.category.clone();
629 let promotion_reason = request
630 .promotion_reason
631 .clone()
632 .unwrap_or_else(|| format!("Promoted from working memory ({})", session.id));
633 entry = entry.with_promotion(session.id.clone(), promotion_reason);
634
635 let saved_path = save_to_memory_bank(workspace_dir, &entry).await?;
636 let _ = refresh_memory_bank_governance(workspace_dir).await?;
637 let saved = load_from_memory_bank(&saved_path).await?;
638
639 if let (Some(manager), Some(task_id)) = (task_manager, request.task_id.as_deref()) {
640 manager.record_memory_event(
641 &session.id,
642 task_id,
643 TaskMemoryEvent::new(
644 TaskMemoryPhase::Promoted,
645 format!("Promoted durable memory: {}", saved.summary),
646 Some(saved.scope.to_string()),
647 Some(saved.memory_type.to_string()),
648 Some(memory_entry_id(workspace_dir, &saved)),
649 ),
650 )?;
651 }
652
653 Ok(entry_detail_from_entry(
654 workspace_dir,
655 &saved,
656 None,
657 Vec::new(),
658 ))
659}
660
661pub async fn update_memory_entry_detail(
663 workspace_dir: &Path,
664 entry_id: &str,
665 request: UpdateMemoryEntryRequest,
666) -> MemoryConsoleResult<MemoryConsoleEntryDetail> {
667 let path = resolve_entry_id(workspace_dir, entry_id);
668 let mut entry = load_from_memory_bank(&path).await?;
669
670 if let Some(summary) = request.summary {
671 entry.summary = summary;
672 }
673 if let Some(content) = request.content {
674 entry.content = content;
675 }
676 if let Some(category) = request.category {
677 entry.category = category;
678 }
679 if let Some(memory_kind) = request.memory_kind {
680 entry.memory_kind = memory_kind;
681 }
682 if let Some(memory_type) = request.memory_type {
683 entry.memory_type = memory_type;
684 }
685 if let Some(scope) = request.scope {
686 entry.scope = scope;
687 }
688 if let Some(task_id) = request.task_id {
689 entry.task_id = task_id;
690 }
691 if let Some(directive_id) = request.directive_id {
692 entry.directive_id = directive_id;
693 }
694 if let Some(agent_id) = request.agent_id {
695 entry.agent_id = agent_id;
696 }
697 if let Some(tags) = request.tags {
698 entry.tags = normalize_tags(tags);
699 }
700 if let Some(confidence) = request.confidence {
701 entry.confidence = confidence.clamp(0.0, 1.0);
702 }
703 if let Some(governance_state) = request.governance_state {
704 entry.governance_state = governance_state;
705 }
706 if let Some(governance_note) = request.governance_note {
707 entry.governance_note = governance_note;
708 }
709
710 let Some(file_path) = entry.file_path.clone() else {
711 return Err(MemoryConsoleError::MissingFilePath);
712 };
713 update_memory_bank_entry(workspace_dir, &file_path, &entry).await?;
714
715 let _ = refresh_memory_bank_governance(workspace_dir).await?;
716 let reloaded = load_from_memory_bank(&file_path).await?;
717 Ok(entry_detail_from_entry(
718 workspace_dir,
719 &reloaded,
720 None,
721 Vec::new(),
722 ))
723}
724
725pub async fn set_memory_entry_archived(
727 workspace_dir: &Path,
728 entry_id: &str,
729 archived: bool,
730) -> MemoryConsoleResult<MemoryConsoleEntryDetail> {
731 let path = resolve_entry_id(workspace_dir, entry_id);
732 let mut entry = load_from_memory_bank(&path).await?;
733 if archived {
734 entry.governance_state = MemoryGovernanceState::Archived;
735 } else {
736 entry
737 .tags
738 .retain(|tag| !tag.eq_ignore_ascii_case("archived"));
739 if entry.governance_state == MemoryGovernanceState::Archived {
740 entry.governance_state = MemoryGovernanceState::Active;
741 }
742 }
743 entry.tags = normalize_tags(entry.tags);
744
745 let Some(file_path) = entry.file_path.clone() else {
746 return Err(MemoryConsoleError::MissingFilePath);
747 };
748 update_memory_bank_entry(workspace_dir, &file_path, &entry).await?;
749
750 let _ = refresh_memory_bank_governance(workspace_dir).await?;
751 let reloaded = load_from_memory_bank(&file_path).await?;
752 Ok(entry_detail_from_entry(
753 workspace_dir,
754 &reloaded,
755 None,
756 Vec::new(),
757 ))
758}
759
760pub async fn delete_memory_entry_by_id(
762 workspace_dir: &Path,
763 entry_id: &str,
764) -> MemoryConsoleResult<()> {
765 delete_memory_bank_entry(workspace_dir, &resolve_entry_id(workspace_dir, entry_id)).await?;
766 let _ = refresh_memory_bank_governance(workspace_dir).await?;
767 Ok(())
768}
769
770pub async fn clear_memory_console(workspace_dir: &Path) -> MemoryConsoleResult<usize> {
772 Ok(clear_memory_bank(workspace_dir).await?)
773}
774
775fn search_working_memory(
776 session: &AgentSession,
777 query: &MemoryConsoleQuery,
778) -> Vec<WorkingMemoryMatch> {
779 let mut matches = Vec::new();
780 let working = &session.state.working_memory;
781 let query_text = query
782 .text
783 .as_deref()
784 .unwrap_or_default()
785 .trim()
786 .to_ascii_lowercase();
787 let has_query = !query_text.is_empty();
788
789 if let Some(summary) = &working.summary {
790 push_working_match(
791 &mut matches,
792 has_query,
793 &query_text,
794 WorkingMemoryMatch {
795 id: "summary".to_string(),
796 section: "summary".to_string(),
797 summary: summary.clone(),
798 detail: None,
799 tags: vec!["summary".to_string()],
800 status: None,
801 score: 2.5,
802 },
803 );
804 }
805
806 for resource in &working.resources {
807 push_working_match(
808 &mut matches,
809 has_query,
810 &query_text,
811 WorkingMemoryMatch {
812 id: resource.id.clone(),
813 section: "resource".to_string(),
814 summary: format!("{}: {}", resource_kind_label(resource.kind), resource.label),
815 detail: Some(resource.value.clone()),
816 tags: vec![
817 resource_kind_label(resource.kind).to_string(),
818 resource.source.clone(),
819 ],
820 status: None,
821 score: 1.4,
822 },
823 );
824 }
825
826 for decision in &working.decisions {
827 push_working_match(
828 &mut matches,
829 has_query,
830 &query_text,
831 WorkingMemoryMatch {
832 id: decision.id.clone(),
833 section: "decision".to_string(),
834 summary: decision.summary.clone(),
835 detail: decision.rationale.clone(),
836 tags: decision.tags.clone(),
837 status: None,
838 score: 3.2,
839 },
840 );
841 }
842
843 for blocker in &working.blockers {
844 push_working_match(
845 &mut matches,
846 has_query,
847 &query_text,
848 WorkingMemoryMatch {
849 id: blocker.id.clone(),
850 section: "blocker".to_string(),
851 summary: blocker.summary.clone(),
852 detail: blocker.detail.clone(),
853 tags: vec!["blocker".to_string()],
854 status: Some(blocker_status_label(blocker.status).to_string()),
855 score: if matches!(blocker.status, SessionBlockerStatus::Open) {
856 3.8
857 } else {
858 1.0
859 },
860 },
861 );
862 }
863
864 for (index, action) in working.next_actions.iter().enumerate() {
865 push_working_match(
866 &mut matches,
867 has_query,
868 &query_text,
869 WorkingMemoryMatch {
870 id: format!("next-action-{index}"),
871 section: "next_action".to_string(),
872 summary: action.clone(),
873 detail: None,
874 tags: vec!["next_action".to_string()],
875 status: None,
876 score: 2.1,
877 },
878 );
879 }
880
881 for (index, question) in working.open_questions.iter().enumerate() {
882 push_working_match(
883 &mut matches,
884 has_query,
885 &query_text,
886 WorkingMemoryMatch {
887 id: format!("open-question-{index}"),
888 section: "open_question".to_string(),
889 summary: question.clone(),
890 detail: None,
891 tags: vec!["open_question".to_string()],
892 status: None,
893 score: 1.8,
894 },
895 );
896 }
897
898 for entry in &working.timeline {
899 push_working_match(
900 &mut matches,
901 has_query,
902 &query_text,
903 WorkingMemoryMatch {
904 id: entry.id.clone(),
905 section: "timeline".to_string(),
906 summary: entry.summary.clone(),
907 detail: entry.detail.clone(),
908 tags: vec![timeline_kind_label(entry.kind).to_string()],
909 status: None,
910 score: 1.3,
911 },
912 );
913 }
914
915 matches.sort_by(|a, b| {
916 b.score
917 .total_cmp(&a.score)
918 .then_with(|| a.summary.cmp(&b.summary))
919 });
920 matches.truncate(query.limit);
921 matches
922}
923
924fn push_working_match(
925 matches: &mut Vec<WorkingMemoryMatch>,
926 has_query: bool,
927 query: &str,
928 mut candidate: WorkingMemoryMatch,
929) {
930 if !has_query {
931 matches.push(candidate);
932 return;
933 }
934
935 let haystack = format!(
936 "{} {} {}",
937 candidate.section,
938 candidate.summary,
939 candidate.detail.clone().unwrap_or_default()
940 )
941 .to_ascii_lowercase();
942
943 if haystack.contains(query)
944 || candidate
945 .tags
946 .iter()
947 .any(|tag| tag.to_ascii_lowercase().contains(query))
948 {
949 candidate.score += 1.5;
950 matches.push(candidate);
951 }
952}
953
954fn entry_summary_from_entry(
955 workspace_dir: &Path,
956 entry: &MemoryBankEntry,
957 score: Option<f32>,
958 matched_fields: Vec<String>,
959) -> MemoryConsoleEntrySummary {
960 MemoryConsoleEntrySummary {
961 entry_id: memory_entry_id(workspace_dir, entry),
962 timestamp: entry.timestamp,
963 memory_kind: entry.memory_kind,
964 memory_type: entry.memory_type,
965 scope: entry.scope,
966 session_id: entry.session_id.clone(),
967 category: entry.category.clone(),
968 task_id: entry.task_id.clone(),
969 directive_id: entry.directive_id.clone(),
970 agent_id: entry.agent_id.clone(),
971 tags: entry.tags.clone(),
972 confidence: entry.confidence,
973 summary: entry.summary.clone(),
974 file_path: entry
975 .file_path
976 .as_ref()
977 .map(|path| path_to_id(workspace_dir, path)),
978 archived: entry.is_archived(),
979 governance_state: entry.governance_state,
980 reflection_state: entry.reflection_state,
981 governance_issue_count: entry.governance_suggestions.len(),
982 score,
983 matched_fields,
984 }
985}
986
987fn entry_detail_from_entry(
988 workspace_dir: &Path,
989 entry: &MemoryBankEntry,
990 score: Option<f32>,
991 matched_fields: Vec<String>,
992) -> MemoryConsoleEntryDetail {
993 MemoryConsoleEntryDetail {
994 summary: entry_summary_from_entry(workspace_dir, entry, score, matched_fields),
995 content: entry.content.clone(),
996 promoted_from_session_id: entry.promoted_from_session_id.clone(),
997 promotion_reason: entry.promotion_reason.clone(),
998 governance_note: entry.governance_note.clone(),
999 governance_suggestions: entry.governance_suggestions.clone(),
1000 reflection_id: entry.reflection_id.clone(),
1001 strategy_key: entry.strategy_key.clone(),
1002 success_count: entry.success_count,
1003 failure_count: entry.failure_count,
1004 outcome_summary: entry.outcome_summary.clone(),
1005 outcome_labels: entry.outcome_labels.clone(),
1006 }
1007}
1008
1009fn session_summary_from_session(session: &AgentSession) -> MemoryConsoleSessionSummary {
1010 MemoryConsoleSessionSummary {
1011 session_id: session.id.clone(),
1012 title: session.title.clone(),
1013 last_active: session.last_active,
1014 message_count: session.message_count(),
1015 workspace_dir: session
1016 .workspace_dir()
1017 .map(|path| path.display().to_string()),
1018 }
1019}
1020
1021fn counts_to_vec(counts: BTreeMap<String, usize>) -> Vec<MemoryConsoleCount> {
1022 counts
1023 .into_iter()
1024 .map(|(key, count)| MemoryConsoleCount { key, count })
1025 .collect()
1026}
1027
1028fn normalize_tags(tags: Vec<String>) -> Vec<String> {
1029 let mut normalized = Vec::new();
1030 for tag in tags {
1031 let trimmed = tag.trim();
1032 if trimmed.is_empty() {
1033 continue;
1034 }
1035 if !normalized
1036 .iter()
1037 .any(|existing: &String| existing.eq_ignore_ascii_case(trimmed))
1038 {
1039 normalized.push(trimmed.to_string());
1040 }
1041 }
1042 normalized
1043}
1044
1045fn memory_entry_id(workspace_dir: &Path, entry: &MemoryBankEntry) -> String {
1046 entry
1047 .file_path
1048 .as_ref()
1049 .map(|path| path_to_id(workspace_dir, path))
1050 .unwrap_or_else(|| {
1051 format!(
1052 ".gestura/memory/{}_{}.md",
1053 entry.timestamp.format("%Y%m%d%H%M%S"),
1054 entry.session_id
1055 )
1056 })
1057}
1058
1059fn path_to_id(workspace_dir: &Path, path: &Path) -> String {
1060 path.strip_prefix(workspace_dir)
1061 .unwrap_or(path)
1062 .to_string_lossy()
1063 .to_string()
1064}
1065
1066fn resolve_entry_id(workspace_dir: &Path, entry_id: &str) -> PathBuf {
1067 let path = Path::new(entry_id);
1068 if path.is_absolute() {
1069 path.to_path_buf()
1070 } else {
1071 workspace_dir.join(path)
1072 }
1073}
1074
1075fn resource_kind_label(kind: SessionMemoryResourceKind) -> &'static str {
1076 match kind {
1077 SessionMemoryResourceKind::Message => "message",
1078 SessionMemoryResourceKind::ToolCall => "tool_call",
1079 SessionMemoryResourceKind::File => "file",
1080 SessionMemoryResourceKind::Command => "command",
1081 SessionMemoryResourceKind::Web => "web",
1082 SessionMemoryResourceKind::Task => "task",
1083 SessionMemoryResourceKind::Knowledge => "knowledge",
1084 SessionMemoryResourceKind::Other => "other",
1085 }
1086}
1087
1088fn blocker_status_label(status: SessionBlockerStatus) -> &'static str {
1089 match status {
1090 SessionBlockerStatus::Open => "open",
1091 SessionBlockerStatus::Resolved => "resolved",
1092 }
1093}
1094
1095fn timeline_kind_label(kind: SessionMemoryEntryKind) -> &'static str {
1096 match kind {
1097 SessionMemoryEntryKind::UserGoal => "user_goal",
1098 SessionMemoryEntryKind::AssistantSummary => "assistant_summary",
1099 SessionMemoryEntryKind::Narration => "narration",
1100 SessionMemoryEntryKind::ToolInsight => "tool_insight",
1101 SessionMemoryEntryKind::Handoff => "handoff",
1102 }
1103}
1104
1105#[cfg(test)]
1106mod tests {
1107 use super::*;
1108 use crate::agent_sessions::AgentSession;
1109 use tempfile::tempdir;
1110
1111 #[tokio::test]
1112 async fn search_memory_console_returns_working_and_durable_matches() {
1113 let temp = tempdir().unwrap();
1114 let mut session =
1115 AgentSession::new_with_workspace(temp.path().to_path_buf(), None).unwrap();
1116 session.state.remember_decision(
1117 "Adopt memory console",
1118 Some("Parity first".to_string()),
1119 vec!["memory".to_string()],
1120 );
1121
1122 let entry = MemoryBankEntry::new(
1123 session.id.clone(),
1124 "Memory console promoted decision".to_string(),
1125 "Durable detail".to_string(),
1126 )
1127 .with_memory_type(MemoryType::Decision)
1128 .with_scope(MemoryScope::Directive)
1129 .with_tags(vec!["memory".to_string(), "console".to_string()]);
1130 save_to_memory_bank(temp.path(), &entry).await.unwrap();
1131
1132 let results = search_memory_console(
1133 temp.path(),
1134 Some(&session),
1135 MemoryConsoleQuery {
1136 text: Some("memory".to_string()),
1137 ..MemoryConsoleQuery::default()
1138 },
1139 )
1140 .await
1141 .unwrap();
1142
1143 assert!(!results.working_memory.is_empty());
1144 assert!(!results.durable_memory.is_empty());
1145 }
1146
1147 #[tokio::test]
1148 async fn archive_filters_out_entries_by_default() {
1149 let temp = tempdir().unwrap();
1150 let session = AgentSession::new_with_workspace(temp.path().to_path_buf(), None).unwrap();
1151 let entry = MemoryBankEntry::new(
1152 session.id.clone(),
1153 "Archived memory".to_string(),
1154 "Hidden from default search".to_string(),
1155 )
1156 .with_tags(vec!["archived".to_string()]);
1157 let path = save_to_memory_bank(temp.path(), &entry).await.unwrap();
1158
1159 let default_results =
1160 search_memory_console(temp.path(), Some(&session), MemoryConsoleQuery::default())
1161 .await
1162 .unwrap();
1163 assert!(default_results.durable_memory.is_empty());
1164
1165 let detail = set_memory_entry_archived(
1166 temp.path(),
1167 &path.strip_prefix(temp.path()).unwrap().to_string_lossy(),
1168 false,
1169 )
1170 .await
1171 .unwrap();
1172 assert!(!detail.summary.archived);
1173 }
1174
1175 #[tokio::test]
1176 async fn governance_refresh_surfaces_in_overview_and_entry_detail() {
1177 let temp = tempdir().unwrap();
1178 let session = AgentSession::new_with_workspace(temp.path().to_path_buf(), None).unwrap();
1179 let base_time = Utc::now();
1180
1181 let mut first = MemoryBankEntry::new(
1182 session.id.clone(),
1183 "Directive memory handoff policy".to_string(),
1184 "Store concise directive-scoped handoff notes for subagent reuse.".to_string(),
1185 )
1186 .with_memory_type(MemoryType::Procedural)
1187 .with_scope(MemoryScope::Directive)
1188 .with_governance_state(MemoryGovernanceState::Pinned)
1189 .with_provenance(
1190 Some("task-governance".to_string()),
1191 Some("directive-governance".to_string()),
1192 Some("agent-a".to_string()),
1193 );
1194 first.category = Some("shared_cognition".to_string());
1195 first.timestamp = base_time;
1196 save_to_memory_bank(temp.path(), &first).await.unwrap();
1197
1198 let mut reflection_a = MemoryBankEntry::new(
1199 session.id.clone(),
1200 "Reflection: inspect files first".to_string(),
1201 "Inspect the code before making assumptions about behavior.".to_string(),
1202 )
1203 .with_memory_type(MemoryType::Reflection)
1204 .with_scope(MemoryScope::Workspace)
1205 .with_provenance(
1206 Some("task-governance".to_string()),
1207 Some("directive-governance".to_string()),
1208 Some("agent-b".to_string()),
1209 )
1210 .with_reflection_learning("inspect-files-first", ReflectionMemoryState::Active, 3, 0)
1211 .with_outcome_provenance(
1212 Some("Approved after inspection".to_string()),
1213 vec!["approved".to_string()],
1214 );
1215 reflection_a.timestamp = base_time + chrono::Duration::seconds(1);
1216 let reflection_a_path = save_to_memory_bank(temp.path(), &reflection_a)
1217 .await
1218 .unwrap();
1219
1220 let mut reflection_b = MemoryBankEntry::new(
1221 session.id.clone(),
1222 "Reflection: inspect files first".to_string(),
1223 "Inspect the code before making assumptions about behavior.".to_string(),
1224 )
1225 .with_memory_type(MemoryType::Reflection)
1226 .with_scope(MemoryScope::Workspace)
1227 .with_provenance(
1228 Some("task-governance".to_string()),
1229 Some("directive-governance".to_string()),
1230 Some("agent-c".to_string()),
1231 )
1232 .with_reflection_learning(
1233 "inspect-files-first",
1234 ReflectionMemoryState::NeedsReview,
1235 0,
1236 3,
1237 )
1238 .with_outcome_provenance(
1239 Some("Rejected without inspection".to_string()),
1240 vec!["failed".to_string()],
1241 );
1242 reflection_b.timestamp = base_time + chrono::Duration::seconds(2);
1243 save_to_memory_bank(temp.path(), &reflection_b)
1244 .await
1245 .unwrap();
1246
1247 let _report = refresh_memory_console_governance(temp.path())
1248 .await
1249 .unwrap();
1250
1251 let overview = get_memory_console_overview(temp.path(), Some(&session))
1252 .await
1253 .unwrap();
1254 assert_eq!(overview.durable_total, 3);
1255 assert!(
1256 overview
1257 .counts_by_governance
1258 .iter()
1259 .any(|count| count.key == "pinned" && count.count == 1)
1260 );
1261 assert!(
1262 overview
1263 .counts_by_category
1264 .iter()
1265 .any(|count| count.key == "shared_cognition" && count.count == 1)
1266 );
1267
1268 let first_id = reflection_a_path
1269 .strip_prefix(temp.path())
1270 .unwrap()
1271 .to_string_lossy()
1272 .to_string();
1273 let detail = get_memory_entry_detail(temp.path(), &first_id)
1274 .await
1275 .unwrap();
1276 assert_eq!(detail.summary.memory_type, MemoryType::Reflection);
1277
1278 let updated = update_memory_entry_detail(
1279 temp.path(),
1280 &first_id,
1281 UpdateMemoryEntryRequest {
1282 governance_state: Some(MemoryGovernanceState::NeedsReview),
1283 governance_note: Some(Some("Operator review requested".to_string())),
1284 ..UpdateMemoryEntryRequest::default()
1285 },
1286 )
1287 .await
1288 .unwrap();
1289 assert_eq!(
1290 updated.summary.governance_state,
1291 MemoryGovernanceState::NeedsReview
1292 );
1293 assert_eq!(
1294 updated.governance_note.as_deref(),
1295 Some("Operator review requested")
1296 );
1297 }
1298}