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//! See [`cellar-memory-manager.md`] §6.2 for the corresponding SQL schema.
9//!
10//! [`kind`]: MemoryChunk::kind
11//! [`metadata`]: MemoryChunk::metadata
12//! [`content`]: MemoryChunk::content
13//! [`cellar-memory-manager.md`]: file:///Users/dimitriospagkratis/.claude/plans/cellar-memory-manager.md
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
54    /// [`cellar-memory-manager.md`] §13.1.
55    ///
56    /// [`cellar-memory-manager.md`]: file:///Users/dimitriospagkratis/.claude/plans/cellar-memory-manager.md
57    #[serde(default)]
58    pub shareable: bool,
59    /// If non-`None`, the ID of a chunk that replaces this one (e.g. a
60    /// correction supersedes the original mistake).
61    pub superseded_by: Option<String>,
62    /// Name of the embedding model used (e.g. `"bge-small-en-v1.5"`). The v1
63    /// `BasicMemoryProvider` records `"none"` because it doesn't embed.
64    pub embedding_model: String,
65    /// Dimensionality of the embedding. `0` for the v1 stub.
66    pub embedding_dim: u32,
67}
68
69/// Input to [`crate::MemoryProvider::write`]. The fields the provider fills in
70/// (id, created_at, tier, embedding model+dim) are absent here.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
72pub struct NewMemoryChunk {
73    /// See [`MemoryChunk::kind`].
74    pub kind: ChunkKind,
75    /// See [`MemoryChunk::source`].
76    pub source: ChunkSource,
77    /// See [`MemoryChunk::session_id`].
78    pub session_id: Option<String>,
79    /// See [`MemoryChunk::project_root`].
80    pub project_root: Option<String>,
81    /// See [`MemoryChunk::caller_id`].
82    pub caller_id: String,
83    /// See [`MemoryChunk::content`].
84    pub content: String,
85    /// See [`MemoryChunk::metadata`]. Defaults to JSON `null` if omitted.
86    #[serde(default)]
87    pub metadata: Value,
88    /// Optional caller-supplied importance hint. The provider may clamp or
89    /// override based on its own scorer; defaults to `0.5` when `None`.
90    #[serde(default)]
91    pub importance: Option<f32>,
92    /// Mark this chunk as cross-caller shareable. Defaults to `false`.
93    #[serde(default)]
94    pub shareable: bool,
95    /// Pin from creation (rare; usually set later via `pin`).
96    #[serde(default)]
97    pub pinned: bool,
98}
99
100/// Categories of memory. Drives kind-filtered retrieval and per-kind
101/// retention horizons.
102#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
103#[serde(rename_all = "snake_case")]
104pub enum ChunkKind {
105    /// A chat message between the user and an agent.
106    Chat,
107    /// A `cel_act` call: attempted, completed, or denied.
108    Action,
109    /// A rule firing.
110    Fire,
111    /// A Cortex event the importance scorer flagged as significant.
112    Observation,
113    /// A user correction or override (confirmation modal Deny, "don't do that
114    /// again" chat message, etc.). Highest-signal kind. Never auto-evicted.
115    Correction,
116    /// End-of-session synthesis: goal, plan, actions, outcome, surprises.
117    JobSummary,
118    /// File / app / URL focus episode.
119    Context,
120    /// A rollup that covers many other chunks. Linked via the summary-members
121    /// table in the storage layer.
122    Rollup,
123}
124
125/// Where the chunk came from. Distinct from `caller_id`: `source` is the
126/// *producer* in the daemon; `caller_id` is the *originator* of the underlying
127/// activity (an external MCP client name, the embedded agent, etc.).
128#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
129#[serde(rename_all = "snake_case")]
130pub enum ChunkSource {
131    /// The embedded agent runtime in the daemon.
132    Embedded,
133    /// An external MCP client (Cursor, Codex, Claude Desktop, etc.). The
134    /// specific client lives in `caller_id`.
135    Mcp,
136    /// The `cel_act` gateway, recording an attempted/completed/denied action.
137    Gateway,
138    /// The rule matcher post-fire hook.
139    Matcher,
140    /// The Cortex perception engine.
141    Cortex,
142    /// System-level metadata (settings changes, daemon lifecycle, etc.).
143    System,
144}
145
146/// Which tier a chunk currently lives in.
147#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
148#[serde(rename_all = "snake_case")]
149pub enum MemoryTier {
150    /// Recent (within the session horizon). Raw rows.
151    Session,
152    /// Older. Mostly summarized.
153    LongTerm,
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use serde_json::json;
160
161    #[test]
162    fn chunk_kind_serializes_snake_case() {
163        assert_eq!(
164            serde_json::to_value(ChunkKind::JobSummary).unwrap(),
165            json!("job_summary")
166        );
167        let back: ChunkKind = serde_json::from_value(json!("rollup")).unwrap();
168        assert_eq!(back, ChunkKind::Rollup);
169    }
170
171    #[test]
172    fn chunk_source_serializes_snake_case() {
173        assert_eq!(
174            serde_json::to_value(ChunkSource::Gateway).unwrap(),
175            json!("gateway")
176        );
177    }
178
179    #[test]
180    fn new_chunk_defaults() {
181        let raw = r#"{"kind":"chat","source":"embedded","caller_id":"embedded","content":"hi","session_id":null,"project_root":null}"#;
182        let n: NewMemoryChunk = serde_json::from_str(raw).unwrap();
183        assert_eq!(n.kind, ChunkKind::Chat);
184        assert_eq!(n.importance, None);
185        assert!(!n.shareable);
186        assert!(!n.pinned);
187        assert_eq!(n.metadata, Value::Null);
188    }
189
190    #[test]
191    fn chunk_round_trip() {
192        let c = MemoryChunk {
193            id: "01950000-0000-7000-8000-000000000001".into(),
194            created_at: Utc::now(),
195            kind: ChunkKind::Action,
196            tier: MemoryTier::Session,
197            source: ChunkSource::Gateway,
198            session_id: Some("sess_abc".into()),
199            project_root: Some("/Users/dim/Workspace".into()),
200            caller_id: "embedded".into(),
201            content: "Agent attempted fs.copy".into(),
202            metadata: json!({"action_type":"fs.copy"}),
203            importance: 0.7,
204            pinned: false,
205            shareable: false,
206            superseded_by: None,
207            embedding_model: "none".into(),
208            embedding_dim: 0,
209        };
210        let s = serde_json::to_string(&c).unwrap();
211        let back: MemoryChunk = serde_json::from_str(&s).unwrap();
212        assert_eq!(c, back);
213    }
214
215    #[test]
216    fn chunk_shareable_default_false_on_deserialize() {
217        // Pre-Phase-4 callers that constructed JSON without the `shareable`
218        // field must still round-trip — shareable defaults to false on the
219        // wire. (Other fields are still required.)
220        let raw = r#"{"id":"x","created_at":"2026-01-01T00:00:00Z","kind":"chat",
221            "tier":"session","source":"embedded","session_id":null,
222            "project_root":null,"caller_id":"embedded",
223            "content":"hi","metadata":null,"importance":0.5,"pinned":false,
224            "superseded_by":null,"embedding_model":"none","embedding_dim":0}"#;
225        let c: MemoryChunk = serde_json::from_str(raw).unwrap();
226        assert!(!c.shareable);
227    }
228}