1use std::collections::HashMap;
15
16use chrono::{DateTime, Utc};
17use rusqlite::Connection;
18
19use crate::Memory;
20
21const CMD_NS: i64 = 1_000_000_000;
22const TURN_NS: i64 = 2_000_000_000;
23const NOTE_NS: i64 = 3_000_000_000;
24const SESSION_NS: i64 = 4_000_000_000;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum Source {
29 Document,
30 Command,
31 Conversation,
32 Note,
33 Session,
34}
35
36impl Source {
37 pub fn tag(self) -> &'static str {
38 match self {
39 Source::Document => "document",
40 Source::Command => "command",
41 Source::Conversation => "conversation",
42 Source::Note => "note",
43 Source::Session => "session",
44 }
45 }
46}
47
48pub fn decode_id(id: i64) -> (Source, i64) {
50 if id >= SESSION_NS {
51 (Source::Session, id - SESSION_NS)
52 } else if id >= NOTE_NS {
53 (Source::Note, id - NOTE_NS)
54 } else if id >= TURN_NS {
55 (Source::Conversation, id - TURN_NS)
56 } else if id >= CMD_NS {
57 (Source::Command, id - CMD_NS)
58 } else {
59 (Source::Document, id)
60 }
61}
62
63fn parse_ts(ts: &str) -> DateTime<Utc> {
64 DateTime::parse_from_rfc3339(ts)
65 .map(|d| d.with_timezone(&Utc))
66 .unwrap_or_else(|_| Utc::now())
67}
68
69pub fn load_memories(conn: &Connection) -> Vec<Memory> {
72 let mut mems = Vec::new();
73 mems.extend(documents(conn));
74 mems.extend(command_runs(conn));
75 mems.extend(agent_turns(conn));
76 mems.extend(bookmarks(conn));
77 mems.extend(sessions(conn));
78 mems
79}
80
81fn documents(conn: &Connection) -> Vec<Memory> {
83 let Ok(mut stmt) =
84 conn.prepare("SELECT id, title, content, source_type, created_at FROM documents")
85 else {
86 return Vec::new();
87 };
88 let rows = stmt.query_map([], |r| {
89 Ok((
90 r.get::<_, i64>(0)?,
91 r.get::<_, String>(1)?,
92 r.get::<_, String>(2)?,
93 r.get::<_, String>(3)?,
94 r.get::<_, String>(4)?,
95 ))
96 });
97 let Ok(rows) = rows else { return Vec::new() };
98 rows.flatten()
99 .map(|(id, title, content, source_type, created)| {
100 let when = parse_ts(&created);
101 Memory {
102 id,
103 text: format!("{title}. {content}"),
104 created_at: when,
105 last_used: when,
106 mentions: 1,
107 importance: 0.5,
108 tags: vec!["document".into(), source_type],
109 embedding: None,
110 }
111 })
112 .collect()
113}
114
115fn command_runs(conn: &Connection) -> Vec<Memory> {
118 let Ok(mut stmt) = conn.prepare(
119 "SELECT id, command, argv_json, notes, stderr, exit_code, created_at FROM command_runs",
120 ) else {
121 return Vec::new();
122 };
123 let rows = stmt.query_map([], |r| {
124 Ok((
125 r.get::<_, i64>(0)?,
126 r.get::<_, String>(1)?,
127 r.get::<_, String>(2)?,
128 r.get::<_, String>(3)?,
129 r.get::<_, String>(4)?,
130 r.get::<_, Option<i64>>(5)?,
131 r.get::<_, String>(6)?,
132 ))
133 });
134 let Ok(rows) = rows else { return Vec::new() };
135 let runs: Vec<_> = rows.flatten().collect();
136 let mut counts: HashMap<String, u32> = HashMap::new();
137 for (_, cmd, ..) in &runs {
138 *counts.entry(cmd.to_lowercase()).or_insert(0) += 1;
139 }
140 runs.into_iter()
141 .map(|(id, command, argv_json, notes, stderr, exit_code, created)| {
142 let when = parse_ts(&created);
143 let ok = exit_code == Some(0);
144 Memory {
145 id: CMD_NS + id,
146 text: format!("{command} {argv_json} {notes} {stderr}"),
147 created_at: when,
148 last_used: when,
149 mentions: *counts.get(&command.to_lowercase()).unwrap_or(&1),
150 importance: if exit_code.unwrap_or(0) != 0 { 0.65 } else { 0.4 },
151 tags: vec!["command".into(), if ok { "ok".into() } else { "error".into() }],
152 embedding: None,
153 }
154 })
155 .collect()
156}
157
158fn agent_turns(conn: &Connection) -> Vec<Memory> {
160 let Ok(mut stmt) = conn.prepare("SELECT id, ts, direction, text FROM agent_turns") else {
161 return Vec::new();
162 };
163 let rows = stmt.query_map([], |r| {
164 Ok((
165 r.get::<_, i64>(0)?,
166 r.get::<_, String>(1)?,
167 r.get::<_, String>(2)?,
168 r.get::<_, String>(3)?,
169 ))
170 });
171 let Ok(rows) = rows else { return Vec::new() };
172 let turns: Vec<_> = rows.flatten().filter(|(_, _, _, t)| !t.trim().is_empty()).collect();
173 let mut counts: HashMap<String, u32> = HashMap::new();
174 for (_, _, _, t) in &turns {
175 *counts.entry(t.trim().to_lowercase()).or_insert(0) += 1;
176 }
177 turns
178 .into_iter()
179 .map(|(id, ts, direction, text)| {
180 let when = parse_ts(&ts);
181 let importance = match direction.as_str() {
182 "user" => 0.6_f32,
183 "agent" => 0.45,
184 _ => 0.3,
185 };
186 let mentions = *counts.get(&text.trim().to_lowercase()).unwrap_or(&1);
187 Memory {
188 id: TURN_NS + id,
189 text,
190 created_at: when,
191 last_used: when,
192 mentions,
193 importance,
194 tags: vec!["conversation".into(), direction],
195 embedding: None,
196 }
197 })
198 .collect()
199}
200
201fn bookmarks(conn: &Connection) -> Vec<Memory> {
203 let mut stmt = match conn
208 .prepare("SELECT id, label, created_at FROM bookmarks WHERE approved = 1")
209 {
210 Ok(stmt) => stmt,
211 Err(_) => match conn.prepare("SELECT id, label, created_at FROM bookmarks") {
212 Ok(stmt) => stmt,
213 Err(_) => return Vec::new(),
214 },
215 };
216 let rows = stmt.query_map([], |r| {
217 Ok((
218 r.get::<_, i64>(0)?,
219 r.get::<_, String>(1)?,
220 r.get::<_, String>(2)?,
221 ))
222 });
223 let Ok(rows) = rows else { return Vec::new() };
224 rows.flatten()
225 .filter(|(_, label, _)| !label.trim().is_empty())
226 .map(|(id, label, created)| {
227 let when = parse_ts(&created);
228 Memory {
229 id: NOTE_NS + id,
230 text: label,
231 created_at: when,
232 last_used: when,
233 mentions: 1,
234 importance: 0.55,
235 tags: vec!["note".into()],
236 embedding: None,
237 }
238 })
239 .collect()
240}
241
242fn sessions(conn: &Connection) -> Vec<Memory> {
245 let Ok(mut stmt) = conn.prepare("SELECT id, notes, transcript, started_at FROM sessions") else {
246 return Vec::new();
247 };
248 let rows = stmt.query_map([], |r| {
249 Ok((
250 r.get::<_, i64>(0)?,
251 r.get::<_, String>(1)?,
252 r.get::<_, String>(2)?,
253 r.get::<_, String>(3)?,
254 ))
255 });
256 let Ok(rows) = rows else { return Vec::new() };
257 rows.flatten()
258 .filter_map(|(id, notes, transcript, started)| {
259 let text = format!("{notes}\n{transcript}").trim().to_string();
260 if text.is_empty() {
261 return None;
262 }
263 let when = parse_ts(&started);
264 Some(Memory {
265 id: SESSION_NS + id,
266 text,
267 created_at: when,
268 last_used: when,
269 mentions: 1,
270 importance: 0.5,
271 tags: vec!["session".into()],
272 embedding: None,
273 })
274 })
275 .collect()
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281
282 fn fixture() -> Connection {
283 let conn = Connection::open_in_memory().unwrap();
284 conn.execute_batch(
285 "CREATE TABLE command_runs (id INTEGER PRIMARY KEY, command TEXT, argv_json TEXT,
286 notes TEXT, stderr TEXT, exit_code INTEGER, created_at TEXT);
287 CREATE TABLE bookmarks (id INTEGER PRIMARY KEY, label TEXT, created_at TEXT);
288 INSERT INTO command_runs VALUES
289 (1, 'cargo', '[\"cargo\",\"build\"]', '', 'error: linker failed', 1, '2026-06-20T12:00:00+00:00'),
290 (2, 'cargo', '[\"cargo\",\"build\"]', '', '', 0, '2026-06-26T12:00:00+00:00');
291 INSERT INTO bookmarks VALUES
292 (1, 'the linker failure was a missing -lstdc++', '2026-06-25T12:00:00+00:00');",
293 )
294 .unwrap();
295 conn
296 }
297
298 #[test]
299 fn tolerates_missing_tables_and_namespaces_ids() {
300 let mems = load_memories(&fixture());
301 assert_eq!(mems.len(), 3);
303 assert!(mems.iter().any(|m| decode_id(m.id) == (Source::Command, 1)));
304 assert!(mems.iter().any(|m| decode_id(m.id) == (Source::Note, 1)));
305 let cmd = mems.iter().find(|m| decode_id(m.id) == (Source::Command, 1)).unwrap();
307 assert_eq!(cmd.mentions, 2);
308 }
309
310 #[test]
311 fn decode_roundtrips_each_source() {
312 assert_eq!(decode_id(5), (Source::Document, 5));
313 assert_eq!(decode_id(CMD_NS + 5), (Source::Command, 5));
314 assert_eq!(decode_id(TURN_NS + 5), (Source::Conversation, 5));
315 assert_eq!(decode_id(NOTE_NS + 5), (Source::Note, 5));
316 assert_eq!(decode_id(SESSION_NS + 5), (Source::Session, 5));
317 }
318
319 #[test]
324 fn cli_and_desktop_rank_identically_for_fixed_now() {
325 use crate::engine::{BuiltinEngine, MemoryEngine};
326 use crate::Query;
327 use chrono::{TimeZone, Utc};
328
329 let conn = fixture();
330 let now = Utc.with_ymd_and_hms(2026, 6, 27, 12, 0, 0).unwrap();
331
332 let cli = BuiltinEngine::new(load_memories(&conn));
335 let desktop = BuiltinEngine::new(load_memories(&conn));
336 let q = Query::new("linker failure", now);
337
338 let cli_rank: Vec<(i64, u32)> =
339 cli.retrieve(&q, 20).iter().map(|s| (s.memory.id, s.percent())).collect();
340 let desktop_rank: Vec<(i64, u32)> =
341 desktop.retrieve(&q, 20).iter().map(|s| (s.memory.id, s.percent())).collect();
342
343 assert_eq!(cli_rank, desktop_rank, "CLI and desktop must rank identically");
344 assert_eq!(decode_id(cli_rank[0].0).0, Source::Note);
346 }
347}