Add operator Mentra memory browser
Some checks failed
CI / rust-skia (Rust only) (push) Has been cancelled
CI / required (push) Has been cancelled

This commit is contained in:
2026-08-23 19:18:41 +02:00
parent 95cca3e777
commit 360203d647
190 changed files with 70246 additions and 45 deletions

View File

@@ -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",

View File

@@ -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)

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View 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,
}
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 {