Skip to main content

zeph_memory/store/
trajectory.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use zeph_db::{ActiveDialect, query, query_as, query_scalar, sql};
5
6use super::SqliteStore;
7use crate::error::MemoryError;
8use crate::store::compression_guidelines::redact_sensitive;
9
10/// Input for inserting a trajectory entry.
11#[derive(Debug, Clone)]
12pub struct NewTrajectoryEntry<'a> {
13    pub conversation_id: Option<i64>,
14    pub turn_index: i64,
15    pub kind: &'a str,
16    pub intent: &'a str,
17    pub outcome: &'a str,
18    pub tools_used: &'a str,
19    pub confidence: f64,
20}
21
22/// A single trajectory memory row from the `trajectory_memory` table.
23#[derive(Debug, Clone, sqlx::FromRow)]
24pub struct TrajectoryEntryRow {
25    pub id: i64,
26    pub conversation_id: Option<i64>,
27    pub turn_index: i64,
28    pub kind: String,
29    pub intent: String,
30    pub outcome: String,
31    pub tools_used: String,
32    pub confidence: f64,
33    pub created_at: String,
34    pub updated_at: String,
35}
36
37impl SqliteStore {
38    /// Insert a trajectory entry.
39    ///
40    /// Returns the id of the inserted row.
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if the query fails.
45    pub async fn insert_trajectory_entry(
46        &self,
47        entry: NewTrajectoryEntry<'_>,
48    ) -> Result<i64, MemoryError> {
49        // Redact potential secrets echoed by the LLM from tool outputs before persisting.
50        let intent = redact_sensitive(entry.intent);
51        let outcome = redact_sensitive(entry.outcome);
52
53        let (id,): (i64,) = query_as(sql!(
54            "INSERT INTO trajectory_memory
55                (conversation_id, turn_index, kind, intent, outcome, tools_used, confidence)
56             VALUES (?, ?, ?, ?, ?, ?, ?)
57             RETURNING id"
58        ))
59        .bind(entry.conversation_id)
60        .bind(entry.turn_index)
61        .bind(entry.kind)
62        .bind(intent.as_ref())
63        .bind(outcome.as_ref())
64        .bind(entry.tools_used)
65        .bind(entry.confidence)
66        .fetch_one(self.pool())
67        .await?;
68
69        Ok(id)
70    }
71
72    /// Load trajectory entries, optionally filtered by kind.
73    ///
74    /// Results are ordered by confidence DESC, then `created_at` DESC.
75    ///
76    /// # Errors
77    ///
78    /// Returns an error if the query fails.
79    pub async fn load_trajectory_entries(
80        &self,
81        kind: Option<&str>,
82        limit: usize,
83    ) -> Result<Vec<TrajectoryEntryRow>, MemoryError> {
84        // `created_at`/`updated_at` are `TIMESTAMPTZ` on Postgres (`TEXT` on SQLite); project
85        // both through `Dialect::select_as_text`, aliased back to their original names so
86        // `#[derive(sqlx::FromRow)]` still binds them into the `String` fields below.
87        // `confidence` is `REAL` (`FLOAT4`) on Postgres but decodes into an `f64` field;
88        // `CAST(... AS DOUBLE PRECISION)` widens it (same idiom used elsewhere in this crate).
89        let created_at_sel =
90            <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("created_at");
91        let updated_at_sel =
92            <ActiveDialect as zeph_db::dialect::Dialect>::select_as_text("updated_at");
93        let rows: Vec<TrajectoryEntryRow> = if let Some(k) = kind {
94            let raw = format!(
95                "SELECT id, conversation_id, turn_index, kind, intent, outcome,
96                    tools_used, CAST(confidence AS DOUBLE PRECISION) AS confidence,
97                    {created_at_sel} AS created_at, {updated_at_sel} AS updated_at
98             FROM trajectory_memory
99             WHERE kind = ?
100             ORDER BY confidence DESC, trajectory_memory.created_at DESC
101             LIMIT ?"
102            );
103            let query_sql = zeph_db::rewrite_placeholders(&raw);
104            query_as(sqlx::AssertSqlSafe(query_sql))
105                .bind(k)
106                .bind(i64::try_from(limit).unwrap_or(i64::MAX))
107                .fetch_all(self.pool())
108                .await?
109        } else {
110            let raw = format!(
111                "SELECT id, conversation_id, turn_index, kind, intent, outcome,
112                    tools_used, CAST(confidence AS DOUBLE PRECISION) AS confidence,
113                    {created_at_sel} AS created_at, {updated_at_sel} AS updated_at
114             FROM trajectory_memory
115             ORDER BY confidence DESC, trajectory_memory.created_at DESC
116             LIMIT ?"
117            );
118            let query_sql = zeph_db::rewrite_placeholders(&raw);
119            query_as(sqlx::AssertSqlSafe(query_sql))
120                .bind(i64::try_from(limit).unwrap_or(i64::MAX))
121                .fetch_all(self.pool())
122                .await?
123        };
124
125        Ok(rows)
126    }
127
128    /// Count total trajectory entries (for metrics/TUI).
129    ///
130    /// # Errors
131    ///
132    /// Returns an error if the query fails.
133    pub async fn count_trajectory_entries(&self) -> Result<i64, MemoryError> {
134        let count: i64 = query_scalar(sql!("SELECT COUNT(*) FROM trajectory_memory"))
135            .fetch_one(self.pool())
136            .await?;
137
138        Ok(count)
139    }
140
141    /// Read the last extracted message id for a given conversation from `trajectory_meta`.
142    ///
143    /// Returns `0` if no row exists for the conversation yet.
144    ///
145    /// # Errors
146    ///
147    /// Returns an error if the query fails.
148    pub async fn trajectory_last_extracted_message_id(
149        &self,
150        conversation_id: i64,
151    ) -> Result<i64, MemoryError> {
152        let id: Option<i64> = query_scalar(sql!(
153            "SELECT last_extracted_message_id
154             FROM trajectory_meta
155             WHERE conversation_id = ?"
156        ))
157        .bind(conversation_id)
158        .fetch_optional(self.pool())
159        .await?;
160
161        Ok(id.unwrap_or(0))
162    }
163
164    /// Upsert the last extracted message id for a given conversation in `trajectory_meta`.
165    ///
166    /// # Errors
167    ///
168    /// Returns an error if the query fails.
169    pub async fn set_trajectory_last_extracted_message_id(
170        &self,
171        conversation_id: i64,
172        message_id: i64,
173    ) -> Result<(), MemoryError> {
174        let now = <ActiveDialect as zeph_db::dialect::Dialect>::NOW;
175        let raw = format!(
176            "INSERT INTO trajectory_meta (conversation_id, last_extracted_message_id, updated_at)
177             VALUES (?, ?, {now})
178             ON CONFLICT(conversation_id) DO UPDATE SET
179                 last_extracted_message_id = excluded.last_extracted_message_id,
180                 updated_at = {now}"
181        );
182        let query_sql = zeph_db::rewrite_placeholders(&raw);
183        query(sqlx::AssertSqlSafe(query_sql))
184            .bind(conversation_id)
185            .bind(message_id)
186            .execute(self.pool())
187            .await?;
188
189        Ok(())
190    }
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    async fn make_store() -> SqliteStore {
198        SqliteStore::with_pool_size(":memory:", 1)
199            .await
200            .expect("in-memory store")
201    }
202
203    #[tokio::test]
204    async fn insert_trajectory_entry_basic() {
205        let store = make_store().await;
206        let id = store
207            .insert_trajectory_entry(NewTrajectoryEntry {
208                conversation_id: None,
209                turn_index: 1,
210                kind: "procedural",
211                intent: "read a file",
212                outcome: "file read successfully",
213                tools_used: "[\"read_file\"]",
214                confidence: 0.9,
215            })
216            .await
217            .expect("insert");
218        assert!(id > 0);
219        assert_eq!(store.count_trajectory_entries().await.expect("count"), 1);
220    }
221
222    #[tokio::test]
223    async fn load_trajectory_entries_kind_filter() {
224        let store = make_store().await;
225        store
226            .insert_trajectory_entry(NewTrajectoryEntry {
227                conversation_id: None,
228                turn_index: 1,
229                kind: "procedural",
230                intent: "build a crate",
231                outcome: "built ok",
232                tools_used: "[\"shell\"]",
233                confidence: 0.8,
234            })
235            .await
236            .expect("insert procedural");
237        store
238            .insert_trajectory_entry(NewTrajectoryEntry {
239                conversation_id: None,
240                turn_index: 2,
241                kind: "episodic",
242                intent: "fixed a bug",
243                outcome: "patch applied",
244                tools_used: "[\"shell\"]",
245                confidence: 0.7,
246            })
247            .await
248            .expect("insert episodic");
249
250        let procedural = store
251            .load_trajectory_entries(Some("procedural"), 10)
252            .await
253            .expect("load procedural");
254        assert_eq!(procedural.len(), 1);
255        assert_eq!(procedural[0].kind, "procedural");
256
257        let all = store
258            .load_trajectory_entries(None, 10)
259            .await
260            .expect("load all");
261        assert_eq!(all.len(), 2);
262    }
263
264    #[tokio::test]
265    async fn trajectory_meta_per_conversation_tracking() {
266        let store = make_store().await;
267        // Two conversations — create real rows to satisfy FK.
268        let cid1 = store.create_conversation().await.expect("create conv 1").0;
269        let cid2 = store.create_conversation().await.expect("create conv 2").0;
270
271        // Initial value is 0 for both.
272        assert_eq!(
273            store
274                .trajectory_last_extracted_message_id(cid1)
275                .await
276                .expect("meta 1"),
277            0
278        );
279        assert_eq!(
280            store
281                .trajectory_last_extracted_message_id(cid2)
282                .await
283                .expect("meta 2"),
284            0
285        );
286
287        // Set for conv 1 — must not affect conv 2.
288        store
289            .set_trajectory_last_extracted_message_id(cid1, 42)
290            .await
291            .expect("set meta 1");
292
293        assert_eq!(
294            store
295                .trajectory_last_extracted_message_id(cid1)
296                .await
297                .expect("meta 1 after"),
298            42
299        );
300        assert_eq!(
301            store
302                .trajectory_last_extracted_message_id(cid2)
303                .await
304                .expect("meta 2 after"),
305            0,
306            "conv2 must remain 0 after conv1 update"
307        );
308
309        // Update conv 2 independently.
310        store
311            .set_trajectory_last_extracted_message_id(cid2, 99)
312            .await
313            .expect("set meta 2");
314        assert_eq!(
315            store
316                .trajectory_last_extracted_message_id(cid2)
317                .await
318                .expect("meta 2 final"),
319            99
320        );
321    }
322
323    #[tokio::test]
324    async fn trajectory_meta_upsert_idempotent() {
325        let store = make_store().await;
326        let cid = store.create_conversation().await.expect("create conv").0;
327
328        store
329            .set_trajectory_last_extracted_message_id(cid, 10)
330            .await
331            .expect("first set");
332        store
333            .set_trajectory_last_extracted_message_id(cid, 20)
334            .await
335            .expect("second set");
336
337        assert_eq!(
338            store
339                .trajectory_last_extracted_message_id(cid)
340                .await
341                .expect("final"),
342            20
343        );
344    }
345}