Skip to main content

cel_memory/
chunk.rs

1//! Memory chunk — the primary persisted unit.
2//!
3//! One row in `memory_chunks`. Every category of memory (chat / action / fire /
4//! observation / correction / job_summary / context / rollup) is a chunk, with
5//! the [`kind`] discriminator and kind-specific structured fields living in
6//! [`metadata`]. The single embedded/FTS-indexed text is [`content`].
7//!
8//! The corresponding SQL schema (the `memory_chunks` table and its indexes)
9//! lives in the `cel-memory-sqlite` crate's migrations.
10//!
11//! [`kind`]: MemoryChunk::kind
12//! [`metadata`]: MemoryChunk::metadata
13//! [`content`]: MemoryChunk::content
14
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18
19/// A persisted memory unit.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21pub struct MemoryChunk {
22    /// Time-ordered ID. uuid v7 in v1.
23    pub id: String,
24    /// When the chunk was written.
25    pub created_at: DateTime<Utc>,
26    /// Discriminator for kind-specific handling and retrieval filtering.
27    pub kind: ChunkKind,
28    /// Tier this chunk lives in: working (never persisted, so never seen
29    /// here), session (recent), or long_term (aged out / summarized).
30    pub tier: MemoryTier,
31    /// Where the chunk came from.
32    pub source: ChunkSource,
33    /// Conversation or delegated-job grouping. `None` for ambient chunks
34    /// like observations and rule firings.
35    pub session_id: Option<String>,
36    /// Working directory or project this chunk is scoped to, if known.
37    pub project_root: Option<String>,
38    /// Normalised caller — `"embedded"`, `"mcp:codex"`, `"gateway"`,
39    /// `"matcher"`, `"cortex"`, `"system"`.
40    pub caller_id: String,
41    /// The human-readable text indexed by FTS and embedded.
42    pub content: String,
43    /// Kind-specific structured fields.
44    pub metadata: Value,
45    /// Importance score in [0, 1]. Drives eviction priority.
46    pub importance: f32,
47    /// If true, never auto-evicted regardless of importance or age.
48    pub pinned: bool,
49    /// Cross-caller visibility flag. When `true`, the chunk surfaces to
50    /// every caller whose query uses [`crate::CallerScope::OwnPlusShared`]
51    /// (in addition to the writer's own scope). When `false` (the default),
52    /// the chunk is only visible to its writer under `Own`/`OwnPlusShared`
53    /// and to privileged surfaces under `Global`. See [`crate::CallerScope`]
54    /// for the full visibility model.
55    #[serde(default)]
56    pub shareable: bool,
57    /// If non-`None`, the ID of a chunk that replaces this one (e.g. a
58    /// correction supersedes the original mistake).
59    pub superseded_by: Option<String>,
60    /// Name of the embedding model used (e.g. `"bge-small-en-v1.5"`). The v1
61    /// `BasicMemoryProvider` records `"none"` because it doesn't embed.
62    pub embedding_model: String,
63    /// Dimensionality of the embedding. `0` for the v1 stub.
64    pub embedding_dim: u32,
65}
66
67/// Input to [`crate::MemoryProvider::write`]. The fields the provider fills in
68/// (id, created_at, tier, embedding model+dim) are absent here.
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
70pub struct NewMemoryChunk {
71    /// See [`MemoryChunk::kind`].
72    pub kind: ChunkKind,
73    /// See [`MemoryChunk::source`].
74    pub source: ChunkSource,
75    /// See [`MemoryChunk::session_id`].
76    pub session_id: Option<String>,
77    /// See [`MemoryChunk::project_root`].
78    pub project_root: Option<String>,
79    /// See [`MemoryChunk::caller_id`].
80    pub caller_id: String,
81    /// See [`MemoryChunk::content`].
82    pub content: String,
83    /// See [`MemoryChunk::metadata`]. Defaults to JSON `null` if omitted.
84    #[serde(default)]
85    pub metadata: Value,
86    /// Optional caller-supplied importance hint. The provider may clamp or
87    /// override based on its own scorer; defaults to `0.5` when `None`.
88    #[serde(default)]
89    pub importance: Option<f32>,
90    /// Mark this chunk as cross-caller shareable. Defaults to `false`.
91    #[serde(default)]
92    pub shareable: bool,
93    /// Pin from creation (rare; usually set later via `pin`).
94    #[serde(default)]
95    pub pinned: bool,
96}
97
98/// Categories of memory. Drives kind-filtered retrieval and per-kind
99/// retention horizons.
100#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
101#[serde(rename_all = "snake_case")]
102pub enum ChunkKind {
103    /// A chat message between the user and an agent.
104    Chat,
105    /// A `cel_act` call: attempted, completed, or denied.
106    Action,
107    /// A rule firing.
108    Fire,
109    /// A Cortex event the importance scorer flagged as significant.
110    Observation,
111    /// A user correction or override (confirmation modal Deny, "don't do that
112    /// again" chat message, etc.). Highest-signal kind. Never auto-evicted.
113    Correction,
114    /// End-of-session synthesis: goal, plan, actions, outcome, surprises.
115    JobSummary,
116    /// File / app / URL focus episode.
117    Context,
118    /// A rollup that covers many other chunks. Linked via the summary-members
119    /// table in the storage layer.
120    Rollup,
121}
122
123/// Where the chunk came from. Distinct from `caller_id`: `source` is the
124/// *producer* in the daemon; `caller_id` is the *originator* of the underlying
125/// activity (an external MCP client name, the embedded agent, etc.).
126#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
127#[serde(rename_all = "snake_case")]
128pub enum ChunkSource {
129    /// The embedded agent runtime in the daemon.
130    Embedded,
131    /// An external MCP client (Cursor, Codex, Claude Desktop, etc.). The
132    /// specific client lives in `caller_id`.
133    Mcp,
134    /// The `cel_act` gateway, recording an attempted/completed/denied action.
135    Gateway,
136    /// The rule matcher post-fire hook.
137    Matcher,
138    /// The Cortex perception engine.
139    Cortex,
140    /// System-level metadata (settings changes, daemon lifecycle, etc.).
141    System,
142}
143
144/// Which tier a chunk currently lives in.
145#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
146#[serde(rename_all = "snake_case")]
147pub enum MemoryTier {
148    /// Recent (within the session horizon). Raw rows.
149    Session,
150    /// Older. Mostly summarized.
151    LongTerm,
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use serde_json::json;
158
159    #[test]
160    fn chunk_kind_serializes_snake_case() {
161        assert_eq!(
162            serde_json::to_value(ChunkKind::JobSummary).unwrap(),
163            json!("job_summary")
164        );
165        let back: ChunkKind = serde_json::from_value(json!("rollup")).unwrap();
166        assert_eq!(back, ChunkKind::Rollup);
167    }
168
169    #[test]
170    fn chunk_source_serializes_snake_case() {
171        assert_eq!(
172            serde_json::to_value(ChunkSource::Gateway).unwrap(),
173            json!("gateway")
174        );
175    }
176
177    #[test]
178    fn new_chunk_defaults() {
179        let raw = r#"{"kind":"chat","source":"embedded","caller_id":"embedded","content":"hi","session_id":null,"project_root":null}"#;
180        let n: NewMemoryChunk = serde_json::from_str(raw).unwrap();
181        assert_eq!(n.kind, ChunkKind::Chat);
182        assert_eq!(n.importance, None);
183        assert!(!n.shareable);
184        assert!(!n.pinned);
185        assert_eq!(n.metadata, Value::Null);
186    }
187
188    #[test]
189    fn chunk_round_trip() {
190        let c = MemoryChunk {
191            id: "01950000-0000-7000-8000-000000000001".into(),
192            created_at: Utc::now(),
193            kind: ChunkKind::Action,
194            tier: MemoryTier::Session,
195            source: ChunkSource::Gateway,
196            session_id: Some("sess_abc".into()),
197            project_root: Some("/Users/dim/Workspace".into()),
198            caller_id: "embedded".into(),
199            content: "Agent attempted fs.copy".into(),
200            metadata: json!({"action_type":"fs.copy"}),
201            importance: 0.7,
202            pinned: false,
203            shareable: false,
204            superseded_by: None,
205            embedding_model: "none".into(),
206            embedding_dim: 0,
207        };
208        let s = serde_json::to_string(&c).unwrap();
209        let back: MemoryChunk = serde_json::from_str(&s).unwrap();
210        assert_eq!(c, back);
211    }
212
213    #[test]
214    fn chunk_shareable_default_false_on_deserialize() {
215        // Pre-Phase-4 callers that constructed JSON without the `shareable`
216        // field must still round-trip — shareable defaults to false on the
217        // wire. (Other fields are still required.)
218        let raw = r#"{"id":"x","created_at":"2026-01-01T00:00:00Z","kind":"chat",
219            "tier":"session","source":"embedded","session_id":null,
220            "project_root":null,"caller_id":"embedded",
221            "content":"hi","metadata":null,"importance":0.5,"pinned":false,
222            "superseded_by":null,"embedding_model":"none","embedding_dim":0}"#;
223        let c: MemoryChunk = serde_json::from_str(raw).unwrap();
224        assert!(!c.shareable);
225    }
226}