Skip to main content

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}