Skip to main content

mw_memory/
sqlite.rs

1//! The one loader: build scorable [`Memory`] items from a MemoryWhale SQLite DB.
2//!
3//! Both surfaces call this — the desktop app (`documents` + `command_runs` +
4//! `agent_turns`) and the CLI (`sessions` + `command_runs` + `bookmarks`). The
5//! two databases have different tables, so every source is queried
6//! independently and a missing table is treated as zero rows (not an error).
7//! That keeps a single loader honest across both schemas instead of two copies
8//! drifting apart.
9//!
10//! Ids are namespaced per source so `explain(id)` stays stable and unique
11//! across sources; [`decode_id`] recovers the source + original row id for
12//! display (e.g. `mw replay <id>` / `mw show <id>`).
13
14use 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/// Which store a memory came from, plus its original per-table row id.
27#[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
48/// Recover the source and original row id from a namespaced memory id.
49pub 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
69/// Load everything MemoryWhale remembers as scorable memories, tolerant of any
70/// source table being absent (so it serves both the desktop and CLI schemas).
71pub 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
81/// Documents / notes (desktop). `text` = title + content.
82fn 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
115/// Command runs (both). Reinforcement = how often the same command recurs;
116/// failures are more important than successes.
117fn 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
158/// Agent conversation turns (desktop; written by Delphin / hooks).
159fn 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
201/// Remembered lessons / bookmarks (CLI `mw mark` / `mw remember`).
202fn bookmarks(conn: &Connection) -> Vec<Memory> {
203    // Review mode is enforced at write time (agent notes land with approved=0),
204    // so every reader just filters approved=1 — one rule, no config lookup here.
205    // Older DBs predating the provenance migration have no `approved` column;
206    // there the prepare fails and we fall back to loading everything.
207    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
242/// Recorded terminal sessions (CLI). `text` = notes + cleaned transcript, so
243/// the same content `mw search` used to LIKE-match still drives similarity.
244fn 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        // 2 command_runs + 1 bookmark; documents/agent_turns/sessions absent.
302        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        // recurring command is reinforced.
306        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    // The CLI (`mw search`, `mw-mcp`) and the desktop Recall panel both build
320    // their engine from *this* loader and score with a caller-supplied `now`.
321    // So for a fixed fixture DB and a fixed `now` they must produce identical
322    // rankings. This asserts that agreement directly, deterministically.
323    #[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        // Two independently constructed engines standing in for the two
333        // surfaces; both go through the shared loader with the same `now`.
334        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        // The bookmark that literally names the linker failure should top it.
345        assert_eq!(decode_id(cli_rank[0].0).0, Source::Note);
346    }
347}