Add operator Mentra memory browser
This commit is contained in:
@@ -25,6 +25,7 @@ const MAX_TOKEN_BYTES: usize = 16 * 1024;
|
||||
const MAX_OPERATOR_MESSAGE_BYTES: usize = 16 * 1024;
|
||||
const MAX_PAGE_SIZE: u16 = 100;
|
||||
const MAX_EVENT_BYTES: usize = 4 * 1024;
|
||||
const MAX_MEMORY_QUERY_BYTES: usize = 1_024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -248,6 +249,81 @@ pub struct AuditEventView {
|
||||
pub authorization_id: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemoryKindView {
|
||||
Episode,
|
||||
Summary,
|
||||
Fact,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MemorySortView {
|
||||
#[default]
|
||||
Newest,
|
||||
Oldest,
|
||||
Relevance,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MemoryFilterView {
|
||||
pub kind: Option<MemoryKindView>,
|
||||
pub pinned: Option<bool>,
|
||||
pub source: Option<String>,
|
||||
pub created_from: Option<i64>,
|
||||
pub created_to: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MemoryCursorView {
|
||||
pub created_at: i64,
|
||||
pub record_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MemoryHealthView {
|
||||
pub agent_count: usize,
|
||||
pub record_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MemoryAgentView {
|
||||
pub avatar_id: String,
|
||||
pub logical_agent_id: String,
|
||||
pub mentra_agent_id: String,
|
||||
pub record_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MemoryRecordView {
|
||||
pub record_id: String,
|
||||
pub kind: MemoryKindView,
|
||||
pub preview: String,
|
||||
pub source: Option<String>,
|
||||
pub source_revision: u64,
|
||||
pub pinned: bool,
|
||||
pub created_at: i64,
|
||||
pub score: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MemoryPageView {
|
||||
pub items: Vec<MemoryRecordView>,
|
||||
pub next_cursor: Option<MemoryCursorView>,
|
||||
pub next_offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MemoryDetailView {
|
||||
pub record: MemoryRecordView,
|
||||
pub content: String,
|
||||
pub metadata_json: String,
|
||||
pub content_truncated: bool,
|
||||
pub metadata_truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
|
||||
pub enum ControlPayload {
|
||||
@@ -259,6 +335,10 @@ pub enum ControlPayload {
|
||||
PendingApprovals(Page<PendingApprovalView>),
|
||||
AuditEvents(Page<AuditEventView>),
|
||||
ObservabilityEvents(Page<StructuredEvent>),
|
||||
MemoryHealth(MemoryHealthView),
|
||||
MemoryAgents(Page<MemoryAgentView>),
|
||||
Memories(MemoryPageView),
|
||||
MemoryDetail(MemoryDetailView),
|
||||
Accepted { operation_id: String },
|
||||
Completed,
|
||||
Subscribed { current_sequence: u64 },
|
||||
@@ -293,6 +373,32 @@ pub enum ControlRequest {
|
||||
ListObservabilityEvents {
|
||||
page: PageRequest,
|
||||
},
|
||||
MemoryHealth,
|
||||
ListMemoryAgents {
|
||||
page: PageRequest,
|
||||
},
|
||||
BrowseMemories {
|
||||
mentra_agent_id: String,
|
||||
cursor: Option<MemoryCursorView>,
|
||||
limit: u16,
|
||||
filter: MemoryFilterView,
|
||||
sort: MemorySortView,
|
||||
},
|
||||
SearchMemories {
|
||||
mentra_agent_id: String,
|
||||
query: String,
|
||||
page: PageRequest,
|
||||
filter: MemoryFilterView,
|
||||
sort: MemorySortView,
|
||||
},
|
||||
GetMemory {
|
||||
mentra_agent_id: String,
|
||||
record_id: String,
|
||||
},
|
||||
ForgetMemory {
|
||||
mentra_agent_id: String,
|
||||
record_id: String,
|
||||
},
|
||||
SubscribeEvents {
|
||||
after_sequence: Option<u64>,
|
||||
},
|
||||
@@ -347,6 +453,11 @@ impl ControlRequest {
|
||||
| Self::ListPendingApprovals { .. }
|
||||
| Self::ListAuditEvents { .. }
|
||||
| Self::ListObservabilityEvents { .. }
|
||||
| Self::MemoryHealth
|
||||
| Self::ListMemoryAgents { .. }
|
||||
| Self::BrowseMemories { .. }
|
||||
| Self::SearchMemories { .. }
|
||||
| Self::GetMemory { .. }
|
||||
| Self::SubscribeEvents { .. }
|
||||
)
|
||||
}
|
||||
@@ -357,7 +468,44 @@ impl ControlRequest {
|
||||
| Self::ListScheduledJobs { page }
|
||||
| Self::ListPendingApprovals { page }
|
||||
| Self::ListAuditEvents { page }
|
||||
| Self::ListObservabilityEvents { page } => page.valid(),
|
||||
| Self::ListObservabilityEvents { page }
|
||||
| Self::ListMemoryAgents { page } => page.valid(),
|
||||
Self::BrowseMemories {
|
||||
mentra_agent_id,
|
||||
cursor,
|
||||
limit,
|
||||
filter,
|
||||
sort,
|
||||
} => {
|
||||
valid_identifier(mentra_agent_id)
|
||||
&& (1..=MAX_PAGE_SIZE).contains(limit)
|
||||
&& *sort != MemorySortView::Relevance
|
||||
&& cursor
|
||||
.as_ref()
|
||||
.is_none_or(|cursor| valid_identifier(&cursor.record_id))
|
||||
&& memory_filter_valid(filter)
|
||||
}
|
||||
Self::SearchMemories {
|
||||
mentra_agent_id,
|
||||
query,
|
||||
page,
|
||||
filter,
|
||||
..
|
||||
} => {
|
||||
valid_identifier(mentra_agent_id)
|
||||
&& query.len() <= MAX_MEMORY_QUERY_BYTES
|
||||
&& !query.contains('\0')
|
||||
&& page.valid()
|
||||
&& memory_filter_valid(filter)
|
||||
}
|
||||
Self::GetMemory {
|
||||
mentra_agent_id,
|
||||
record_id,
|
||||
}
|
||||
| Self::ForgetMemory {
|
||||
mentra_agent_id,
|
||||
record_id,
|
||||
} => valid_identifier(mentra_agent_id) && valid_identifier(record_id),
|
||||
Self::CancelRequest { target_request_id } => valid_identifier(target_request_id),
|
||||
Self::CancelAction { action_id } => valid_identifier(action_id),
|
||||
Self::DecideApproval { approval_id, .. } => *approval_id != 0,
|
||||
@@ -1726,12 +1874,25 @@ fn valid_uuid_text(value: &str) -> bool {
|
||||
libremetaverse_types::UUID::parse(value.to_owned()).is_ok()
|
||||
}
|
||||
|
||||
fn memory_filter_valid(filter: &MemoryFilterView) -> bool {
|
||||
filter
|
||||
.source
|
||||
.as_ref()
|
||||
.is_none_or(|source| !source.is_empty() && source.len() <= 256 && !source.contains('\0'))
|
||||
&& match (filter.created_from, filter.created_to) {
|
||||
(Some(from), Some(to)) => from <= to,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn payload_valid(payload: &ControlPayload, maximum_frame_bytes: usize) -> bool {
|
||||
let page_is_bounded = match payload {
|
||||
ControlPayload::Sessions(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
|
||||
ControlPayload::ScheduledJobs(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
|
||||
ControlPayload::PendingApprovals(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
|
||||
ControlPayload::AuditEvents(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
|
||||
ControlPayload::MemoryAgents(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
|
||||
ControlPayload::Memories(page) => page.items.len() <= usize::from(MAX_PAGE_SIZE),
|
||||
_ => true,
|
||||
};
|
||||
page_is_bounded
|
||||
@@ -1771,6 +1932,12 @@ fn request_name(request: &ControlRequest) -> &'static str {
|
||||
ControlRequest::ListPendingApprovals { .. } => "list_pending_approvals",
|
||||
ControlRequest::ListAuditEvents { .. } => "list_audit_events",
|
||||
ControlRequest::ListObservabilityEvents { .. } => "list_observability_events",
|
||||
ControlRequest::MemoryHealth => "memory_health",
|
||||
ControlRequest::ListMemoryAgents { .. } => "list_memory_agents",
|
||||
ControlRequest::BrowseMemories { .. } => "browse_memories",
|
||||
ControlRequest::SearchMemories { .. } => "search_memories",
|
||||
ControlRequest::GetMemory { .. } => "get_memory",
|
||||
ControlRequest::ForgetMemory { .. } => "forget_memory",
|
||||
ControlRequest::SubscribeEvents { .. } => "subscribe_events",
|
||||
ControlRequest::CancelRequest { .. } => "cancel_request",
|
||||
ControlRequest::PauseAutonomy => "pause_autonomy",
|
||||
|
||||
@@ -6,8 +6,9 @@ use crate::behavior::{BehaviorIngress, BehaviorMode};
|
||||
use crate::control_plane::{
|
||||
AuditEventView, CONTROL_PROTOCOL_VERSION, ControlContext, ControlError, ControlErrorCode,
|
||||
ControlFuture, ControlPayload, ControlRequest, ControlTarget, ConversationChannelView,
|
||||
HealthView, Page, PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView,
|
||||
SessionMetadataView,
|
||||
HealthView, MemoryAgentView, MemoryCursorView, MemoryDetailView, MemoryFilterView,
|
||||
MemoryHealthView, MemoryKindView, MemoryPageView, MemoryRecordView, MemorySortView, Page,
|
||||
PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView, SessionMetadataView,
|
||||
};
|
||||
use crate::conversation::{ConversationChannel, ConversationKey, ConversationStore};
|
||||
use crate::observability::{
|
||||
@@ -88,6 +89,7 @@ pub struct AgentControlTarget {
|
||||
observability: Mutex<Option<Arc<Observability>>>,
|
||||
build: Mutex<Option<Arc<dyn crate::build::BuildControl>>>,
|
||||
vision: Mutex<Option<Arc<dyn crate::vision::VisionControl>>>,
|
||||
memory: Mutex<Option<Arc<crate::memory_admin::MentraMemoryAdmin>>>,
|
||||
behavior: BehaviorIngress,
|
||||
commands: mpsc::Sender<RuntimeControlCommand>,
|
||||
command_capacity: usize,
|
||||
@@ -133,6 +135,7 @@ impl AgentControlTarget {
|
||||
observability: Mutex::new(None),
|
||||
build: Mutex::new(None),
|
||||
vision: Mutex::new(None),
|
||||
memory: Mutex::new(None),
|
||||
behavior,
|
||||
commands,
|
||||
command_capacity,
|
||||
@@ -158,6 +161,10 @@ impl AgentControlTarget {
|
||||
*lock(&self.vision) = Some(vision);
|
||||
}
|
||||
|
||||
pub fn attach_memory_control(&self, memory: Arc<crate::memory_admin::MentraMemoryAdmin>) {
|
||||
*lock(&self.memory) = Some(memory);
|
||||
}
|
||||
|
||||
pub fn update_session(&self, session: SessionStatus) {
|
||||
let mut state = lock(&self.state);
|
||||
state.session = session;
|
||||
@@ -362,6 +369,156 @@ impl AgentControlTarget {
|
||||
&page, values,
|
||||
)))
|
||||
}
|
||||
ControlRequest::MemoryHealth => {
|
||||
let memory = memory_admin(&self.memory)?;
|
||||
let (agent_count, record_count) = memory.total_counts().map_err(memory_error)?;
|
||||
Ok(ControlPayload::MemoryHealth(MemoryHealthView {
|
||||
agent_count,
|
||||
record_count,
|
||||
}))
|
||||
}
|
||||
ControlRequest::ListMemoryAgents { page } => {
|
||||
require_operator(context)?;
|
||||
let values = memory_admin(&self.memory)?
|
||||
.agents()
|
||||
.map_err(memory_error)?
|
||||
.into_iter()
|
||||
.map(|(mapping, record_count)| MemoryAgentView {
|
||||
avatar_id: mapping.avatar_id,
|
||||
logical_agent_id: mapping.logical_agent_id,
|
||||
mentra_agent_id: mapping.mentra_agent_id,
|
||||
record_count,
|
||||
})
|
||||
.collect();
|
||||
Ok(ControlPayload::MemoryAgents(page_values(&page, values)))
|
||||
}
|
||||
ControlRequest::BrowseMemories {
|
||||
mentra_agent_id,
|
||||
cursor,
|
||||
limit,
|
||||
filter,
|
||||
sort,
|
||||
} => {
|
||||
require_operator(context)?;
|
||||
let (records, next_cursor) = memory_admin(&self.memory)?
|
||||
.browse(
|
||||
&mentra_agent_id,
|
||||
cursor.map(|cursor| mentra::memory::MemoryListCursor {
|
||||
created_at: cursor.created_at,
|
||||
record_id: cursor.record_id,
|
||||
}),
|
||||
usize::from(limit),
|
||||
memory_filter(filter),
|
||||
memory_sort(sort)?,
|
||||
)
|
||||
.map_err(memory_error)?;
|
||||
Ok(ControlPayload::Memories(MemoryPageView {
|
||||
items: records.into_iter().map(memory_record_view).collect(),
|
||||
next_cursor: next_cursor.map(|cursor| MemoryCursorView {
|
||||
created_at: cursor.created_at,
|
||||
record_id: cursor.record_id,
|
||||
}),
|
||||
next_offset: None,
|
||||
}))
|
||||
}
|
||||
ControlRequest::SearchMemories {
|
||||
mentra_agent_id,
|
||||
query,
|
||||
page,
|
||||
filter,
|
||||
sort,
|
||||
} => {
|
||||
require_operator(context)?;
|
||||
let start = usize::try_from(page.cursor.unwrap_or(0)).unwrap_or(usize::MAX);
|
||||
let requested = start
|
||||
.saturating_add(usize::from(page.limit))
|
||||
.saturating_add(1);
|
||||
let memory = memory_admin(&self.memory)?;
|
||||
let mut records = if query.trim().is_empty() {
|
||||
memory
|
||||
.browse(
|
||||
&mentra_agent_id,
|
||||
None,
|
||||
requested.min(500),
|
||||
memory_filter(filter.clone()),
|
||||
match sort {
|
||||
MemorySortView::Oldest => mentra::memory::MemoryListSort::Oldest,
|
||||
_ => mentra::memory::MemoryListSort::Newest,
|
||||
},
|
||||
)
|
||||
.map(|(records, _)| records)
|
||||
} else {
|
||||
memory.search(
|
||||
&mentra_agent_id,
|
||||
&query,
|
||||
requested.min(500),
|
||||
memory_filter(filter.clone()),
|
||||
)
|
||||
}
|
||||
.map_err(memory_error)?;
|
||||
records.retain(|record| record_matches(record, &filter));
|
||||
match sort {
|
||||
MemorySortView::Newest => records.sort_by(|a, b| {
|
||||
(b.created_at, &b.record_id).cmp(&(a.created_at, &a.record_id))
|
||||
}),
|
||||
MemorySortView::Oldest => records.sort_by(|a, b| {
|
||||
(a.created_at, &a.record_id).cmp(&(b.created_at, &b.record_id))
|
||||
}),
|
||||
MemorySortView::Relevance => {}
|
||||
}
|
||||
let has_more = records.len() > start.saturating_add(usize::from(page.limit));
|
||||
let items = records
|
||||
.into_iter()
|
||||
.skip(start)
|
||||
.take(usize::from(page.limit))
|
||||
.map(memory_record_view)
|
||||
.collect();
|
||||
Ok(ControlPayload::Memories(MemoryPageView {
|
||||
items,
|
||||
next_cursor: None,
|
||||
next_offset: has_more.then(|| {
|
||||
u64::try_from(start.saturating_add(usize::from(page.limit)))
|
||||
.unwrap_or(u64::MAX)
|
||||
}),
|
||||
}))
|
||||
}
|
||||
ControlRequest::GetMemory {
|
||||
mentra_agent_id,
|
||||
record_id,
|
||||
} => {
|
||||
require_operator(context)?;
|
||||
let record = memory_admin(&self.memory)?
|
||||
.detail(&mentra_agent_id, &record_id)
|
||||
.map_err(memory_error)?
|
||||
.ok_or_else(|| {
|
||||
control_error(ControlErrorCode::NotFound, "memory record not found", false)
|
||||
})?;
|
||||
Ok(ControlPayload::MemoryDetail(MemoryDetailView {
|
||||
record: memory_record_view(record.clone()),
|
||||
content: truncate_utf8(&record.content, 24 * 1024),
|
||||
metadata_json: truncate_utf8(&record.metadata_json, 8 * 1024),
|
||||
content_truncated: record.content.len() > 24 * 1024,
|
||||
metadata_truncated: record.metadata_json.len() > 8 * 1024,
|
||||
}))
|
||||
}
|
||||
ControlRequest::ForgetMemory {
|
||||
mentra_agent_id,
|
||||
record_id,
|
||||
} => {
|
||||
require_operator(context)?;
|
||||
if memory_admin(&self.memory)?
|
||||
.forget(&mentra_agent_id, &record_id)
|
||||
.map_err(memory_error)?
|
||||
{
|
||||
Ok(ControlPayload::Completed)
|
||||
} else {
|
||||
Err(control_error(
|
||||
ControlErrorCode::NotFound,
|
||||
"memory record not found",
|
||||
false,
|
||||
))
|
||||
}
|
||||
}
|
||||
ControlRequest::PauseAutonomy => {
|
||||
let response = self.enqueue("pause", RuntimeControlCommand::Pause)?;
|
||||
self.behavior.pause();
|
||||
@@ -639,6 +796,25 @@ fn runtime_domain_event(
|
||||
.result_code("completed")?
|
||||
.code_field("decision", if *approve { "approved" } else { "denied" })?,
|
||||
),
|
||||
ControlRequest::ForgetMemory {
|
||||
mentra_agent_id,
|
||||
record_id,
|
||||
} => Some(
|
||||
EventDraft::new(
|
||||
EventFamily::ControlCommand,
|
||||
EventSeverity::Info,
|
||||
"memory",
|
||||
EventOrigin::Control,
|
||||
)?
|
||||
.correlation(CorrelationIds {
|
||||
session_id: Some(pseudonymous_identifier(mentra_agent_id)),
|
||||
action_id: Some(pseudonymous_identifier(record_id)),
|
||||
..CorrelationIds::default()
|
||||
})?
|
||||
.result_code("completed")?
|
||||
.code_field("operation", "forget_memory")?
|
||||
.redacted("memory_content")?,
|
||||
),
|
||||
ControlRequest::GracefulShutdown => Some(
|
||||
EventDraft::new(
|
||||
EventFamily::Shutdown,
|
||||
@@ -659,6 +835,7 @@ fn request_correlation(request: &ControlRequest) -> CorrelationIds {
|
||||
ControlRequest::DecideApproval { approval_id, .. } => {
|
||||
Some(format!("approval-{approval_id}"))
|
||||
}
|
||||
ControlRequest::ForgetMemory { record_id, .. } => Some(record_id.clone()),
|
||||
_ => None,
|
||||
};
|
||||
CorrelationIds {
|
||||
@@ -677,6 +854,12 @@ const fn runtime_request_name(request: &ControlRequest) -> &'static str {
|
||||
ControlRequest::ListPendingApprovals { .. } => "list_pending_approvals",
|
||||
ControlRequest::ListAuditEvents { .. } => "list_audit_events",
|
||||
ControlRequest::ListObservabilityEvents { .. } => "list_observability_events",
|
||||
ControlRequest::MemoryHealth => "memory_health",
|
||||
ControlRequest::ListMemoryAgents { .. } => "list_memory_agents",
|
||||
ControlRequest::BrowseMemories { .. } => "browse_memories",
|
||||
ControlRequest::SearchMemories { .. } => "search_memories",
|
||||
ControlRequest::GetMemory { .. } => "get_memory",
|
||||
ControlRequest::ForgetMemory { .. } => "forget_memory",
|
||||
ControlRequest::SubscribeEvents { .. } => "subscribe_events",
|
||||
ControlRequest::CancelRequest { .. } => "cancel_request",
|
||||
ControlRequest::PauseAutonomy => "pause_autonomy",
|
||||
@@ -753,6 +936,111 @@ const fn policy_outcome_name(outcome: PolicyFinalOutcome) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_admin(
|
||||
slot: &Mutex<Option<Arc<crate::memory_admin::MentraMemoryAdmin>>>,
|
||||
) -> Result<Arc<crate::memory_admin::MentraMemoryAdmin>, ControlError> {
|
||||
lock(slot).clone().ok_or_else(|| {
|
||||
control_error(
|
||||
ControlErrorCode::Conflict,
|
||||
"memory service is not available",
|
||||
true,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn require_operator(context: &ControlContext) -> Result<(), ControlError> {
|
||||
(context.role == crate::control_plane::ControlRole::Operator)
|
||||
.then_some(())
|
||||
.ok_or_else(|| {
|
||||
control_error(
|
||||
ControlErrorCode::PermissionDenied,
|
||||
"memory content requires operator role",
|
||||
false,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn memory_filter(filter: MemoryFilterView) -> mentra::memory::MemoryListFilter {
|
||||
mentra::memory::MemoryListFilter {
|
||||
kind: filter.kind.map(|kind| match kind {
|
||||
MemoryKindView::Episode => mentra::memory::MemoryRecordKind::Episode,
|
||||
MemoryKindView::Summary => mentra::memory::MemoryRecordKind::Summary,
|
||||
MemoryKindView::Fact => mentra::memory::MemoryRecordKind::Fact,
|
||||
}),
|
||||
pinned: filter.pinned,
|
||||
source: filter.source,
|
||||
created_from: filter.created_from,
|
||||
created_to: filter.created_to,
|
||||
}
|
||||
}
|
||||
|
||||
fn memory_sort(sort: MemorySortView) -> Result<mentra::memory::MemoryListSort, ControlError> {
|
||||
match sort {
|
||||
MemorySortView::Newest => Ok(mentra::memory::MemoryListSort::Newest),
|
||||
MemorySortView::Oldest => Ok(mentra::memory::MemoryListSort::Oldest),
|
||||
MemorySortView::Relevance => Err(control_error(
|
||||
ControlErrorCode::InvalidRequest,
|
||||
"relevance sort requires a search query",
|
||||
false,
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn record_matches(record: &mentra::memory::MemoryRecord, filter: &MemoryFilterView) -> bool {
|
||||
filter.kind.is_none_or(|kind| {
|
||||
matches!(
|
||||
(kind, record.kind),
|
||||
(
|
||||
MemoryKindView::Episode,
|
||||
mentra::memory::MemoryRecordKind::Episode
|
||||
) | (
|
||||
MemoryKindView::Summary,
|
||||
mentra::memory::MemoryRecordKind::Summary
|
||||
) | (MemoryKindView::Fact, mentra::memory::MemoryRecordKind::Fact)
|
||||
)
|
||||
}) && filter.pinned.is_none_or(|pinned| record.pinned == pinned)
|
||||
&& filter
|
||||
.source
|
||||
.as_ref()
|
||||
.is_none_or(|source| record.source.as_ref() == Some(source))
|
||||
&& filter
|
||||
.created_from
|
||||
.is_none_or(|from| record.created_at >= from)
|
||||
&& filter.created_to.is_none_or(|to| record.created_at <= to)
|
||||
}
|
||||
|
||||
fn memory_record_view(record: mentra::memory::MemoryRecord) -> MemoryRecordView {
|
||||
MemoryRecordView {
|
||||
record_id: record.record_id,
|
||||
kind: match record.kind {
|
||||
mentra::memory::MemoryRecordKind::Episode => MemoryKindView::Episode,
|
||||
mentra::memory::MemoryRecordKind::Summary => MemoryKindView::Summary,
|
||||
mentra::memory::MemoryRecordKind::Fact => MemoryKindView::Fact,
|
||||
},
|
||||
preview: truncate_utf8(&record.content, 160),
|
||||
source: record.source.map(|source| truncate_utf8(&source, 128)),
|
||||
source_revision: record.source_revision,
|
||||
pinned: record.pinned,
|
||||
created_at: record.created_at,
|
||||
score: record.score,
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_utf8(value: &str, maximum: usize) -> String {
|
||||
if value.len() <= maximum {
|
||||
return value.to_owned();
|
||||
}
|
||||
let mut end = maximum;
|
||||
while !value.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
value[..end].to_owned()
|
||||
}
|
||||
|
||||
fn memory_error(_: String) -> ControlError {
|
||||
control_error(ControlErrorCode::Internal, "memory operation failed", false)
|
||||
}
|
||||
|
||||
fn unix_seconds() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::behavior::{BehaviorController, BehaviorError, EmbodimentFuture, EmbodimentSink};
|
||||
use crate::control_plane::{
|
||||
CONTROL_PROTOCOL_VERSION, ControlLimits, ControlPayload, ControlPlane, ControlRequest,
|
||||
ControlRequestEnvelope, PageRequest,
|
||||
ControlRequestEnvelope, MemoryFilterView, MemorySortView, PageRequest,
|
||||
};
|
||||
use crate::control_runtime::{AgentControlTarget, RuntimeControlCommand};
|
||||
use crate::conversation::{ConversationLimits, ConversationStore};
|
||||
@@ -12,6 +12,7 @@ use crate::{
|
||||
};
|
||||
use libremetaverse_types::UUID;
|
||||
use libremetaverse_types::compat::CancellationToken;
|
||||
use mentra::memory::{MemoryRecord, MemoryRecordKind, MemoryStore};
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
@@ -152,6 +153,31 @@ async fn production_target_projects_state_and_routes_real_mutations() {
|
||||
target.attach_observability(observability);
|
||||
let vision = Arc::new(FakeVision(Mutex::new(Some("visual-1".into()))));
|
||||
target.attach_vision_control(vision.clone());
|
||||
let memory_root =
|
||||
std::env::temp_dir().join(format!("metacrate-control-memory-{}", std::process::id()));
|
||||
let memory_store = mentra::runtime::HybridRuntimeStore::new(memory_root.join("runtime.sqlite"));
|
||||
memory_store
|
||||
.upsert_records(&[MemoryRecord {
|
||||
record_id: "fact:operator".into(),
|
||||
agent_id: "mentra-agent".into(),
|
||||
kind: MemoryRecordKind::Fact,
|
||||
content: format!("operator-only durable detail {}", "x".repeat(100_000)),
|
||||
source_revision: 1,
|
||||
created_at: 1,
|
||||
metadata_json: "{}".into(),
|
||||
source: Some("manual".into()),
|
||||
pinned: true,
|
||||
score: None,
|
||||
}])
|
||||
.expect("seed memory");
|
||||
let memory = Arc::new(
|
||||
crate::MentraMemoryAdmin::new(memory_store, memory_root.join("memory-agents.json"))
|
||||
.expect("memory admin"),
|
||||
);
|
||||
memory
|
||||
.register("avatar-id", "logical-agent", "mentra-agent")
|
||||
.expect("mapping");
|
||||
target.attach_memory_control(memory);
|
||||
target.update_region(
|
||||
Some("00000000-0000-4000-8000-000000000001".into()),
|
||||
Some(9_007_199_254_740_992),
|
||||
@@ -200,6 +226,81 @@ async fn production_target_projects_state_and_routes_real_mutations() {
|
||||
assert!(vision.active_capture().is_none(), "pause cancels capture");
|
||||
|
||||
let observer = plane.connect("observer-token").expect("observer");
|
||||
let health = observer
|
||||
.request(envelope("memory-health", ControlRequest::MemoryHealth))
|
||||
.await;
|
||||
assert!(matches!(health.result, Ok(ControlPayload::MemoryHealth(_))));
|
||||
let denied_memory = observer
|
||||
.request(envelope(
|
||||
"memory-denied",
|
||||
ControlRequest::BrowseMemories {
|
||||
mentra_agent_id: "mentra-agent".into(),
|
||||
cursor: None,
|
||||
limit: 10,
|
||||
filter: MemoryFilterView::default(),
|
||||
sort: MemorySortView::Newest,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
assert!(denied_memory.result.is_err());
|
||||
let memories = operator
|
||||
.request(envelope(
|
||||
"memory-browse",
|
||||
ControlRequest::BrowseMemories {
|
||||
mentra_agent_id: "mentra-agent".into(),
|
||||
cursor: None,
|
||||
limit: 10,
|
||||
filter: MemoryFilterView::default(),
|
||||
sort: MemorySortView::Newest,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
let Ok(ControlPayload::Memories(memories)) = memories.result else {
|
||||
panic!("operator memory browse")
|
||||
};
|
||||
assert_eq!(memories.items.len(), 1);
|
||||
assert!(memories.items[0].preview.contains("durable detail"));
|
||||
for (id, query) in [("memory-empty", ""), ("memory-punctuation", "(durable)!!!")] {
|
||||
let search = operator
|
||||
.request(envelope(
|
||||
id,
|
||||
ControlRequest::SearchMemories {
|
||||
mentra_agent_id: "mentra-agent".into(),
|
||||
query: query.into(),
|
||||
page: PageRequest {
|
||||
cursor: None,
|
||||
limit: 10,
|
||||
},
|
||||
filter: MemoryFilterView {
|
||||
source: Some("manual".into()),
|
||||
..MemoryFilterView::default()
|
||||
},
|
||||
sort: if query.is_empty() {
|
||||
MemorySortView::Newest
|
||||
} else {
|
||||
MemorySortView::Relevance
|
||||
},
|
||||
},
|
||||
))
|
||||
.await;
|
||||
let Ok(ControlPayload::Memories(search)) = search.result else {
|
||||
panic!("memory search")
|
||||
};
|
||||
assert_eq!(search.items.len(), 1);
|
||||
}
|
||||
let detail = operator
|
||||
.request(envelope(
|
||||
"memory-detail",
|
||||
ControlRequest::GetMemory {
|
||||
mentra_agent_id: "mentra-agent".into(),
|
||||
record_id: "fact:operator".into(),
|
||||
},
|
||||
))
|
||||
.await;
|
||||
let Ok(ControlPayload::MemoryDetail(detail)) = detail.result else {
|
||||
panic!("bounded memory detail")
|
||||
};
|
||||
assert_eq!(detail.content.len(), 24 * 1024);
|
||||
let denied = observer
|
||||
.request(envelope("shutdown", ControlRequest::GracefulShutdown))
|
||||
.await;
|
||||
|
||||
@@ -1779,7 +1779,8 @@ pub struct PolicyLlmResponder {
|
||||
client: Arc<crate::llm::LlmClient>,
|
||||
approval_reviewer: Arc<AutonomousApprovalReviewer>,
|
||||
runtime: Arc<mentra::Runtime>,
|
||||
agents: Mutex<BTreeMap<String, Arc<tokio::sync::Mutex<mentra::Agent>>>>,
|
||||
agents: Mutex<BTreeMap<String, MentraAgentHandle>>,
|
||||
memory_admin: Arc<crate::memory_admin::MentraMemoryAdmin>,
|
||||
active_tools: Arc<Mutex<BTreeMap<String, ActiveMentraRequest>>>,
|
||||
gateway: Arc<crate::policy::PolicyGateway>,
|
||||
backend: Arc<dyn crate::backend::AuthorizedToolBackend>,
|
||||
@@ -1788,6 +1789,12 @@ pub struct PolicyLlmResponder {
|
||||
storage_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct MentraAgentHandle {
|
||||
id: String,
|
||||
agent: Arc<tokio::sync::Mutex<mentra::Agent>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ActiveMentraRequest {
|
||||
executor: Arc<crate::policy::PolicyToolExecutor>,
|
||||
@@ -1994,11 +2001,17 @@ impl PolicyLlmResponder {
|
||||
let active_tools = Arc::new(Mutex::new(BTreeMap::new()));
|
||||
std::fs::create_dir_all(&storage_path)
|
||||
.map_err(|error| InteractionError::Persistence(error.to_string()))?;
|
||||
let store = mentra::runtime::HybridRuntimeStore::new(storage_path.join("runtime.sqlite"));
|
||||
let memory_admin = Arc::new(
|
||||
crate::memory_admin::MentraMemoryAdmin::new(
|
||||
store.clone(),
|
||||
storage_path.join("memory-agents.json"),
|
||||
)
|
||||
.map_err(InteractionError::Persistence)?,
|
||||
);
|
||||
let mut builder = mentra::Runtime::builder()
|
||||
.with_runtime_identifier(MENTRA_RUNTIME_IDENTIFIER)
|
||||
.with_store(mentra::runtime::HybridRuntimeStore::new(
|
||||
storage_path.join("runtime.sqlite"),
|
||||
))
|
||||
.with_store(store)
|
||||
.with_registered_provider(client.mentra_provider());
|
||||
for definition in gateway.registered_tool_definitions() {
|
||||
builder = builder.with_tool(MentraGridTool {
|
||||
@@ -2015,9 +2028,13 @@ impl PolicyLlmResponder {
|
||||
.into_iter()
|
||||
.filter(|agent| agent.name().starts_with(MENTRA_AGENT_PREFIX))
|
||||
.map(|agent| {
|
||||
let id = agent.id().to_owned();
|
||||
(
|
||||
agent.name().to_owned(),
|
||||
Arc::new(tokio::sync::Mutex::new(agent)),
|
||||
MentraAgentHandle {
|
||||
id,
|
||||
agent: Arc::new(tokio::sync::Mutex::new(agent)),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -2029,6 +2046,7 @@ impl PolicyLlmResponder {
|
||||
client,
|
||||
runtime: Arc::new(runtime),
|
||||
agents: Mutex::new(agents),
|
||||
memory_admin,
|
||||
active_tools,
|
||||
gateway,
|
||||
backend,
|
||||
@@ -2048,8 +2066,13 @@ impl PolicyLlmResponder {
|
||||
.agents
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if let Some(agent) = agents.get(&key) {
|
||||
return Ok(Arc::clone(agent));
|
||||
if let Some(handle) = agents.get(&key) {
|
||||
if request.origin == InteractionOrigin::AuthorizedIm {
|
||||
self.memory_admin
|
||||
.register(&request.sender_id.to_string(), &key, &handle.id)
|
||||
.map_err(|_| InteractionModelError::Failed)?;
|
||||
}
|
||||
return Ok(Arc::clone(&handle.agent));
|
||||
}
|
||||
let tools = self.gateway.tools_for(context, (self.now)());
|
||||
let mut tool_names = tools
|
||||
@@ -2081,10 +2104,27 @@ impl PolicyLlmResponder {
|
||||
.runtime
|
||||
.spawn_with_config(key.clone(), model, config)
|
||||
.map_err(|_| InteractionModelError::Failed)?;
|
||||
let id = agent.id().to_owned();
|
||||
let agent = Arc::new(tokio::sync::Mutex::new(agent));
|
||||
agents.insert(key, Arc::clone(&agent));
|
||||
agents.insert(
|
||||
key.clone(),
|
||||
MentraAgentHandle {
|
||||
id: id.clone(),
|
||||
agent: Arc::clone(&agent),
|
||||
},
|
||||
);
|
||||
if request.origin == InteractionOrigin::AuthorizedIm {
|
||||
self.memory_admin
|
||||
.register(&request.sender_id.to_string(), &key, &id)
|
||||
.map_err(|_| InteractionModelError::Failed)?;
|
||||
}
|
||||
Ok(agent)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn memory_admin(&self) -> Arc<crate::memory_admin::MentraMemoryAdmin> {
|
||||
Arc::clone(&self.memory_admin)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn mentra_agent_name(request: &ResponseRequest) -> String {
|
||||
|
||||
@@ -15,6 +15,7 @@ pub mod conversation;
|
||||
pub mod interaction;
|
||||
pub mod landmarks;
|
||||
pub mod llm;
|
||||
pub mod memory_admin;
|
||||
pub mod observability;
|
||||
pub mod perception;
|
||||
pub mod policy;
|
||||
@@ -96,8 +97,10 @@ pub use control_plane::{
|
||||
ControlErrorCode, ControlEvent, ControlEventKind, ControlFuture, ControlLimits, ControlPayload,
|
||||
ControlPlane, ControlRequest, ControlRequestEnvelope, ControlResponseEnvelope, ControlRole,
|
||||
ControlSubscription, ControlTarget, ConversationChannelView, HealthView,
|
||||
InProcessControlClient, Page, PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView,
|
||||
SessionMetadataView, TcpControlClient, TcpControlConfig, TcpControlServer,
|
||||
InProcessControlClient, MemoryAgentView, MemoryCursorView, MemoryDetailView, MemoryFilterView,
|
||||
MemoryHealthView, MemoryKindView, MemoryPageView, MemoryRecordView, MemorySortView, Page,
|
||||
PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView, SessionMetadataView,
|
||||
TcpControlClient, TcpControlConfig, TcpControlServer,
|
||||
};
|
||||
pub use control_runtime::{AgentControlTarget, RuntimeControlCommand};
|
||||
pub use conversation::{
|
||||
@@ -130,6 +133,7 @@ pub use landmarks::{
|
||||
pub use llm::{
|
||||
CompletionMessage, ContentPart, ImageDetail, LlmClient, LlmError, ToolDefinition, ToolSchema,
|
||||
};
|
||||
pub use memory_admin::{MemoryAgentMapping, MentraMemoryAdmin};
|
||||
pub use observability::{
|
||||
AgentMetrics, CorrelationIds, DiagnosticDirection, EventDraft, EventFamily, EventOrigin,
|
||||
EventSeverity, EventSubscription, JournalConfig, LatencyMetrics, MetricsSnapshot,
|
||||
|
||||
@@ -415,6 +415,7 @@ async fn run_live(
|
||||
control_target.attach_observability(live.observability.clone());
|
||||
control_target.attach_build_control(live.build_control.clone());
|
||||
control_target.attach_vision_control(live.vision.clone());
|
||||
control_target.attach_memory_control(live.memory_admin.clone());
|
||||
control_target.update_session(handle.status());
|
||||
let erased_target: Arc<dyn ControlTarget> = control_target.clone();
|
||||
let (control_plane, integrated_client, control_server) = match config.mode {
|
||||
@@ -732,6 +733,7 @@ struct LiveInteractions {
|
||||
policy: Arc<metacrate_grid_agent::PolicyGateway>,
|
||||
audit: Arc<metacrate_grid_agent::MemoryPolicyAudit>,
|
||||
observability: Arc<metacrate_grid_agent::Observability>,
|
||||
memory_admin: Arc<metacrate_grid_agent::MentraMemoryAdmin>,
|
||||
_appearance_recovery: libremetaverse_types::compat::Subscription,
|
||||
_landmark_intake: metacrate_grid_agent::LibremetaverseLandmarkIntake,
|
||||
landmark_roaming: metacrate_grid_agent::LandmarkRoamingHandle,
|
||||
@@ -903,6 +905,7 @@ async fn start_live_interactions(
|
||||
now,
|
||||
config.storage_path.join("mentra"),
|
||||
)?);
|
||||
let memory_admin = responder.memory_admin();
|
||||
let vision_limits = config.vision;
|
||||
let scene_source = Arc::new(LibremetaverseSceneSource::new(owner, vision_limits));
|
||||
scene_source.start_prefetch();
|
||||
@@ -936,6 +939,7 @@ async fn start_live_interactions(
|
||||
policy: gateway,
|
||||
audit,
|
||||
observability,
|
||||
memory_admin,
|
||||
_appearance_recovery: appearance_recovery,
|
||||
_landmark_intake: landmark_intake,
|
||||
landmark_roaming,
|
||||
|
||||
288
crates/metacrate-grid-agent/src/memory_admin.rs
Normal file
288
crates/metacrate-grid-agent/src/memory_admin.rs
Normal file
@@ -0,0 +1,288 @@
|
||||
//! Operator-only access to durable Mentra memory.
|
||||
|
||||
#![allow(clippy::missing_errors_doc)]
|
||||
|
||||
use mentra::memory::{
|
||||
MemoryListCursor, MemoryListFilter, MemoryListRequest, MemoryListSort, MemoryRecord,
|
||||
MemorySearchMode, MemorySearchRequest, MemoryStore,
|
||||
};
|
||||
use mentra::runtime::HybridRuntimeStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
|
||||
const MAX_MAPPINGS: usize = 4_096;
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MemoryAgentMapping {
|
||||
pub avatar_id: String,
|
||||
pub logical_agent_id: String,
|
||||
pub mentra_agent_id: String,
|
||||
}
|
||||
|
||||
pub struct MentraMemoryAdmin {
|
||||
store: HybridRuntimeStore,
|
||||
mappings_path: PathBuf,
|
||||
mappings: Mutex<BTreeMap<String, MemoryAgentMapping>>,
|
||||
}
|
||||
|
||||
impl MentraMemoryAdmin {
|
||||
pub fn new(store: HybridRuntimeStore, mappings_path: PathBuf) -> Result<Self, String> {
|
||||
let mappings = load_mappings(&mappings_path)?;
|
||||
Ok(Self {
|
||||
store,
|
||||
mappings_path,
|
||||
mappings: Mutex::new(mappings),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn register(
|
||||
&self,
|
||||
avatar_id: &str,
|
||||
logical_agent_id: &str,
|
||||
mentra_agent_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let mapping = MemoryAgentMapping {
|
||||
avatar_id: avatar_id.to_owned(),
|
||||
logical_agent_id: logical_agent_id.to_owned(),
|
||||
mentra_agent_id: mentra_agent_id.to_owned(),
|
||||
};
|
||||
if !valid_mapping(&mapping) {
|
||||
return Err("invalid memory-agent mapping".to_owned());
|
||||
}
|
||||
let mut mappings = lock(&self.mappings);
|
||||
if !mappings.contains_key(avatar_id) && mappings.len() >= MAX_MAPPINGS {
|
||||
return Err("memory-agent mapping limit reached".to_owned());
|
||||
}
|
||||
if mappings.get(avatar_id).is_some_and(|mapping| {
|
||||
mapping.logical_agent_id == logical_agent_id
|
||||
&& mapping.mentra_agent_id == mentra_agent_id
|
||||
}) {
|
||||
return Ok(());
|
||||
}
|
||||
let previous = mappings.insert(avatar_id.to_owned(), mapping);
|
||||
if let Err(error) = persist_mappings(&self.mappings_path, mappings.values()) {
|
||||
if let Some(previous) = previous {
|
||||
mappings.insert(avatar_id.to_owned(), previous);
|
||||
} else {
|
||||
mappings.remove(avatar_id);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn agents(&self) -> Result<Vec<(MemoryAgentMapping, usize)>, String> {
|
||||
lock(&self.mappings)
|
||||
.values()
|
||||
.map(|mapping| {
|
||||
self.store
|
||||
.count_records(&mapping.mentra_agent_id)
|
||||
.map(|count| (mapping.clone(), count))
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn total_counts(&self) -> Result<(usize, usize), String> {
|
||||
let agents = self.agents()?;
|
||||
Ok((agents.len(), agents.iter().map(|(_, count)| count).sum()))
|
||||
}
|
||||
|
||||
pub fn browse(
|
||||
&self,
|
||||
mentra_agent_id: &str,
|
||||
cursor: Option<MemoryListCursor>,
|
||||
limit: usize,
|
||||
filter: MemoryListFilter,
|
||||
sort: MemoryListSort,
|
||||
) -> Result<(Vec<MemoryRecord>, Option<MemoryListCursor>), String> {
|
||||
self.require_agent(mentra_agent_id)?;
|
||||
self.store
|
||||
.list_records(&MemoryListRequest {
|
||||
agent_id: mentra_agent_id.to_owned(),
|
||||
cursor,
|
||||
limit,
|
||||
filter,
|
||||
sort,
|
||||
})
|
||||
.map(|page| (page.records, page.next_cursor))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn search(
|
||||
&self,
|
||||
mentra_agent_id: &str,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
filter: MemoryListFilter,
|
||||
) -> Result<Vec<MemoryRecord>, String> {
|
||||
self.require_agent(mentra_agent_id)?;
|
||||
self.store
|
||||
.search_records_with_options(&MemorySearchRequest {
|
||||
agent_id: mentra_agent_id.to_owned(),
|
||||
query: query.to_owned(),
|
||||
limit,
|
||||
char_budget: None,
|
||||
mode: MemorySearchMode::Tool,
|
||||
filter,
|
||||
})
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn detail(
|
||||
&self,
|
||||
mentra_agent_id: &str,
|
||||
record_id: &str,
|
||||
) -> Result<Option<MemoryRecord>, String> {
|
||||
self.require_agent(mentra_agent_id)?;
|
||||
self.store
|
||||
.get_record(mentra_agent_id, record_id)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
pub fn forget(&self, mentra_agent_id: &str, record_id: &str) -> Result<bool, String> {
|
||||
self.require_agent(mentra_agent_id)?;
|
||||
self.store
|
||||
.tombstone_records(mentra_agent_id, &[record_id.to_owned()])
|
||||
.map(|affected| affected == 1)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn require_agent(&self, mentra_agent_id: &str) -> Result<(), String> {
|
||||
lock(&self.mappings)
|
||||
.values()
|
||||
.any(|mapping| mapping.mentra_agent_id == mentra_agent_id)
|
||||
.then_some(())
|
||||
.ok_or_else(|| "memory agent not found".to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn load_mappings(path: &Path) -> Result<BTreeMap<String, MemoryAgentMapping>, String> {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||
match std::fs::read(path.with_extension("json.bak")) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(backup) if backup.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
Err(backup) => return Err(backup.to_string()),
|
||||
}
|
||||
}
|
||||
Err(error) => return Err(error.to_string()),
|
||||
};
|
||||
let values: Vec<MemoryAgentMapping> =
|
||||
serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||
if values.len() > MAX_MAPPINGS || values.iter().any(|mapping| !valid_mapping(mapping)) {
|
||||
return Err("invalid or excessive memory-agent mappings".to_owned());
|
||||
}
|
||||
let mut mappings = BTreeMap::new();
|
||||
for mapping in values {
|
||||
if mappings
|
||||
.insert(mapping.avatar_id.clone(), mapping)
|
||||
.is_some()
|
||||
{
|
||||
return Err("duplicate memory-agent mapping".to_owned());
|
||||
}
|
||||
}
|
||||
Ok(mappings)
|
||||
}
|
||||
|
||||
fn valid_mapping(mapping: &MemoryAgentMapping) -> bool {
|
||||
[
|
||||
mapping.avatar_id.as_str(),
|
||||
mapping.logical_agent_id.as_str(),
|
||||
mapping.mentra_agent_id.as_str(),
|
||||
]
|
||||
.iter()
|
||||
.all(|value| !value.is_empty() && value.len() <= 256 && !value.contains('\0'))
|
||||
}
|
||||
|
||||
fn persist_mappings<'a>(
|
||||
path: &Path,
|
||||
mappings: impl Iterator<Item = &'a MemoryAgentMapping>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(&mappings.collect::<Vec<_>>())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let temporary = path.with_extension("json.tmp");
|
||||
let backup = path.with_extension("json.bak");
|
||||
std::fs::write(&temporary, bytes).map_err(|error| error.to_string())?;
|
||||
let had_previous = path.exists();
|
||||
if had_previous {
|
||||
let _ = std::fs::remove_file(&backup);
|
||||
std::fs::rename(path, &backup).map_err(|error| error.to_string())?;
|
||||
}
|
||||
if let Err(error) = std::fs::rename(&temporary, path) {
|
||||
if had_previous {
|
||||
let _ = std::fs::rename(&backup, path);
|
||||
}
|
||||
return Err(error.to_string());
|
||||
}
|
||||
if had_previous {
|
||||
let _ = std::fs::remove_file(backup);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn lock<T>(value: &Mutex<T>) -> MutexGuard<'_, T> {
|
||||
value
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use mentra::memory::{MemoryRecordKind, MemoryStore};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn mapping_persists_and_cross_agent_forget_fails_closed() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"metacrate-memory-admin-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let database = root.join("runtime.sqlite");
|
||||
let mappings = root.join("memory-agents.json");
|
||||
let store = HybridRuntimeStore::new(&database);
|
||||
store
|
||||
.upsert_records(&[record("fact:a", "mentra-a"), record("fact:b", "mentra-b")])
|
||||
.unwrap();
|
||||
let admin = MentraMemoryAdmin::new(store.clone(), mappings.clone()).unwrap();
|
||||
admin.register("avatar-a", "logical-a", "mentra-a").unwrap();
|
||||
admin.register("avatar-b", "logical-b", "mentra-b").unwrap();
|
||||
assert!(!admin.forget("mentra-a", "fact:b").unwrap());
|
||||
assert!(admin.detail("mentra-b", "fact:b").unwrap().is_some());
|
||||
drop(admin);
|
||||
|
||||
let reopened = MentraMemoryAdmin::new(store, mappings).unwrap();
|
||||
assert_eq!(reopened.agents().unwrap().len(), 2);
|
||||
assert!(reopened.forget("mentra-a", "fact:a").unwrap());
|
||||
assert!(reopened.detail("mentra-a", "fact:a").unwrap().is_none());
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
fn record(id: &str, agent_id: &str) -> MemoryRecord {
|
||||
MemoryRecord {
|
||||
record_id: id.to_owned(),
|
||||
agent_id: agent_id.to_owned(),
|
||||
kind: MemoryRecordKind::Fact,
|
||||
content: "durable memory".to_owned(),
|
||||
source_revision: 1,
|
||||
created_at: 1,
|
||||
metadata_json: "{}".to_owned(),
|
||||
source: Some("manual".to_owned()),
|
||||
pinned: true,
|
||||
score: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,9 @@
|
||||
use crate::control_plane::{
|
||||
AuditEventView, CONTROL_PROTOCOL_VERSION, ControlEvent, ControlEventKind, ControlPayload,
|
||||
ControlRequest, ControlRequestEnvelope, ControlResponseEnvelope, HealthView,
|
||||
InProcessControlClient, Page, PageRequest, PendingApprovalView, RuntimeView, ScheduledJobView,
|
||||
SessionMetadataView, TcpControlClient,
|
||||
InProcessControlClient, MemoryAgentView, MemoryCursorView, MemoryDetailView, MemoryFilterView,
|
||||
MemoryHealthView, MemoryKindView, MemoryRecordView, MemorySortView, Page, PageRequest,
|
||||
PendingApprovalView, RuntimeView, ScheduledJobView, SessionMetadataView, TcpControlClient,
|
||||
};
|
||||
use crate::observability::{EventSeverity, MetricsSnapshot, StructuredEvent};
|
||||
use crate::{AgentPreferences, ControlLimits, OperatingMode, PreferencesSummary, SecretString};
|
||||
@@ -42,6 +43,7 @@ const PAGE_LIMIT: u16 = 100;
|
||||
pub enum OperatorScreen {
|
||||
Overview,
|
||||
Sessions,
|
||||
Memories,
|
||||
QueuesAndBudgets,
|
||||
Roaming,
|
||||
Approvals,
|
||||
@@ -53,9 +55,10 @@ pub enum OperatorScreen {
|
||||
}
|
||||
|
||||
impl OperatorScreen {
|
||||
const ALL: [Self; 10] = [
|
||||
const ALL: [Self; 11] = [
|
||||
Self::Overview,
|
||||
Self::Sessions,
|
||||
Self::Memories,
|
||||
Self::QueuesAndBudgets,
|
||||
Self::Roaming,
|
||||
Self::Approvals,
|
||||
@@ -71,6 +74,7 @@ impl OperatorScreen {
|
||||
match self {
|
||||
Self::Overview => "Overview",
|
||||
Self::Sessions => "Sessions",
|
||||
Self::Memories => "Memory",
|
||||
Self::QueuesAndBudgets => "Queues & budgets",
|
||||
Self::Roaming => "Roaming",
|
||||
Self::Approvals => "Approvals",
|
||||
@@ -337,6 +341,10 @@ pub struct OperatorSnapshot {
|
||||
pub sessions: Vec<SessionMetadataView>,
|
||||
pub schedules: Vec<ScheduledJobView>,
|
||||
pub approvals: Vec<PendingApprovalView>,
|
||||
pub memory_health: Option<MemoryHealthView>,
|
||||
pub memory_agents: Vec<MemoryAgentView>,
|
||||
pub memories: Vec<MemoryRecordView>,
|
||||
pub memory_detail: Option<MemoryDetailView>,
|
||||
pub audit: Vec<AuditEventView>,
|
||||
pub timeline: VecDeque<StructuredEvent>,
|
||||
pub gap_count: u64,
|
||||
@@ -352,6 +360,10 @@ impl Default for OperatorSnapshot {
|
||||
sessions: Vec::new(),
|
||||
schedules: Vec::new(),
|
||||
approvals: Vec::new(),
|
||||
memory_health: None,
|
||||
memory_agents: Vec::new(),
|
||||
memories: Vec::new(),
|
||||
memory_detail: None,
|
||||
audit: Vec::new(),
|
||||
timeline: VecDeque::with_capacity(MAX_TIMELINE),
|
||||
gap_count: 0,
|
||||
@@ -376,10 +388,23 @@ pub enum OperatorCommand {
|
||||
Pause,
|
||||
Resume,
|
||||
CancelAction(String),
|
||||
DecideApproval { id: u64, approve: bool },
|
||||
DecideApproval {
|
||||
id: u64,
|
||||
approve: bool,
|
||||
},
|
||||
Reconnect,
|
||||
ExpireSession { avatar_id: String, direct_im: bool },
|
||||
ToggleSchedule { job_id: String, enabled: bool },
|
||||
ExpireSession {
|
||||
avatar_id: String,
|
||||
direct_im: bool,
|
||||
},
|
||||
ToggleSchedule {
|
||||
job_id: String,
|
||||
enabled: bool,
|
||||
},
|
||||
ForgetMemory {
|
||||
mentra_agent_id: String,
|
||||
record_id: String,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
@@ -411,6 +436,13 @@ impl OperatorCommand {
|
||||
job_id: job_id.clone(),
|
||||
enabled: *enabled,
|
||||
},
|
||||
Self::ForgetMemory {
|
||||
mentra_agent_id,
|
||||
record_id,
|
||||
} => ControlRequest::ForgetMemory {
|
||||
mentra_agent_id: mentra_agent_id.clone(),
|
||||
record_id: record_id.clone(),
|
||||
},
|
||||
Self::Shutdown => ControlRequest::GracefulShutdown,
|
||||
}
|
||||
}
|
||||
@@ -424,7 +456,7 @@ impl OperatorCommand {
|
||||
| Self::Reconnect
|
||||
| Self::ExpireSession { .. }
|
||||
| Self::ToggleSchedule { .. } => CommandConfirmation::Normal,
|
||||
Self::Shutdown => CommandConfirmation::HighRisk,
|
||||
Self::ForgetMemory { .. } | Self::Shutdown => CommandConfirmation::HighRisk,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -459,13 +491,21 @@ pub enum TuiInput {
|
||||
FilterBackspace,
|
||||
FilterCommit,
|
||||
FilterCancel,
|
||||
MemoryLoad,
|
||||
MemoryNextAgent,
|
||||
MemoryNextPage,
|
||||
MemoryPreviousPage,
|
||||
MemoryCycleKind,
|
||||
MemoryTogglePinned,
|
||||
MemoryCycleSort,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum TuiAction {
|
||||
None,
|
||||
Refresh,
|
||||
Execute(OperatorCommand),
|
||||
Request(ControlRequest),
|
||||
Exit,
|
||||
}
|
||||
|
||||
@@ -605,6 +645,14 @@ pub struct OperatorTui {
|
||||
cadence_millis: ValueCell<u64>,
|
||||
last_frame: ValueCell<Option<Instant>>,
|
||||
filter_editing: bool,
|
||||
memory_agent: usize,
|
||||
memory_filter: MemoryFilterView,
|
||||
memory_sort: MemorySortView,
|
||||
memory_next_cursor: Option<MemoryCursorView>,
|
||||
memory_cursor_stack: Vec<Option<MemoryCursorView>>,
|
||||
memory_current_offset: u64,
|
||||
memory_next_offset: Option<u64>,
|
||||
memory_refresh_pending: bool,
|
||||
}
|
||||
|
||||
impl Default for OperatorTui {
|
||||
@@ -627,6 +675,14 @@ impl Default for OperatorTui {
|
||||
cadence_millis: ValueCell::new(0),
|
||||
last_frame: ValueCell::new(None),
|
||||
filter_editing: false,
|
||||
memory_agent: 0,
|
||||
memory_filter: MemoryFilterView::default(),
|
||||
memory_sort: MemorySortView::Newest,
|
||||
memory_next_cursor: None,
|
||||
memory_cursor_stack: Vec::new(),
|
||||
memory_current_offset: 0,
|
||||
memory_next_offset: None,
|
||||
memory_refresh_pending: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -647,9 +703,59 @@ impl OperatorTui {
|
||||
self.selected[Self::screen_index(self.screen)]
|
||||
}
|
||||
|
||||
fn memory_request(&self, cursor: Option<MemoryCursorView>) -> Option<ControlRequest> {
|
||||
let agent = self.snapshot.memory_agents.get(self.memory_agent)?;
|
||||
if self.filter.query.trim().is_empty() {
|
||||
Some(ControlRequest::BrowseMemories {
|
||||
mentra_agent_id: agent.mentra_agent_id.clone(),
|
||||
cursor,
|
||||
limit: 50,
|
||||
filter: self.memory_filter.clone(),
|
||||
sort: if self.memory_sort == MemorySortView::Relevance {
|
||||
MemorySortView::Newest
|
||||
} else {
|
||||
self.memory_sort
|
||||
},
|
||||
})
|
||||
} else {
|
||||
Some(ControlRequest::SearchMemories {
|
||||
mentra_agent_id: agent.mentra_agent_id.clone(),
|
||||
query: self.filter.query.clone(),
|
||||
page: PageRequest {
|
||||
cursor: (self.memory_current_offset != 0).then_some(self.memory_current_offset),
|
||||
limit: 50,
|
||||
},
|
||||
filter: self.memory_filter.clone(),
|
||||
sort: self.memory_sort,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn current_memory_request(&self) -> Option<ControlRequest> {
|
||||
let cursor = self.memory_cursor_stack.last().cloned().flatten();
|
||||
self.memory_request(cursor)
|
||||
}
|
||||
|
||||
fn parse_memory_filter(&mut self) {
|
||||
let mut query = Vec::new();
|
||||
for token in self.filter.query.split_whitespace() {
|
||||
if let Some(value) = token.strip_prefix("source:") {
|
||||
self.memory_filter.source = (!value.is_empty()).then(|| value.to_owned());
|
||||
} else if let Some(value) = token.strip_prefix("from:") {
|
||||
self.memory_filter.created_from = value.parse().ok();
|
||||
} else if let Some(value) = token.strip_prefix("to:") {
|
||||
self.memory_filter.created_to = value.parse().ok();
|
||||
} else {
|
||||
query.push(token);
|
||||
}
|
||||
}
|
||||
self.filter.query = query.join(" ");
|
||||
}
|
||||
|
||||
fn visible_rows(&self) -> usize {
|
||||
match self.screen {
|
||||
OperatorScreen::Sessions => self.snapshot.sessions.len(),
|
||||
OperatorScreen::Memories => self.snapshot.memories.len(),
|
||||
OperatorScreen::Roaming => self.snapshot.schedules.len(),
|
||||
OperatorScreen::Approvals => self.snapshot.approvals.len(),
|
||||
OperatorScreen::Timeline => {
|
||||
@@ -687,6 +793,14 @@ impl OperatorTui {
|
||||
|
||||
#[must_use]
|
||||
pub fn command_shortcut(&self, key: char) -> Option<OperatorCommand> {
|
||||
if self.screen == OperatorScreen::Memories && key == 'd' {
|
||||
let agent = self.snapshot.memory_agents.get(self.memory_agent)?;
|
||||
let record = self.snapshot.memories.get(self.selected_row())?;
|
||||
return Some(OperatorCommand::ForgetMemory {
|
||||
mentra_agent_id: agent.mentra_agent_id.clone(),
|
||||
record_id: record.record_id.clone(),
|
||||
});
|
||||
}
|
||||
match key {
|
||||
'p' => Some(OperatorCommand::Pause),
|
||||
'u' => Some(OperatorCommand::Resume),
|
||||
@@ -737,7 +851,11 @@ impl OperatorTui {
|
||||
.unwrap_or(0);
|
||||
self.screen = OperatorScreen::ALL[(i + 1) % OperatorScreen::ALL.len()];
|
||||
self.scroll = 0;
|
||||
TuiAction::None
|
||||
if self.screen == OperatorScreen::Memories {
|
||||
TuiAction::Request(ControlRequest::ListMemoryAgents { page: page() })
|
||||
} else {
|
||||
TuiAction::None
|
||||
}
|
||||
}
|
||||
TuiInput::PreviousScreen => {
|
||||
let i = OperatorScreen::ALL
|
||||
@@ -747,7 +865,11 @@ impl OperatorTui {
|
||||
self.screen = OperatorScreen::ALL
|
||||
[(i + OperatorScreen::ALL.len() - 1) % OperatorScreen::ALL.len()];
|
||||
self.scroll = 0;
|
||||
TuiAction::None
|
||||
if self.screen == OperatorScreen::Memories {
|
||||
TuiAction::Request(ControlRequest::ListMemoryAgents { page: page() })
|
||||
} else {
|
||||
TuiAction::None
|
||||
}
|
||||
}
|
||||
TuiInput::ScrollUp => {
|
||||
let index = Self::screen_index(self.screen);
|
||||
@@ -821,7 +943,11 @@ impl OperatorTui {
|
||||
}
|
||||
TuiInput::BeginFilter => {
|
||||
self.filter_editing = true;
|
||||
self.status = "event/audit filter: type query, Enter applies, Esc clears".into();
|
||||
self.status = if self.screen == OperatorScreen::Memories {
|
||||
"memory search/filter: type query, Enter applies, Esc clears".into()
|
||||
} else {
|
||||
"event/audit filter: type query, Enter applies, Esc clears".into()
|
||||
};
|
||||
TuiAction::None
|
||||
}
|
||||
TuiInput::FilterCharacter(value) => {
|
||||
@@ -840,14 +966,122 @@ impl OperatorTui {
|
||||
self.filter_editing = false;
|
||||
self.selected[Self::screen_index(self.screen)] = 0;
|
||||
self.status = format!("filter applied: {}", self.filter.query);
|
||||
TuiAction::None
|
||||
if self.screen == OperatorScreen::Memories {
|
||||
self.parse_memory_filter();
|
||||
self.memory_cursor_stack.clear();
|
||||
self.memory_current_offset = 0;
|
||||
self.memory_next_offset = None;
|
||||
self.memory_request(None)
|
||||
.map_or(TuiAction::None, TuiAction::Request)
|
||||
} else {
|
||||
TuiAction::None
|
||||
}
|
||||
}
|
||||
TuiInput::FilterCancel => {
|
||||
self.filter_editing = false;
|
||||
self.filter.query.clear();
|
||||
self.status = "filter cleared".into();
|
||||
if self.screen == OperatorScreen::Memories {
|
||||
self.memory_current_offset = 0;
|
||||
self.memory_sort = match self.memory_sort {
|
||||
MemorySortView::Relevance => MemorySortView::Newest,
|
||||
sort => sort,
|
||||
};
|
||||
self.memory_request(None)
|
||||
.map_or(TuiAction::None, TuiAction::Request)
|
||||
} else {
|
||||
TuiAction::None
|
||||
}
|
||||
}
|
||||
TuiInput::MemoryLoad => {
|
||||
if let (Some(agent), Some(record)) = (
|
||||
self.snapshot.memory_agents.get(self.memory_agent),
|
||||
self.snapshot.memories.get(self.selected_row()),
|
||||
) {
|
||||
TuiAction::Request(ControlRequest::GetMemory {
|
||||
mentra_agent_id: agent.mentra_agent_id.clone(),
|
||||
record_id: record.record_id.clone(),
|
||||
})
|
||||
} else {
|
||||
self.memory_request(None)
|
||||
.map_or(TuiAction::None, TuiAction::Request)
|
||||
}
|
||||
}
|
||||
TuiInput::MemoryNextAgent => {
|
||||
if !self.snapshot.memory_agents.is_empty() {
|
||||
self.memory_agent = (self.memory_agent + 1) % self.snapshot.memory_agents.len();
|
||||
}
|
||||
self.memory_cursor_stack.clear();
|
||||
self.memory_current_offset = 0;
|
||||
self.memory_next_offset = None;
|
||||
self.memory_request(None)
|
||||
.map_or(TuiAction::None, TuiAction::Request)
|
||||
}
|
||||
TuiInput::MemoryNextPage => {
|
||||
if self.filter.query.trim().is_empty() {
|
||||
if let Some(cursor) = self.memory_next_cursor.clone() {
|
||||
self.memory_cursor_stack.push(Some(cursor.clone()));
|
||||
return self
|
||||
.memory_request(Some(cursor))
|
||||
.map_or(TuiAction::None, TuiAction::Request);
|
||||
}
|
||||
} else if let Some(offset) = self.memory_next_offset {
|
||||
self.memory_current_offset = offset;
|
||||
return self
|
||||
.memory_request(None)
|
||||
.map_or(TuiAction::None, TuiAction::Request);
|
||||
}
|
||||
TuiAction::None
|
||||
}
|
||||
TuiInput::MemoryPreviousPage => {
|
||||
if self.filter.query.trim().is_empty() {
|
||||
self.memory_cursor_stack.pop();
|
||||
let cursor = self.memory_cursor_stack.last().cloned().flatten();
|
||||
self.memory_request(cursor)
|
||||
.map_or(TuiAction::None, TuiAction::Request)
|
||||
} else {
|
||||
self.memory_current_offset = self.memory_current_offset.saturating_sub(50);
|
||||
self.memory_request(None)
|
||||
.map_or(TuiAction::None, TuiAction::Request)
|
||||
}
|
||||
}
|
||||
TuiInput::MemoryCycleKind => {
|
||||
self.memory_filter.kind = match self.memory_filter.kind {
|
||||
None => Some(MemoryKindView::Episode),
|
||||
Some(MemoryKindView::Episode) => Some(MemoryKindView::Summary),
|
||||
Some(MemoryKindView::Summary) => Some(MemoryKindView::Fact),
|
||||
Some(MemoryKindView::Fact) => None,
|
||||
};
|
||||
self.memory_current_offset = 0;
|
||||
self.memory_cursor_stack.clear();
|
||||
self.memory_request(None)
|
||||
.map_or(TuiAction::None, TuiAction::Request)
|
||||
}
|
||||
TuiInput::MemoryTogglePinned => {
|
||||
self.memory_filter.pinned = match self.memory_filter.pinned {
|
||||
None => Some(true),
|
||||
Some(true) => Some(false),
|
||||
Some(false) => None,
|
||||
};
|
||||
self.memory_current_offset = 0;
|
||||
self.memory_cursor_stack.clear();
|
||||
self.memory_request(None)
|
||||
.map_or(TuiAction::None, TuiAction::Request)
|
||||
}
|
||||
TuiInput::MemoryCycleSort => {
|
||||
self.memory_sort = match self.memory_sort {
|
||||
MemorySortView::Newest => MemorySortView::Oldest,
|
||||
MemorySortView::Oldest if self.filter.query.trim().is_empty() => {
|
||||
MemorySortView::Newest
|
||||
}
|
||||
MemorySortView::Oldest => MemorySortView::Relevance,
|
||||
MemorySortView::Relevance => MemorySortView::Newest,
|
||||
};
|
||||
self.memory_current_offset = 0;
|
||||
self.memory_cursor_stack.clear();
|
||||
self.memory_request(None)
|
||||
.map_or(TuiAction::None, TuiAction::Request)
|
||||
}
|
||||
TuiInput::Command(command) if command.confirmation() == CommandConfirmation::None => {
|
||||
TuiAction::Execute(command)
|
||||
}
|
||||
@@ -856,10 +1090,13 @@ impl OperatorTui {
|
||||
self.pending = Some(command);
|
||||
TuiAction::None
|
||||
}
|
||||
TuiInput::Confirm => self
|
||||
.pending
|
||||
.take()
|
||||
.map_or(TuiAction::None, TuiAction::Execute),
|
||||
TuiInput::Confirm => {
|
||||
let command = self.pending.take();
|
||||
self.memory_refresh_pending = command
|
||||
.as_ref()
|
||||
.is_some_and(|command| matches!(command, OperatorCommand::ForgetMemory { .. }));
|
||||
command.map_or(TuiAction::None, TuiAction::Execute)
|
||||
}
|
||||
TuiInput::Reject => {
|
||||
self.pending = None;
|
||||
self.status = "command cancelled".into();
|
||||
@@ -897,6 +1134,7 @@ impl OperatorTui {
|
||||
ControlRequest::Health,
|
||||
ControlRequest::Runtime,
|
||||
ControlRequest::Metrics,
|
||||
ControlRequest::MemoryHealth,
|
||||
ControlRequest::ListSessions { page: page() },
|
||||
ControlRequest::ListScheduledJobs { page: page() },
|
||||
ControlRequest::ListPendingApprovals { page: page() },
|
||||
@@ -957,8 +1195,13 @@ impl OperatorTui {
|
||||
client: &dyn TuiTransport,
|
||||
command: OperatorCommand,
|
||||
) -> Result<(), TuiError> {
|
||||
let refresh_memory = matches!(command, OperatorCommand::ForgetMemory { .. });
|
||||
self.send_and_apply(client, command.request()).await?;
|
||||
self.refresh(client).await
|
||||
self.refresh(client).await?;
|
||||
if refresh_memory && let Some(request) = self.current_memory_request() {
|
||||
self.send_and_apply(client, request).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_and_apply(
|
||||
@@ -990,6 +1233,34 @@ impl OperatorTui {
|
||||
ControlPayload::Health(value) => self.snapshot.health = Some(value),
|
||||
ControlPayload::Metrics(value) => self.snapshot.metrics = Some(value),
|
||||
ControlPayload::Runtime(value) => self.snapshot.runtime = Some(value),
|
||||
ControlPayload::MemoryHealth(value) => self.snapshot.memory_health = Some(value),
|
||||
ControlPayload::MemoryAgents(Page { items, .. }) => {
|
||||
let identity = self
|
||||
.snapshot
|
||||
.memory_agents
|
||||
.get(self.memory_agent)
|
||||
.map(|agent| agent.mentra_agent_id.clone());
|
||||
self.snapshot.memory_agents = items;
|
||||
self.memory_agent = identity
|
||||
.and_then(|id| {
|
||||
self.snapshot
|
||||
.memory_agents
|
||||
.iter()
|
||||
.position(|agent| agent.mentra_agent_id == id)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
.min(self.snapshot.memory_agents.len().saturating_sub(1));
|
||||
}
|
||||
ControlPayload::Memories(page) => {
|
||||
self.snapshot.memories = page.items;
|
||||
self.snapshot.memory_detail = None;
|
||||
self.memory_next_cursor = page.next_cursor;
|
||||
self.memory_next_offset = page.next_offset;
|
||||
self.selected[Self::screen_index(OperatorScreen::Memories)] = 0;
|
||||
}
|
||||
ControlPayload::MemoryDetail(detail) => {
|
||||
self.snapshot.memory_detail = Some(detail);
|
||||
}
|
||||
ControlPayload::Sessions(Page { items, .. }) => {
|
||||
let index = Self::screen_index(OperatorScreen::Sessions);
|
||||
let identity = self
|
||||
@@ -1205,6 +1476,7 @@ impl OperatorTui {
|
||||
match self.screen {
|
||||
OperatorScreen::Overview => self.draw_overview(frame, chunks[1], color),
|
||||
OperatorScreen::Sessions => self.draw_sessions(frame, chunks[1], color),
|
||||
OperatorScreen::Memories => self.draw_memories(frame, chunks[1], color),
|
||||
OperatorScreen::QueuesAndBudgets => self.draw_performance(frame, chunks[1], color),
|
||||
OperatorScreen::Roaming => self.draw_roaming(frame, chunks[1], color),
|
||||
OperatorScreen::Approvals => self.draw_approvals(frame, chunks[1], color),
|
||||
@@ -1320,6 +1592,15 @@ impl OperatorTui {
|
||||
)
|
||||
}
|
||||
OperatorScreen::Sessions => format!("sessions={}", self.snapshot.sessions.len()),
|
||||
OperatorScreen::Memories => self.snapshot.memory_health.as_ref().map_or_else(
|
||||
|| "memory health unavailable".into(),
|
||||
|health| {
|
||||
format!(
|
||||
"memory agents={} records={}",
|
||||
health.agent_count, health.record_count
|
||||
)
|
||||
},
|
||||
),
|
||||
OperatorScreen::QueuesAndBudgets => self.snapshot.metrics.as_ref().map_or_else(
|
||||
|| "metrics unavailable".into(),
|
||||
|metrics| {
|
||||
@@ -1462,6 +1743,79 @@ impl OperatorTui {
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_memories(&self, frame: &mut Frame<'_>, area: Rect, color: bool) {
|
||||
let rows = self
|
||||
.snapshot
|
||||
.memories
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(self.selected_row().saturating_sub(5))
|
||||
.map(|(index, item)| {
|
||||
styled_row(
|
||||
index == self.selected_row(),
|
||||
color,
|
||||
vec![
|
||||
item.record_id.clone(),
|
||||
format!("{:?}", item.kind).to_lowercase(),
|
||||
item.created_at.to_string(),
|
||||
if item.pinned { "yes" } else { "no" }.to_owned(),
|
||||
item.preview.clone(),
|
||||
],
|
||||
)
|
||||
});
|
||||
let agent = self.snapshot.memory_agents.get(self.memory_agent);
|
||||
let detail = self.snapshot.memory_detail.as_ref().map_or_else(
|
||||
|| {
|
||||
vec![
|
||||
Line::raw(agent.map_or_else(
|
||||
|| "No operator-visible memory agents".into(),
|
||||
|agent| format!(
|
||||
"agent: {} avatar={} records={}",
|
||||
agent.logical_agent_id, agent.avatar_id, agent.record_count
|
||||
),
|
||||
)),
|
||||
Line::raw(format!(
|
||||
"query={:?} kind={:?} pinned={:?} sort={:?}",
|
||||
self.filter.query,
|
||||
self.memory_filter.kind,
|
||||
self.memory_filter.pinned,
|
||||
self.memory_sort
|
||||
)),
|
||||
Line::raw("Enter load/detail / search (source:/from:/to:) g agent n/b page k kind i pinned o sort d forget"),
|
||||
]
|
||||
},
|
||||
|detail| {
|
||||
vec![
|
||||
Line::raw(format!("record: {}", detail.record.record_id)),
|
||||
Line::raw(format!(
|
||||
"source: {:?} revision={}",
|
||||
detail.record.source, detail.record.source_revision
|
||||
)),
|
||||
Line::raw(format!(
|
||||
"{}{}",
|
||||
detail.content,
|
||||
if detail.content_truncated { " [truncated]" } else { "" }
|
||||
)),
|
||||
Line::raw(format!(
|
||||
"metadata: {}{}",
|
||||
detail.metadata_json,
|
||||
if detail.metadata_truncated { " [truncated]" } else { "" }
|
||||
)),
|
||||
Line::raw("[d] forget (high-risk confirmation)"),
|
||||
]
|
||||
},
|
||||
);
|
||||
Self::draw_table_detail(
|
||||
frame,
|
||||
area,
|
||||
color,
|
||||
"Mentra durable memory",
|
||||
["Record", "Kind", "Created", "Pinned", "Preview"],
|
||||
rows,
|
||||
detail,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_roaming(&self, frame: &mut Frame<'_>, area: Rect, color: bool) {
|
||||
let rows = self
|
||||
.snapshot
|
||||
@@ -2401,7 +2755,10 @@ pub fn run_preferences_terminal(path: PathBuf, color: bool) -> Result<(), TuiErr
|
||||
};
|
||||
match app.reduce(input) {
|
||||
TuiAction::Exit => return Ok(()),
|
||||
TuiAction::None | TuiAction::Refresh | TuiAction::Execute(_) => {}
|
||||
TuiAction::None
|
||||
| TuiAction::Refresh
|
||||
| TuiAction::Execute(_)
|
||||
| TuiAction::Request(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2458,6 +2815,10 @@ pub async fn run_terminal_with_preferences(
|
||||
work_tx.try_send(WorkerRequest::Command(command)).map_err(|_| TuiError("control worker busy".into()))?;
|
||||
app.status = "control command queued".into();
|
||||
}
|
||||
TuiAction::Request(request) => {
|
||||
work_tx.try_send(WorkerRequest::Request(request)).map_err(|_| TuiError("control worker busy".into()))?;
|
||||
app.status = "memory request queued".into();
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = refresh.tick() => {
|
||||
@@ -2471,6 +2832,12 @@ pub async fn run_terminal_with_preferences(
|
||||
app.refresh_millis = result.elapsed_millis;
|
||||
app.sample_performance();
|
||||
app.status = result.status;
|
||||
if app.memory_refresh_pending {
|
||||
app.memory_refresh_pending = false;
|
||||
if let Some(request) = app.current_memory_request() {
|
||||
let _ = work_tx.try_send(WorkerRequest::Request(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
app.record_error(error.to_string());
|
||||
@@ -2485,6 +2852,7 @@ pub async fn run_terminal_with_preferences(
|
||||
enum WorkerRequest {
|
||||
Refresh,
|
||||
Command(OperatorCommand),
|
||||
Request(ControlRequest),
|
||||
}
|
||||
|
||||
struct WorkerResult {
|
||||
@@ -2528,11 +2896,16 @@ async fn worker_cycle(
|
||||
payloads.push(worker_send(client, sequence, command.request()).await?);
|
||||
"command completed".to_owned()
|
||||
}
|
||||
WorkerRequest::Request(request) => {
|
||||
payloads.push(worker_send(client, sequence, request).await?);
|
||||
return Ok((payloads, "memory request completed".to_owned()));
|
||||
}
|
||||
};
|
||||
for request in [
|
||||
ControlRequest::Health,
|
||||
ControlRequest::Runtime,
|
||||
ControlRequest::Metrics,
|
||||
ControlRequest::MemoryHealth,
|
||||
ControlRequest::ListSessions { page: page() },
|
||||
ControlRequest::ListScheduledJobs { page: page() },
|
||||
ControlRequest::ListPendingApprovals { page: page() },
|
||||
@@ -2611,9 +2984,35 @@ fn map_event(app: &OperatorTui, value: &event::Event) -> Option<TuiInput> {
|
||||
event::KeyCode::BackTab | event::KeyCode::Left => Some(TuiInput::PreviousScreen),
|
||||
event::KeyCode::Up => Some(TuiInput::ScrollUp),
|
||||
event::KeyCode::Down => Some(TuiInput::ScrollDown),
|
||||
event::KeyCode::Char('/') if app.screen == OperatorScreen::Timeline => {
|
||||
event::KeyCode::Char('/')
|
||||
if matches!(
|
||||
app.screen,
|
||||
OperatorScreen::Timeline | OperatorScreen::Memories
|
||||
) =>
|
||||
{
|
||||
Some(TuiInput::BeginFilter)
|
||||
}
|
||||
event::KeyCode::Enter if app.screen == OperatorScreen::Memories => {
|
||||
Some(TuiInput::MemoryLoad)
|
||||
}
|
||||
event::KeyCode::Char('g') if app.screen == OperatorScreen::Memories => {
|
||||
Some(TuiInput::MemoryNextAgent)
|
||||
}
|
||||
event::KeyCode::Char('n') if app.screen == OperatorScreen::Memories => {
|
||||
Some(TuiInput::MemoryNextPage)
|
||||
}
|
||||
event::KeyCode::Char('b') if app.screen == OperatorScreen::Memories => {
|
||||
Some(TuiInput::MemoryPreviousPage)
|
||||
}
|
||||
event::KeyCode::Char('k') if app.screen == OperatorScreen::Memories => {
|
||||
Some(TuiInput::MemoryCycleKind)
|
||||
}
|
||||
event::KeyCode::Char('i') if app.screen == OperatorScreen::Memories => {
|
||||
Some(TuiInput::MemoryTogglePinned)
|
||||
}
|
||||
event::KeyCode::Char('o') if app.screen == OperatorScreen::Memories => {
|
||||
Some(TuiInput::MemoryCycleSort)
|
||||
}
|
||||
event::KeyCode::Char('r') => Some(TuiInput::Refresh),
|
||||
event::KeyCode::Char(key @ ('p' | 'u' | 'f' | 'a' | 'd' | 'x' | 'e' | 't' | 's')) => {
|
||||
app.command_shortcut(key).map(TuiInput::Command)
|
||||
|
||||
@@ -80,6 +80,12 @@ impl TuiTransport for FakeTransport {
|
||||
})
|
||||
}
|
||||
ControlRequest::Metrics => ControlPayload::Metrics(empty_metrics()),
|
||||
ControlRequest::MemoryHealth => {
|
||||
ControlPayload::MemoryHealth(crate::MemoryHealthView {
|
||||
agent_count: 1,
|
||||
record_count: 2,
|
||||
})
|
||||
}
|
||||
_ => ControlPayload::Completed,
|
||||
};
|
||||
Ok(ControlResponseEnvelope {
|
||||
@@ -140,7 +146,7 @@ async fn snapshot_refresh_is_transport_neutral_and_bounded() {
|
||||
app.snapshot.health.as_ref().expect("health").service_state,
|
||||
"running"
|
||||
);
|
||||
assert_eq!(transport.0.lock().expect("requests").len(), 8);
|
||||
assert_eq!(transport.0.lock().expect("requests").len(), 9);
|
||||
app.refresh(&transport).await.expect("stable refresh");
|
||||
assert_eq!(
|
||||
app.snapshot.timeline.len(),
|
||||
@@ -149,6 +155,54 @@ async fn snapshot_refresh_is_transport_neutral_and_bounded() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_panel_builds_filtered_requests_and_confirms_forget() {
|
||||
let mut app = OperatorTui::default();
|
||||
assert_eq!(app.reduce(TuiInput::NextScreen), TuiAction::None);
|
||||
assert!(matches!(
|
||||
app.reduce(TuiInput::NextScreen),
|
||||
TuiAction::Request(ControlRequest::ListMemoryAgents { .. })
|
||||
));
|
||||
app.snapshot.memory_agents.push(crate::MemoryAgentView {
|
||||
avatar_id: "avatar-a".into(),
|
||||
logical_agent_id: "logical-a".into(),
|
||||
mentra_agent_id: "mentra-a".into(),
|
||||
record_count: 1,
|
||||
});
|
||||
app.snapshot.memories.push(crate::MemoryRecordView {
|
||||
record_id: "fact:1".into(),
|
||||
kind: crate::MemoryKindView::Fact,
|
||||
preview: "durable detail".into(),
|
||||
source: Some("manual".into()),
|
||||
source_revision: 1,
|
||||
pinned: true,
|
||||
created_at: 7,
|
||||
score: None,
|
||||
});
|
||||
let forget = app.command_shortcut('d').expect("forget command");
|
||||
assert_eq!(forget.confirmation(), CommandConfirmation::HighRisk);
|
||||
|
||||
app.reduce(TuiInput::BeginFilter);
|
||||
for character in "source:manual hello".chars() {
|
||||
app.reduce(TuiInput::FilterCharacter(character));
|
||||
}
|
||||
let TuiAction::Request(ControlRequest::SearchMemories { query, filter, .. }) =
|
||||
app.reduce(TuiInput::FilterCommit)
|
||||
else {
|
||||
panic!("memory search request")
|
||||
};
|
||||
assert_eq!(query, "hello");
|
||||
assert_eq!(filter.source.as_deref(), Some("manual"));
|
||||
assert!(
|
||||
app.render(TuiRenderOptions {
|
||||
width: 120,
|
||||
height: 30,
|
||||
color: false
|
||||
})
|
||||
.contains("durable detail")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn performance_history_is_bounded_and_large_dashboards_are_deterministic() {
|
||||
let transport = FakeTransport::new();
|
||||
@@ -167,6 +221,7 @@ async fn performance_history_is_bounded_and_large_dashboards_are_deterministic()
|
||||
for (screen, marker) in [
|
||||
(OperatorScreen::Overview, "Runtime"),
|
||||
(OperatorScreen::Sessions, "Sessions"),
|
||||
(OperatorScreen::Memories, "Mentra durable memory"),
|
||||
(OperatorScreen::QueuesAndBudgets, "Work queue"),
|
||||
(OperatorScreen::Roaming, "Roaming schedules"),
|
||||
(OperatorScreen::Approvals, "Pending approvals"),
|
||||
@@ -239,6 +294,7 @@ fn every_screen_renders_without_a_terminal_at_small_unicode_and_mono_sizes() {
|
||||
for screen in [
|
||||
OperatorScreen::Overview,
|
||||
OperatorScreen::Sessions,
|
||||
OperatorScreen::Memories,
|
||||
OperatorScreen::QueuesAndBudgets,
|
||||
OperatorScreen::Roaming,
|
||||
OperatorScreen::Approvals,
|
||||
|
||||
@@ -58,10 +58,10 @@ fn package_has_only_reviewed_rust_dependencies_and_no_build_script() {
|
||||
#[test]
|
||||
fn runtime_source_has_no_subprocess_or_native_abi_escape_hatch() {
|
||||
let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut files = Vec::with_capacity(38);
|
||||
let mut files = Vec::with_capacity(39);
|
||||
collect_rust_files(&source, &mut files);
|
||||
assert!(
|
||||
files.len() <= 38,
|
||||
files.len() <= 39,
|
||||
"source-file count needs a reviewed bound update"
|
||||
);
|
||||
for path in files {
|
||||
|
||||
Reference in New Issue
Block a user