1use 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#[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#[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 pub async fn insert_trajectory_entry(
46 &self,
47 entry: NewTrajectoryEntry<'_>,
48 ) -> Result<i64, MemoryError> {
49 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 pub async fn load_trajectory_entries(
80 &self,
81 kind: Option<&str>,
82 limit: usize,
83 ) -> Result<Vec<TrajectoryEntryRow>, MemoryError> {
84 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 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 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 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 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 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 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 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}