cel_memory/query.rs
1//! Query, profile, scope, and predicate types.
2//!
3//! [`MemoryQuery`] is the input to [`crate::MemoryProvider::retrieve`].
4//! [`RetrievalProfile`] selects a tuned set of hybrid weights and kind filters
5//! per caller path; [`CallerScope`] enforces multi-agent isolation.
6//! [`MemoryPredicate`] describes the criteria for
7//! [`crate::MemoryProvider::delete_matching`].
8
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11
12use crate::chunk::ChunkKind;
13
14/// A retrieval query — the input to [`crate::MemoryProvider::retrieve`].
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct MemoryQuery {
17 /// Free-text query. Embedded for vector search and tokenized for FTS.
18 pub text: String,
19 /// Optional kind filter. `None` means all kinds.
20 #[serde(default)]
21 pub kinds: Option<Vec<ChunkKind>>,
22 /// Optional lower bound on `created_at`.
23 #[serde(default)]
24 pub since: Option<DateTime<Utc>>,
25 /// Optional upper bound on `created_at`.
26 #[serde(default)]
27 pub until: Option<DateTime<Utc>>,
28 /// Restrict to chunks in this session.
29 #[serde(default)]
30 pub session_id: Option<String>,
31 /// Multi-agent visibility scope.
32 #[serde(default)]
33 pub caller_scope: CallerScope,
34 /// Restrict to chunks with a `project_root` prefix matching this string.
35 #[serde(default)]
36 pub project_root_prefix: Option<String>,
37 /// Top-k results. Default 8.
38 #[serde(default = "default_k")]
39 pub k: usize,
40 /// Include rollup chunks (`ChunkKind::Rollup`). Default true.
41 #[serde(default = "default_true")]
42 pub include_rollups: bool,
43 /// Minimum importance to include. `None` means no floor.
44 #[serde(default)]
45 pub min_importance: Option<f32>,
46 /// Retrieval profile selecting hybrid weights and per-caller defaults.
47 #[serde(default)]
48 pub profile: RetrievalProfile,
49 /// Identifier of the caller performing the retrieval — used to enforce
50 /// [`CallerScope::Own`] and to log access for relevance feedback.
51 pub caller_id: String,
52}
53
54fn default_k() -> usize {
55 8
56}
57
58fn default_true() -> bool {
59 true
60}
61
62/// Multi-agent visibility scope for a query.
63///
64/// The default scope for an external MCP client is [`Own`]; an embedded agent
65/// typically gets [`OwnPlusShared`]; privileged user surfaces (a Memory tab UI,
66/// an audit timeline) get [`Global`].
67///
68/// [`Own`]: CallerScope::Own
69/// [`OwnPlusShared`]: CallerScope::OwnPlusShared
70/// [`Global`]: CallerScope::Global
71#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
72#[serde(rename_all = "snake_case")]
73pub enum CallerScope {
74 /// Caller sees only chunks where `caller_id == self`.
75 #[default]
76 Own,
77 /// Caller sees own chunks plus any chunk tagged shareable.
78 OwnPlusShared,
79 /// Caller sees everything. Privileged — granted to user surfaces only.
80 Global,
81}
82
83/// Named retrieval profiles. Each profile sets a tuned set of hybrid weights
84/// (vector / FTS / recency) and kind filters appropriate to the caller path.
85///
86/// The in-crate [`crate::BasicMemoryProvider`] accepts the profile but does not
87/// honor it (it always performs a single lexical match). A full storage backend
88/// (e.g. the `cel-memory-sqlite` crate) implements per-profile hybrid-weight
89/// tuning (vector / FTS / recency).
90#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, Hash)]
91#[serde(rename_all = "snake_case")]
92pub enum RetrievalProfile {
93 /// Embedded agent's per-turn retrieval. Semantic-heavy, short recency
94 /// half-life. The default for any call without a profile.
95 #[default]
96 AgentChatTurn,
97 /// Embedded agent's delegated-job context. Heavier on long-term tier and
98 /// job-summary chunks.
99 AgentDelegatedJob,
100 /// NL rule compiler authoring a new rule — wants similar prior rules.
101 NLCompilerSimilarRules,
102 /// NL rule compiler authoring a new rule — wants similar prior fires
103 /// to a draft rule's match.
104 NLCompilerSimilarFires,
105 /// Audit / Activity tab — wide window, keyword-dominant.
106 AuditTimeline,
107 /// User free-text search in the Memory tab.
108 UserSearch,
109}
110
111/// Criteria for [`crate::MemoryProvider::delete_matching`] (and reused as the
112/// filter inside [`crate::ExportFilter`]).
113///
114/// Empty predicate matches nothing — `delete_matching(MemoryPredicate::default())`
115/// is a safe no-op rather than a "delete everything" footgun. Use
116/// [`crate::MemoryProvider::purge_all`] for that.
117#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
118pub struct MemoryPredicate {
119 /// Match chunks whose kind is in this set.
120 #[serde(default)]
121 pub kinds: Option<Vec<ChunkKind>>,
122 /// Match chunks written by callers in this set.
123 #[serde(default)]
124 pub callers: Option<Vec<String>>,
125 /// Match chunks belonging to these sessions.
126 #[serde(default)]
127 pub session_ids: Option<Vec<String>>,
128 /// Match chunks whose `project_root` begins with this prefix.
129 #[serde(default)]
130 pub project_root_prefix: Option<String>,
131 /// Match chunks created before this instant.
132 #[serde(default)]
133 pub before: Option<DateTime<Utc>>,
134 /// Match chunks created after this instant.
135 #[serde(default)]
136 pub after: Option<DateTime<Utc>>,
137 /// If `Some(true)`, only pinned chunks; if `Some(false)`, only unpinned;
138 /// `None` ignores pinning.
139 #[serde(default)]
140 pub pinned: Option<bool>,
141 /// Match chunks with importance strictly less than this.
142 #[serde(default)]
143 pub importance_below: Option<f32>,
144 /// Match chunks whose content contains this substring (case-insensitive
145 /// in v1).
146 #[serde(default)]
147 pub content_contains: Option<String>,
148}
149
150impl MemoryPredicate {
151 /// True if the predicate has no constraints — i.e. would match every
152 /// chunk. [`crate::MemoryProvider::delete_matching`] short-circuits to a
153 /// no-op when this returns true.
154 pub fn is_empty(&self) -> bool {
155 self.kinds.is_none()
156 && self.callers.is_none()
157 && self.session_ids.is_none()
158 && self.project_root_prefix.is_none()
159 && self.before.is_none()
160 && self.after.is_none()
161 && self.pinned.is_none()
162 && self.importance_below.is_none()
163 && self.content_contains.is_none()
164 }
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170 use serde_json::json;
171
172 #[test]
173 fn caller_scope_default_is_own() {
174 assert_eq!(CallerScope::default(), CallerScope::Own);
175 }
176
177 #[test]
178 fn retrieval_profile_default_is_agent_chat_turn() {
179 assert_eq!(RetrievalProfile::default(), RetrievalProfile::AgentChatTurn);
180 }
181
182 #[test]
183 fn empty_predicate_is_empty() {
184 assert!(MemoryPredicate::default().is_empty());
185 let p = MemoryPredicate {
186 content_contains: Some("hi".into()),
187 ..Default::default()
188 };
189 assert!(!p.is_empty());
190 }
191
192 #[test]
193 fn query_round_trip() {
194 let q = MemoryQuery {
195 text: "Q4 report".into(),
196 kinds: Some(vec![ChunkKind::Chat, ChunkKind::Action]),
197 since: None,
198 until: None,
199 session_id: Some("sess_abc".into()),
200 caller_scope: CallerScope::OwnPlusShared,
201 project_root_prefix: Some("/Users/dim/Workspace".into()),
202 k: 8,
203 include_rollups: true,
204 min_importance: Some(0.3),
205 profile: RetrievalProfile::AgentChatTurn,
206 caller_id: "embedded".into(),
207 };
208 let s = serde_json::to_string(&q).unwrap();
209 let back: MemoryQuery = serde_json::from_str(&s).unwrap();
210 assert_eq!(q, back);
211 }
212
213 #[test]
214 fn query_defaults_apply_on_deserialize() {
215 let raw = json!({
216 "text": "Q4 report",
217 "caller_id": "embedded"
218 })
219 .to_string();
220 let q: MemoryQuery = serde_json::from_str(&raw).unwrap();
221 assert_eq!(q.k, 8);
222 assert!(q.include_rollups);
223 assert_eq!(q.profile, RetrievalProfile::AgentChatTurn);
224 assert_eq!(q.caller_scope, CallerScope::Own);
225 }
226}