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 — for example `"agent"`, `"mcp:codex"`,
39 /// `"gateway"`, `"policy"`, `"perception"`, `"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 /// An action call: attempted, completed, or denied.
106 Action,
107 /// A rule firing.
108 Fire,
109 /// A runtime observation 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 category, while `caller_id` is the originator of the underlying
125/// activity (for example an agent id, client id, or service name).
126#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
127#[serde(rename_all = "snake_case")]
128pub enum ChunkSource {
129 /// An embedded or in-process agent.
130 Embedded,
131 /// An external MCP client (Cursor, Codex, Claude Desktop, etc.). The
132 /// specific client lives in `caller_id`.
133 Mcp,
134 /// An action gateway, recording an attempted/completed/denied action.
135 Gateway,
136 /// A policy or rule matcher post-fire hook.
137 Matcher,
138 /// A perception or observation runtime.
139 Perception,
140 /// System-level metadata (settings changes, lifecycle events, 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}