Skip to main content

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}