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}