gestura_core/
memory_console.rs

1//! Shared memory console service for CLI and GUI inspection workflows.
2//!
3//! This module provides the unified memory-console surface used across CLI,
4//! GUI, and agent-facing memory inspection flows. It composes two underlying
5//! systems:
6//!
7//! - session working memory from `agent_sessions`
8//! - durable memory-bank entries from `gestura_core::memory_bank`
9//!
10//! ## Design role
11//!
12//! The memory console is intentionally a facade-level service instead of a
13//! standalone domain crate because it coordinates multiple domains at once:
14//!
15//! - session storage
16//! - durable memory-bank retrieval and mutation
17//! - task lifecycle/memory event integration
18//! - shared DTOs consumed by both CLI and GUI presentation layers
19//!
20//! ## High-signal workflows
21//!
22//! - overview facets and counts for memory health
23//! - cross-memory search over working and durable memory
24//! - inspection of promotions, archival state, and provenance
25//! - task-aware memory lifecycle views for handoffs and blockers
26//!
27//! This keeps memory-related UX parity in one core-owned place instead of
28//! duplicating query logic in multiple frontends.
29
30use 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
49/// Result type for memory-console workflows.
50pub type MemoryConsoleResult<T> = Result<T, MemoryConsoleError>;
51
52/// Errors produced while inspecting or mutating memory-console state.
53#[derive(Debug, thiserror::Error)]
54pub enum MemoryConsoleError {
55    /// Wrapped memory-bank error.
56    #[error(transparent)]
57    MemoryBank(#[from] MemoryBankError),
58    /// Wrapped task-manager error.
59    #[error(transparent)]
60    Task(#[from] TaskError),
61    /// Wrapped session-store error.
62    #[error(transparent)]
63    Session(#[from] AppError),
64    /// A workspace path is required for memory-bank operations.
65    #[error("No workspace directory configured for the selected session")]
66    MissingWorkspace,
67    /// The requested durable memory entry did not contain an on-disk path.
68    #[error("Memory entry is missing its file path")]
69    MissingFilePath,
70    /// The requested session could not be found.
71    #[error("Session not found: {0}")]
72    SessionNotFound(String),
73    /// The requested task memory lifecycle is missing.
74    #[error("Task memory lifecycle not found for task: {0}")]
75    TaskLifecycleNotFound(String),
76    /// Invalid user input for a console action.
77    #[error("Invalid memory console input: {0}")]
78    InvalidInput(String),
79}
80
81/// Lightweight session summary used by the memory console.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct MemoryConsoleSessionSummary {
84    /// Session id.
85    pub session_id: String,
86    /// Human-friendly title.
87    pub title: String,
88    /// Last-activity timestamp.
89    pub last_active: DateTime<Utc>,
90    /// Persisted message count.
91    pub message_count: usize,
92    /// Optional workspace path.
93    pub workspace_dir: Option<String>,
94}
95
96/// A simple count bucket for overview facets.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct MemoryConsoleCount {
99    /// Facet key.
100    pub key: String,
101    /// Number of items in the facet.
102    pub count: usize,
103}
104
105/// Search filters shared by CLI, GUI, and slash/TUI memory consoles.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct MemoryConsoleQuery {
108    /// Optional free-text query.
109    pub text: Option<String>,
110    /// Maximum number of durable and working-memory matches to return.
111    pub limit: usize,
112    /// Whether to search current-session working memory.
113    pub include_working_memory: bool,
114    /// Whether to search durable memory-bank entries.
115    pub include_durable_memory: bool,
116    /// Whether archived entries should be included.
117    pub include_archived: bool,
118    /// Optional memory-kind restrictions.
119    pub kinds: Vec<MemoryKind>,
120    /// Optional memory-type restrictions.
121    pub memory_types: Vec<MemoryType>,
122    /// Optional memory-scope restrictions.
123    pub scopes: Vec<MemoryScope>,
124    /// Optional session filter.
125    pub session_id: Option<String>,
126    /// Optional task filter.
127    pub task_id: Option<String>,
128    /// Optional directive filter.
129    pub directive_id: Option<String>,
130    /// Optional agent filter.
131    pub agent_id: Option<String>,
132    /// Optional category filter.
133    pub category: Option<String>,
134    /// Optional tags filter.
135    pub tags: Vec<String>,
136    /// Optional minimum confidence.
137    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/// Search hit from session working memory.
163#[derive(Debug, Clone, Serialize, Deserialize)]
164pub struct WorkingMemoryMatch {
165    /// Stable id scoped to the session snapshot.
166    pub id: String,
167    /// Working-memory section label.
168    pub section: String,
169    /// Summary text.
170    pub summary: String,
171    /// Optional detail text.
172    pub detail: Option<String>,
173    /// Optional tags associated with the record.
174    pub tags: Vec<String>,
175    /// Optional status value for blockers.
176    pub status: Option<String>,
177    /// Ranking score.
178    pub score: f32,
179}
180
181/// Durable memory summary displayed in lists and search results.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct MemoryConsoleEntrySummary {
184    /// Stable id used by CLI and GUI actions.
185    pub entry_id: String,
186    /// Entry timestamp.
187    pub timestamp: DateTime<Utc>,
188    /// Retention kind.
189    pub memory_kind: MemoryKind,
190    /// Memory type.
191    pub memory_type: MemoryType,
192    /// Scope.
193    pub scope: MemoryScope,
194    /// Owning session id.
195    pub session_id: String,
196    /// Optional category.
197    pub category: Option<String>,
198    /// Optional task id.
199    pub task_id: Option<String>,
200    /// Optional directive id.
201    pub directive_id: Option<String>,
202    /// Optional agent id.
203    pub agent_id: Option<String>,
204    /// Tags.
205    pub tags: Vec<String>,
206    /// Confidence.
207    pub confidence: f32,
208    /// Summary.
209    pub summary: String,
210    /// Relative file path.
211    pub file_path: Option<String>,
212    /// Whether the entry is archived.
213    pub archived: bool,
214    /// Governance state.
215    pub governance_state: MemoryGovernanceState,
216    /// Reflection governance state, when applicable.
217    pub reflection_state: Option<ReflectionMemoryState>,
218    /// Number of unresolved governance suggestions.
219    pub governance_issue_count: usize,
220    /// Optional ranking score.
221    pub score: Option<f32>,
222    /// Optional matched fields.
223    pub matched_fields: Vec<String>,
224}
225
226/// Full durable memory detail including content.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct MemoryConsoleEntryDetail {
229    /// Summary fields used in lists.
230    #[serde(flatten)]
231    pub summary: MemoryConsoleEntrySummary,
232    /// Full markdown content.
233    pub content: String,
234    /// Promotion provenance session id.
235    pub promoted_from_session_id: Option<String>,
236    /// Promotion rationale.
237    pub promotion_reason: Option<String>,
238    /// Optional operator governance note.
239    pub governance_note: Option<String>,
240    /// Persisted governance suggestions.
241    pub governance_suggestions: Vec<MemoryGovernanceSuggestion>,
242    /// Optional linked reflection id.
243    pub reflection_id: Option<String>,
244    /// Optional normalized strategy key.
245    pub strategy_key: Option<String>,
246    /// Reflection downstream success count.
247    pub success_count: u16,
248    /// Reflection downstream failure count.
249    pub failure_count: u16,
250    /// Optional downstream outcome summary.
251    pub outcome_summary: Option<String>,
252    /// Stable downstream outcome labels.
253    pub outcome_labels: Vec<String>,
254}
255
256/// Overview payload for the memory console home screen.
257#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct MemoryConsoleOverview {
259    /// Current workspace path.
260    pub workspace_dir: String,
261    /// Selected session summary, if any.
262    pub session: Option<MemoryConsoleSessionSummary>,
263    /// Total durable entries.
264    pub durable_total: usize,
265    /// Count of open blockers in working memory.
266    pub open_blocker_count: usize,
267    /// Count of promotion candidates.
268    pub promotion_candidate_count: usize,
269    /// Count of working-memory resources.
270    pub working_resource_count: usize,
271    /// Count of working-memory decisions.
272    pub working_decision_count: usize,
273    /// Count of durable entries that currently need governance review.
274    pub governance_review_count: usize,
275    /// Total number of persisted governance suggestions.
276    pub governance_issue_count: usize,
277    /// Working-memory rolling summary.
278    pub working_summary: Option<String>,
279    /// Recent durable entries.
280    pub recent_entries: Vec<MemoryConsoleEntrySummary>,
281    /// Facet counts by kind.
282    pub counts_by_kind: Vec<MemoryConsoleCount>,
283    /// Facet counts by type.
284    pub counts_by_type: Vec<MemoryConsoleCount>,
285    /// Facet counts by scope.
286    pub counts_by_scope: Vec<MemoryConsoleCount>,
287    /// Facet counts by entry category.
288    pub counts_by_category: Vec<MemoryConsoleCount>,
289    /// Facet counts by governance state.
290    pub counts_by_governance: Vec<MemoryConsoleCount>,
291}
292
293/// Combined search response for working + durable memory.
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct MemoryConsoleSearchResponse {
296    /// The normalized query that was executed.
297    pub query: MemoryConsoleQuery,
298    /// Working-memory matches.
299    pub working_memory: Vec<WorkingMemoryMatch>,
300    /// Durable memory matches.
301    pub durable_memory: Vec<MemoryConsoleEntrySummary>,
302}
303
304/// Request for promoting a working-memory candidate into durable memory.
305#[derive(Debug, Clone, Serialize, Deserialize)]
306pub struct PromoteMemoryCandidateRequest {
307    /// Candidate summary.
308    pub summary: String,
309    /// Optional detail/body.
310    pub detail: Option<String>,
311    /// Optional category.
312    pub category: Option<String>,
313    /// Durable memory kind.
314    pub memory_kind: MemoryKind,
315    /// Durable memory type.
316    pub memory_type: MemoryType,
317    /// Durable memory scope.
318    pub scope: MemoryScope,
319    /// Optional task id to associate.
320    pub task_id: Option<String>,
321    /// Optional directive id to associate.
322    pub directive_id: Option<String>,
323    /// Optional agent id to associate.
324    pub agent_id: Option<String>,
325    /// Tags to attach.
326    pub tags: Vec<String>,
327    /// Confidence to assign.
328    pub confidence: f32,
329    /// Promotion reason shown in metadata.
330    pub promotion_reason: Option<String>,
331}
332
333/// Request for updating an existing durable memory entry.
334#[derive(Debug, Clone, Serialize, Deserialize, Default)]
335pub struct UpdateMemoryEntryRequest {
336    /// Replacement summary.
337    pub summary: Option<String>,
338    /// Replacement markdown body.
339    pub content: Option<String>,
340    /// Replacement category.
341    pub category: Option<Option<String>>,
342    /// Replacement kind.
343    pub memory_kind: Option<MemoryKind>,
344    /// Replacement type.
345    pub memory_type: Option<MemoryType>,
346    /// Replacement scope.
347    pub scope: Option<MemoryScope>,
348    /// Replacement task id.
349    pub task_id: Option<Option<String>>,
350    /// Replacement directive id.
351    pub directive_id: Option<Option<String>>,
352    /// Replacement agent id.
353    pub agent_id: Option<Option<String>>,
354    /// Replacement tags.
355    pub tags: Option<Vec<String>>,
356    /// Replacement confidence.
357    pub confidence: Option<f32>,
358    /// Replacement governance state.
359    pub governance_state: Option<MemoryGovernanceState>,
360    /// Replacement governance note.
361    pub governance_note: Option<Option<String>>,
362}
363
364/// Task-memory detail view shared by CLI and GUI.
365#[derive(Debug, Clone, Serialize, Deserialize)]
366pub struct TaskMemoryConsoleDetail {
367    /// The looked-up task id.
368    pub task_id: String,
369    /// Lifecycle payload recorded in metadata.
370    pub lifecycle: TaskMemoryLifecycle,
371}
372
373/// List recent sessions for global memory-console browsing.
374pub 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
397/// Resolve a session id or prefix into a persisted session.
398pub 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
418/// Build a memory-console overview for a workspace and optional session.
419pub 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| &current.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
494/// Return the current session working-memory snapshot.
495pub fn get_working_memory_snapshot(session: &AgentSession) -> SessionWorkingMemory {
496    session.state.working_memory.clone()
497}
498
499/// Return promotion candidates for the selected session.
500pub fn get_memory_promotion_candidates(
501    session: &AgentSession,
502    limit: usize,
503) -> Vec<SessionMemoryPromotionCandidate> {
504    session.state.promotion_candidates(limit.max(1))
505}
506
507/// Look up a durable memory entry by CLI/GUI id.
508pub 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
522/// Search across working memory and durable memory using shared filters.
523pub 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
578/// Read task-local memory lifecycle information.
579pub 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
595/// Refresh persisted governance suggestions for durable memory.
596pub async fn refresh_memory_console_governance(
597    workspace_dir: &Path,
598) -> MemoryConsoleResult<MemoryGovernanceRefreshReport> {
599    Ok(refresh_memory_bank_governance(workspace_dir).await?)
600}
601
602/// Promote a working-memory candidate into durable memory.
603pub 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
661/// Update a durable memory entry in place.
662pub 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
725/// Archive or unarchive a durable memory entry.
726pub 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
760/// Delete a durable memory entry by id.
761pub 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
770/// Clear all durable memory entries in the workspace.
771pub 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}