Skip to main content

agentdb/memory/
graph.rs

1use crate::error::{AgentDbError, Result};
2use crate::schema::now_ms;
3use rusqlite::params;
4use rusqlite::Connection;
5use serde_json::Value;
6use std::sync::{Arc, Mutex};
7
8/// A typed node in the memory graph.
9#[derive(Debug, Clone)]
10pub struct Node {
11    /// Unique identifier for this node.
12    pub id: String,
13    /// Semantic type label (e.g. `"session"`, `"thought"`, `"tool"`).
14    pub kind: String,
15    /// Arbitrary JSON payload attached to the node.
16    pub data: Option<Value>,
17    /// Unix-millisecond timestamp when the node was first created.
18    pub created_at: i64,
19    /// Unix-millisecond timestamp of the most recent update.
20    pub updated_at: i64,
21}
22
23/// A directed, weighted edge between two nodes in the memory graph.
24#[derive(Debug, Clone)]
25pub struct Edge {
26    /// ID of the source node.
27    pub src: String,
28    /// ID of the destination node.
29    pub dst: String,
30    /// Semantic relationship label (e.g. `"recalled"`, `"leads_to"`).
31    pub relation: String,
32    /// Importance or strength of the relationship, conventionally in `[0.0, 1.0]`.
33    pub weight: f64,
34    /// Unix-millisecond timestamp when the edge was created or last updated.
35    pub created_at: i64,
36}
37
38/// Options controlling how the memory graph is traversed.
39#[derive(Debug, Clone, Default)]
40pub struct TraversalOptions {
41    /// If set, only follow edges whose relation label matches this string.
42    pub relation: Option<String>,
43    /// Maximum number of hops from the anchor node.
44    pub max_depth: usize,
45    /// Discard edges whose weight is below this threshold.
46    pub min_weight: Option<f64>,
47}
48
49/// A single node returned by a graph traversal, with path metadata.
50#[derive(Debug, Clone)]
51pub struct TraversalResult {
52    /// The reached node.
53    pub node: Node,
54    /// Number of hops from the anchor node.
55    pub depth: usize,
56    /// Weight of the edge that connected this node to its parent in the traversal.
57    pub weight: f64,
58}
59
60/// In-process memory graph backed by a SQLite WAL database.
61pub struct MemoryGraph {
62    conn: Arc<Mutex<Connection>>,
63}
64
65impl MemoryGraph {
66    pub(crate) fn new(conn: Arc<Mutex<Connection>>) -> Self {
67        Self { conn }
68    }
69
70    /// Add or update a node. If a node with this `id` already exists it is overwritten
71    /// in place (kind, data, and updated_at are refreshed; created_at is preserved).
72    pub fn add_node(&self, id: &str, kind: &str, data: Option<Value>) -> Result<()> {
73        let conn = self.conn.lock().unwrap();
74        let data_str = data.as_ref().map(|d| d.to_string());
75        let now = now_ms();
76        conn.execute(
77            "INSERT INTO _adb_nodes (id, kind, data, created_at, updated_at)
78             VALUES (?1, ?2, ?3, ?4, ?5)
79             ON CONFLICT(id) DO UPDATE SET
80               kind       = excluded.kind,
81               data       = excluded.data,
82               updated_at = excluded.updated_at",
83            params![id, kind, data_str, now, now],
84        )?;
85        Ok(())
86    }
87
88    /// Retrieve a node by its ID.
89    ///
90    /// Returns [`AgentDbError::NodeNotFound`] if no node with that ID exists.
91    pub fn get_node(&self, id: &str) -> Result<Node> {
92        let conn = self.conn.lock().unwrap();
93        conn.query_row(
94            "SELECT id, kind, data, created_at, updated_at FROM _adb_nodes WHERE id = ?1",
95            params![id],
96            |row| {
97                let data_str: Option<String> = row.get(2)?;
98                Ok(Node {
99                    id: row.get(0)?,
100                    kind: row.get(1)?,
101                    data: data_str
102                        .as_deref()
103                        .and_then(|s| serde_json::from_str(s).ok()),
104                    created_at: row.get(3)?,
105                    updated_at: row.get(4)?,
106                })
107            },
108        )
109        .map_err(|_| AgentDbError::NodeNotFound(id.to_string()))
110    }
111
112    /// Remove a node by its ID. Associated edges are also deleted via ON DELETE CASCADE.
113    pub fn delete_node(&self, id: &str) -> Result<()> {
114        let conn = self.conn.lock().unwrap();
115        conn.execute("DELETE FROM _adb_nodes WHERE id = ?1", params![id])?;
116        Ok(())
117    }
118
119    /// Add or update a directed edge `src → dst` with the given `relation` label and `weight`.
120    ///
121    /// Both `src` and `dst` must already exist; returns [`AgentDbError::NodeNotFound`] otherwise.
122    /// If an edge with the same `(src, dst, relation)` triple already exists its weight is updated.
123    pub fn add_edge(&self, src: &str, dst: &str, relation: &str, weight: f64) -> Result<()> {
124        self.get_node(src)?;
125        self.get_node(dst)?;
126        let conn = self.conn.lock().unwrap();
127        conn.execute(
128            "INSERT INTO _adb_edges (src, dst, relation, weight, created_at)
129             VALUES (?1, ?2, ?3, ?4, ?5)
130             ON CONFLICT(src, dst, relation) DO UPDATE SET
131               weight     = excluded.weight,
132               created_at = excluded.created_at",
133            params![src, dst, relation, weight, now_ms()],
134        )?;
135        Ok(())
136    }
137
138    /// Remove the edge `src → dst` with the given `relation`.
139    ///
140    /// Returns [`AgentDbError::EdgeNotFound`] if no such edge exists.
141    pub fn delete_edge(&self, src: &str, dst: &str, relation: &str) -> Result<()> {
142        let conn = self.conn.lock().unwrap();
143        let changed = conn.execute(
144            "DELETE FROM _adb_edges WHERE src = ?1 AND dst = ?2 AND relation = ?3",
145            params![src, dst, relation],
146        )?;
147        if changed == 0 {
148            return Err(AgentDbError::EdgeNotFound {
149                src: src.to_string(),
150                dst: dst.to_string(),
151            });
152        }
153        Ok(())
154    }
155
156    /// Traverse the graph from `node_id` and return all reachable nodes within the
157    /// constraints defined by `opts`.
158    ///
159    /// Uses a recursive Common Table Expression (CTE) for efficient SQLite-side
160    /// traversal. Results are ordered by ascending depth, then descending edge weight.
161    pub fn neighbors(&self, node_id: &str, opts: TraversalOptions) -> Result<Vec<TraversalResult>> {
162        let conn = self.conn.lock().unwrap();
163        let max_depth = opts.max_depth.max(1) as i64;
164        let min_weight = opts.min_weight.unwrap_or(0.0);
165
166        let results = if let Some(ref relation) = opts.relation {
167            let sql = "
168                WITH RECURSIVE traverse(node_id, depth, weight) AS (
169                    SELECT dst, 1, weight
170                    FROM _adb_edges
171                    WHERE src = ?1 AND relation = ?2 AND weight >= ?4
172                    UNION ALL
173                    SELECT e.dst, t.depth + 1, e.weight
174                    FROM _adb_edges e
175                    JOIN traverse t ON e.src = t.node_id
176                    WHERE t.depth < ?3
177                      AND e.relation = ?2
178                      AND e.weight >= ?4
179                )
180                SELECT DISTINCT n.id, n.kind, n.data, n.created_at, n.updated_at,
181                       t.depth, t.weight
182                FROM traverse t
183                JOIN _adb_nodes n ON n.id = t.node_id
184                ORDER BY t.depth ASC, t.weight DESC
185            ";
186            let mut stmt = conn.prepare(sql)?;
187            let rows =
188                stmt.query_map(params![node_id, relation, max_depth, min_weight], parse_row)?;
189            rows.map(|r| r.map_err(AgentDbError::Sqlite))
190                .collect::<Result<Vec<_>>>()?
191        } else {
192            let sql = "
193                WITH RECURSIVE traverse(node_id, depth, weight) AS (
194                    SELECT dst, 1, weight
195                    FROM _adb_edges
196                    WHERE src = ?1 AND weight >= ?3
197                    UNION ALL
198                    SELECT e.dst, t.depth + 1, e.weight
199                    FROM _adb_edges e
200                    JOIN traverse t ON e.src = t.node_id
201                    WHERE t.depth < ?2
202                      AND e.weight >= ?3
203                )
204                SELECT DISTINCT n.id, n.kind, n.data, n.created_at, n.updated_at,
205                       t.depth, t.weight
206                FROM traverse t
207                JOIN _adb_nodes n ON n.id = t.node_id
208                ORDER BY t.depth ASC, t.weight DESC
209            ";
210            let mut stmt = conn.prepare(sql)?;
211            let rows = stmt.query_map(params![node_id, max_depth, min_weight], parse_row)?;
212            rows.map(|r| r.map_err(AgentDbError::Sqlite))
213                .collect::<Result<Vec<_>>>()?
214        };
215
216        Ok(results)
217    }
218
219    /// Return all nodes whose `kind` field matches the given string.
220    pub fn nodes_by_kind(&self, kind: &str) -> Result<Vec<Node>> {
221        let conn = self.conn.lock().unwrap();
222        let mut stmt = conn.prepare(
223            "SELECT id, kind, data, created_at, updated_at FROM _adb_nodes WHERE kind = ?1",
224        )?;
225        let rows = stmt.query_map(params![kind], |row| {
226            let data_str: Option<String> = row.get(2)?;
227            Ok(Node {
228                id: row.get(0)?,
229                kind: row.get(1)?,
230                data: data_str
231                    .as_deref()
232                    .and_then(|s| serde_json::from_str(s).ok()),
233                created_at: row.get(3)?,
234                updated_at: row.get(4)?,
235            })
236        })?;
237        rows.map(|r| r.map_err(AgentDbError::Sqlite)).collect()
238    }
239
240    /// Return the total node and edge counts as `(nodes, edges)`.
241    pub fn stats(&self) -> Result<(i64, i64)> {
242        let conn = self.conn.lock().unwrap();
243        let nodes: i64 = conn.query_row("SELECT COUNT(*) FROM _adb_nodes", [], |r| r.get(0))?;
244        let edges: i64 = conn.query_row("SELECT COUNT(*) FROM _adb_edges", [], |r| r.get(0))?;
245        Ok((nodes, edges))
246    }
247}
248
249fn parse_row(row: &rusqlite::Row) -> rusqlite::Result<TraversalResult> {
250    let data_str: Option<String> = row.get(2)?;
251    Ok(TraversalResult {
252        node: Node {
253            id: row.get(0)?,
254            kind: row.get(1)?,
255            data: data_str
256                .as_deref()
257                .and_then(|s| serde_json::from_str(s).ok()),
258            created_at: row.get(3)?,
259            updated_at: row.get(4)?,
260        },
261        depth: row.get::<_, i64>(5)? as usize,
262        weight: row.get(6)?,
263    })
264}