entelix_memory/entity.rs
1//! `EntityMemory` — `entity_name → EntityRecord` map keyed by namespace.
2//!
3//! Each record carries the entity's current fact plus the wall-clock
4//! time it was last confirmed (`last_seen`) and originally created
5//! (`created_at`). Long-running agents call [`EntityMemory::prune_older_than`]
6//! periodically to drop facts that have not been re-confirmed within
7//! the configured TTL — without that, entity stores grow without
8//! bound and stale facts pollute every retrieval.
9//!
10//! The entire map lives under a single store key so reads and writes
11//! are atomic per-thread. Persistent backends that prefer one row per
12//! entity can implement a dedicated `Store<EntityRecord>` variant
13//! later — the trait surface stays the same.
14
15use std::collections::HashMap;
16use std::sync::Arc;
17use std::time::Duration;
18
19use chrono::{DateTime, Utc};
20use entelix_core::{ExecutionContext, Result};
21use serde::{Deserialize, Serialize};
22
23use crate::namespace::Namespace;
24use crate::store::Store;
25
26const DEFAULT_KEY: &str = "entities";
27
28/// One entity's recorded fact plus provenance metadata.
29///
30/// `last_seen` is refreshed every time [`EntityMemory::set_entity`]
31/// or [`EntityMemory::touch`] runs; reads do not advance it.
32/// `created_at` is set once on first insertion and preserved across
33/// subsequent updates so the audit trail of "when did we first
34/// learn this entity?" stays intact.
35#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
36pub struct EntityRecord {
37 /// The current fact recorded for this entity.
38 pub fact: String,
39 /// Wall-clock time the fact was last confirmed (set or touched).
40 pub last_seen: DateTime<Utc>,
41 /// Wall-clock time the entity was first observed.
42 pub created_at: DateTime<Utc>,
43}
44
45/// Map of `entity_name → EntityRecord` keyed by namespace.
46pub struct EntityMemory {
47 store: Arc<dyn Store<HashMap<String, EntityRecord>>>,
48 namespace: Namespace,
49}
50
51impl EntityMemory {
52 /// Build an entity memory over `store` scoped to `namespace`.
53 pub fn new(store: Arc<dyn Store<HashMap<String, EntityRecord>>>, namespace: Namespace) -> Self {
54 Self { store, namespace }
55 }
56
57 /// Borrow the bound namespace.
58 pub const fn namespace(&self) -> &Namespace {
59 &self.namespace
60 }
61
62 /// Insert or replace the fact for `entity`. `last_seen` is set
63 /// to `Utc::now()`; `created_at` is preserved on update or set
64 /// to `now` on first insertion.
65 pub async fn set_entity(
66 &self,
67 ctx: &ExecutionContext,
68 entity: &str,
69 fact: impl Into<String>,
70 ) -> Result<()> {
71 let mut all = self
72 .store
73 .get(ctx, &self.namespace, DEFAULT_KEY)
74 .await?
75 .unwrap_or_default();
76 let now = Utc::now();
77 let fact = fact.into();
78 match all.entry(entity.to_owned()) {
79 std::collections::hash_map::Entry::Occupied(mut occ) => {
80 let existing = occ.get_mut();
81 existing.fact = fact;
82 existing.last_seen = now;
83 }
84 std::collections::hash_map::Entry::Vacant(vac) => {
85 vac.insert(EntityRecord {
86 fact,
87 last_seen: now,
88 created_at: now,
89 });
90 }
91 }
92 self.store.put(ctx, &self.namespace, DEFAULT_KEY, all).await
93 }
94
95 /// Refresh `last_seen` for `entity` without changing the fact.
96 /// Use when the agent re-encounters an entity in a way that
97 /// re-confirms relevance (the entity was mentioned again, even
98 /// if no new fact was learned). Returns `Ok(false)` when the
99 /// entity is not present so callers can distinguish absent vs
100 /// touched.
101 pub async fn touch(&self, ctx: &ExecutionContext, entity: &str) -> Result<bool> {
102 let Some(mut all) = self.store.get(ctx, &self.namespace, DEFAULT_KEY).await? else {
103 return Ok(false);
104 };
105 let Some(record) = all.get_mut(entity) else {
106 return Ok(false);
107 };
108 record.last_seen = Utc::now();
109 self.store
110 .put(ctx, &self.namespace, DEFAULT_KEY, all)
111 .await?;
112 Ok(true)
113 }
114
115 /// Look up a single entity's fact. The lightweight ergonomic
116 /// accessor — callers needing provenance use
117 /// [`Self::entity_record`].
118 pub async fn entity(&self, ctx: &ExecutionContext, entity: &str) -> Result<Option<String>> {
119 Ok(self
120 .entity_record(ctx, entity)
121 .await?
122 .map(|record| record.fact))
123 }
124
125 /// Look up a single entity's full record (fact + timestamps).
126 pub async fn entity_record(
127 &self,
128 ctx: &ExecutionContext,
129 entity: &str,
130 ) -> Result<Option<EntityRecord>> {
131 Ok(self
132 .store
133 .get(ctx, &self.namespace, DEFAULT_KEY)
134 .await?
135 .and_then(|all| all.get(entity).cloned()))
136 }
137
138 /// Read the `entity → fact` projection over every recorded
139 /// record. Use [`Self::all_records`] to retain timestamps.
140 pub async fn all(&self, ctx: &ExecutionContext) -> Result<HashMap<String, String>> {
141 let records = self.all_records(ctx).await?;
142 Ok(records
143 .into_iter()
144 .map(|(name, record)| (name, record.fact))
145 .collect())
146 }
147
148 /// Read every recorded entity's full record.
149 pub async fn all_records(
150 &self,
151 ctx: &ExecutionContext,
152 ) -> Result<HashMap<String, EntityRecord>> {
153 Ok(self
154 .store
155 .get(ctx, &self.namespace, DEFAULT_KEY)
156 .await?
157 .unwrap_or_default())
158 }
159
160 /// Drop every record whose `last_seen` is older than `ttl` ago.
161 /// Returns the number of records removed so callers can log
162 /// or expose pruning metrics.
163 ///
164 /// Runs as a single read-modify-write under the namespace's
165 /// store key, so the prune is atomic per-thread.
166 pub async fn prune_older_than(&self, ctx: &ExecutionContext, ttl: Duration) -> Result<usize> {
167 let Some(mut all) = self.store.get(ctx, &self.namespace, DEFAULT_KEY).await? else {
168 return Ok(0);
169 };
170 // chrono::Duration is signed and uses i64 nanoseconds; for
171 // pathological ttls (above i64::MAX seconds) saturate to
172 // chrono::Duration::MAX so the cutoff stays in the past.
173 let cutoff = Utc::now() - chrono::Duration::from_std(ttl).unwrap_or(chrono::Duration::MAX);
174 let before = all.len();
175 all.retain(|_, record| record.last_seen >= cutoff);
176 let removed = before - all.len();
177 if removed > 0 {
178 self.store
179 .put(ctx, &self.namespace, DEFAULT_KEY, all)
180 .await?;
181 }
182 Ok(removed)
183 }
184
185 /// Remove a single entity. Idempotent — removing an absent
186 /// entity is a no-op.
187 pub async fn remove(&self, ctx: &ExecutionContext, entity: &str) -> Result<()> {
188 let Some(mut all) = self.store.get(ctx, &self.namespace, DEFAULT_KEY).await? else {
189 return Ok(());
190 };
191 all.remove(entity);
192 self.store.put(ctx, &self.namespace, DEFAULT_KEY, all).await
193 }
194
195 /// Clear every entity in this namespace.
196 pub async fn clear(&self, ctx: &ExecutionContext) -> Result<()> {
197 self.store.delete(ctx, &self.namespace, DEFAULT_KEY).await
198 }
199}