Skip to main content

apollo/memory/
surreal.rs

1//! SurrealDB-backed memory storage using the local RocksDB engine.
2
3use anyhow::Result;
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use surrealdb::engine::local::RocksDb;
9use surrealdb::Surreal;
10use tokio::sync::OnceCell;
11
12use super::traits::*;
13
14#[derive(Clone)]
15pub struct SurrealMemory {
16    path: PathBuf,
17    cell: Arc<OnceCell<Surreal<surrealdb::engine::local::Db>>>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21struct MemoryRow {
22    namespace: String,
23    key: String,
24    value: String,
25    metadata: Option<serde_json::Value>,
26    created_at: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize)]
30struct ConversationRow {
31    chat_id: String,
32    sender_id: String,
33    role: String,
34    content: String,
35    seq: i64,
36    created_at: String,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
40struct StickerRow {
41    sticker_id: String,
42    file_id: String,
43    description: String,
44    analyzed_at: String,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
48struct EmbeddingRow {
49    namespace: String,
50    key: String,
51    vector: Vec<f32>,
52    text: String,
53    created_at: String,
54    #[serde(default)]
55    dim: Option<i64>,
56    #[serde(default)]
57    model: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61struct FileIndexRow {
62    path: String,
63    hash: String,
64    last_indexed: String,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68struct ChunkRow {
69    file_path: String,
70    start_line: u32,
71    end_line: u32,
72    content: String,
73    embedding: Option<Vec<f32>>,
74    created_at: String,
75}
76
77impl SurrealMemory {
78    pub async fn db(&self) -> Result<Surreal<surrealdb::engine::local::Db>> {
79        let db = self
80            .cell
81            .get_or_try_init(|| async {
82                let db = Surreal::new::<RocksDb>(self.path.as_path()).await?;
83                db.use_ns("claw").use_db("memory").await?;
84                db.query(SCHEMA_SQL).await?;
85                Ok::<_, anyhow::Error>(db)
86            })
87            .await?;
88        Ok(db.clone())
89    }
90
91    pub async fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
92        let path = path.as_ref().to_path_buf();
93        if let Some(parent) = path.parent() {
94            if !parent.as_os_str().is_empty() {
95                tokio::fs::create_dir_all(parent).await?;
96            }
97        }
98        Ok(Self {
99            path,
100            cell: Arc::new(OnceCell::new()),
101        })
102    }
103
104    fn memory_id(namespace: &str, key: &str) -> String {
105        format!("{namespace}::{key}")
106    }
107}
108
109const SCHEMA_SQL: &str = r#"
110    DEFINE TABLE IF NOT EXISTS memories SCHEMALESS;
111    DEFINE FIELD IF NOT EXISTS namespace ON memories TYPE string;
112    DEFINE FIELD IF NOT EXISTS key ON memories TYPE string;
113    DEFINE FIELD IF NOT EXISTS value ON memories TYPE string;
114    DEFINE FIELD IF NOT EXISTS metadata ON memories TYPE option<object>;
115    DEFINE FIELD IF NOT EXISTS created_at ON memories TYPE string;
116    DEFINE INDEX IF NOT EXISTS memory_lookup_idx ON memories FIELDS namespace, key UNIQUE;
117    DEFINE INDEX IF NOT EXISTS memory_namespace_idx ON memories FIELDS namespace;
118
119    DEFINE TABLE IF NOT EXISTS conversations SCHEMALESS;
120    DEFINE FIELD IF NOT EXISTS chat_id ON conversations TYPE string;
121    DEFINE FIELD IF NOT EXISTS sender_id ON conversations TYPE string;
122    DEFINE FIELD IF NOT EXISTS role ON conversations TYPE string;
123    DEFINE FIELD IF NOT EXISTS content ON conversations TYPE string;
124    DEFINE FIELD IF NOT EXISTS seq ON conversations TYPE int;
125    DEFINE FIELD IF NOT EXISTS created_at ON conversations TYPE string;
126    DEFINE INDEX IF NOT EXISTS conversation_chat_idx ON conversations FIELDS chat_id, seq;
127
128    DEFINE TABLE IF NOT EXISTS sticker_cache SCHEMALESS;
129    DEFINE FIELD IF NOT EXISTS sticker_id ON sticker_cache TYPE string;
130    DEFINE FIELD IF NOT EXISTS file_id ON sticker_cache TYPE string;
131    DEFINE FIELD IF NOT EXISTS description ON sticker_cache TYPE string;
132    DEFINE FIELD IF NOT EXISTS analyzed_at ON sticker_cache TYPE string;
133    DEFINE INDEX IF NOT EXISTS sticker_id_idx ON sticker_cache FIELDS sticker_id UNIQUE;
134
135    DEFINE TABLE IF NOT EXISTS embeddings SCHEMALESS;
136    DEFINE FIELD IF NOT EXISTS namespace ON embeddings TYPE string;
137    DEFINE FIELD IF NOT EXISTS key ON embeddings TYPE string;
138    DEFINE FIELD IF NOT EXISTS vector ON embeddings TYPE array;
139    DEFINE FIELD IF NOT EXISTS text ON embeddings TYPE string;
140    DEFINE FIELD IF NOT EXISTS created_at ON embeddings TYPE string;
141    DEFINE FIELD IF NOT EXISTS dim ON embeddings TYPE option<int>;
142    DEFINE FIELD IF NOT EXISTS model ON embeddings TYPE option<string>;
143    DEFINE INDEX IF NOT EXISTS embedding_lookup_idx ON embeddings FIELDS namespace, key UNIQUE;
144    DEFINE INDEX IF NOT EXISTS embedding_namespace_idx ON embeddings FIELDS namespace;
145    DEFINE INDEX IF NOT EXISTS embedding_namespace_dim_idx ON embeddings FIELDS namespace, dim;
146    -- No MTREE index on `vector`, and this is now settled by measurement, not
147    -- by assumption. See `bench_mtree_feasibility` and
148    -- `bench_search_embeddings_scaling` in this file; numbers from a release
149    -- build on macOS arm64 at dim 1536.
150    --
151    -- `DEFINE INDEX ... MTREE DIMENSION 1536 DIST COSINE` is accepted, but:
152    --   1. It locks the whole table to that one dimension. Storing a 3-dim
153    --      vector afterwards fails with "Incorrect vector dimension (3).
154    --      Expected a vector of 1536 dimension." So "one index per distinct
155    --      dimension" is not possible on a shared table at all — it would
156    --      need dimension-partitioned tables, not just extra indexes.
157    --   2. It is ~25x more expensive to write: 29.76ms/row indexed versus
158    --      ~1.2ms/row unindexed.
159    --   3. It does not even pay off on reads. At 2000 rows,
160    --      `vector <|10,COSINE|> $q` had a 2558ms median against 961ms for
161    --      the brute-force scan — 2.7x slower.
162    --
163    -- The scan itself is not cheap (~0.27ms per candidate row, so ~30ms at
164    -- 100 rows and ~2.5s at 10k), but the cost is materializing a
165    -- 1536-element array as SurrealDB `Value`s per row, not the cosine
166    -- arithmetic. Scoring in-engine with
167    -- `vector::similarity::cosine(...) ORDER BY score LIMIT k` was measured
168    -- too and is ~1.6x slower still, because it walks the same arrays.
169    -- Anything that actually fixes this has to make a row cheaper to read
170    -- (a compact encoding rather than an array of `Value`s), which MTREE
171    -- does not do.
172
173    DEFINE TABLE IF NOT EXISTS files SCHEMALESS;
174    DEFINE FIELD IF NOT EXISTS path ON files TYPE string;
175    DEFINE FIELD IF NOT EXISTS hash ON files TYPE string;
176    DEFINE FIELD IF NOT EXISTS last_indexed ON files TYPE string;
177    DEFINE INDEX IF NOT EXISTS file_path_idx ON files FIELDS path UNIQUE;
178
179    DEFINE TABLE IF NOT EXISTS chunks SCHEMALESS;
180    DEFINE FIELD IF NOT EXISTS file_path ON chunks TYPE string;
181    DEFINE FIELD IF NOT EXISTS start_line ON chunks TYPE int;
182    DEFINE FIELD IF NOT EXISTS end_line ON chunks TYPE int;
183    DEFINE FIELD IF NOT EXISTS content ON chunks TYPE string;
184    DEFINE FIELD IF NOT EXISTS embedding ON chunks TYPE option<array>;
185    DEFINE FIELD IF NOT EXISTS created_at ON chunks TYPE string;
186    DEFINE INDEX IF NOT EXISTS chunk_file_idx ON chunks FIELDS file_path;
187
188    DEFINE TABLE IF NOT EXISTS cron_jobs SCHEMALESS;
189    DEFINE FIELD IF NOT EXISTS name ON cron_jobs TYPE string;
190    DEFINE FIELD IF NOT EXISTS schedule ON cron_jobs TYPE string;
191    DEFINE FIELD IF NOT EXISTS task ON cron_jobs TYPE string;
192    DEFINE FIELD IF NOT EXISTS channel ON cron_jobs TYPE string;
193    DEFINE FIELD IF NOT EXISTS model ON cron_jobs TYPE string;
194    DEFINE FIELD IF NOT EXISTS enabled ON cron_jobs TYPE bool;
195    DEFINE FIELD IF NOT EXISTS last_run ON cron_jobs TYPE option<string>;
196    DEFINE FIELD IF NOT EXISTS next_run ON cron_jobs TYPE option<string>;
197    DEFINE INDEX IF NOT EXISTS cron_name_idx ON cron_jobs FIELDS name UNIQUE;
198
199    DEFINE TABLE IF NOT EXISTS memory_nodes SCHEMALESS;
200    DEFINE FIELD IF NOT EXISTS id ON memory_nodes TYPE string;
201    DEFINE FIELD IF NOT EXISTS kind ON memory_nodes TYPE string;
202    DEFINE FIELD IF NOT EXISTS text ON memory_nodes TYPE string;
203    DEFINE FIELD IF NOT EXISTS confidence ON memory_nodes TYPE float;
204    DEFINE FIELD IF NOT EXISTS status ON memory_nodes TYPE string;
205    DEFINE FIELD IF NOT EXISTS created_at ON memory_nodes TYPE string;
206    DEFINE INDEX IF NOT EXISTS memory_node_id_idx ON memory_nodes FIELDS id UNIQUE;
207
208    DEFINE TABLE IF NOT EXISTS memory_edges SCHEMALESS;
209    DEFINE FIELD IF NOT EXISTS from_id ON memory_edges TYPE string;
210    DEFINE FIELD IF NOT EXISTS to_id ON memory_edges TYPE string;
211    DEFINE FIELD IF NOT EXISTS rel ON memory_edges TYPE string;
212    DEFINE FIELD IF NOT EXISTS created_at ON memory_edges TYPE string;
213
214    DEFINE ANALYZER IF NOT EXISTS memory_analyzer TOKENIZERS blank, class FILTERS lowercase, snowball(english);
215    DEFINE INDEX IF NOT EXISTS memory_fts_idx ON memories FIELDS value
216        SEARCH ANALYZER memory_analyzer BM25;
217"#;
218
219fn parse_timestamp(value: &str) -> chrono::DateTime<chrono::Utc> {
220    chrono::DateTime::parse_from_rfc3339(value)
221        .map(|dt| dt.with_timezone(&chrono::Utc))
222        .unwrap_or_else(|_| chrono::Utc::now())
223}
224
225#[async_trait]
226impl MemoryBackend for SurrealMemory {
227    fn as_any(&self) -> &dyn std::any::Any {
228        self
229    }
230
231    async fn store(
232        &self,
233        namespace: &str,
234        key: &str,
235        value: &str,
236        metadata: Option<serde_json::Value>,
237    ) -> Result<()> {
238        let created_at = chrono::Utc::now().to_rfc3339();
239        let row = MemoryRow {
240            namespace: namespace.to_string(),
241            key: key.to_string(),
242            value: value.to_string(),
243            metadata,
244            created_at,
245        };
246        let _: Option<MemoryRow> = self
247            .db()
248            .await?
249            .upsert(("memories", Self::memory_id(namespace, key)))
250            .content(row)
251            .await?;
252        Ok(())
253    }
254
255    async fn recall(&self, namespace: &str, key: &str) -> Result<Option<MemoryEntry>> {
256        let row: Option<MemoryRow> = self
257            .db()
258            .await?
259            .select(("memories", Self::memory_id(namespace, key)))
260            .await?;
261        Ok(row.map(|entry| MemoryEntry {
262            key: entry.key,
263            value: entry.value,
264            metadata: entry.metadata,
265            created_at: parse_timestamp(&entry.created_at),
266        }))
267    }
268
269    async fn search(&self, namespace: &str, query: &str, limit: usize) -> Result<Vec<MemoryEntry>> {
270        // Try full-text search with BM25 ranking first
271        let mut result = self
272            .db()
273            .await?
274            .query(
275                "SELECT *, search::score(1) AS score
276                 FROM memories
277                 WHERE namespace = $namespace
278                   AND value @1@ $query
279                 ORDER BY score DESC
280                 LIMIT $limit",
281            )
282            .bind(("namespace", namespace.to_string()))
283            .bind(("query", query.to_string()))
284            .bind(("limit", limit as i64))
285            .await?;
286        let rows: Vec<MemoryRow> = result.take(0)?;
287
288        if !rows.is_empty() {
289            return Ok(rows
290                .into_iter()
291                .map(|entry| MemoryEntry {
292                    key: entry.key,
293                    value: entry.value,
294                    metadata: entry.metadata,
295                    created_at: parse_timestamp(&entry.created_at),
296                })
297                .collect());
298        }
299
300        // Fallback to CONTAINS for partial matches
301        let query_lower = query.to_lowercase();
302        let mut result = self.db().await?
303            .query(
304                "SELECT * FROM memories
305                 WHERE namespace = $namespace
306                   AND (string::lowercase(key) CONTAINS $query OR string::lowercase(value) CONTAINS $query)
307                 ORDER BY created_at DESC
308                 LIMIT $limit"
309            )
310            .bind(("namespace", namespace.to_string()))
311            .bind(("query", query_lower))
312            .bind(("limit", limit as i64))
313            .await?;
314        let rows: Vec<MemoryRow> = result.take(0)?;
315        Ok(rows
316            .into_iter()
317            .map(|entry| MemoryEntry {
318                key: entry.key,
319                value: entry.value,
320                metadata: entry.metadata,
321                created_at: parse_timestamp(&entry.created_at),
322            })
323            .collect())
324    }
325
326    async fn forget(&self, namespace: &str, key: &str) -> Result<()> {
327        let _: Option<MemoryRow> = self
328            .db()
329            .await?
330            .delete(("memories", Self::memory_id(namespace, key)))
331            .await?;
332        Ok(())
333    }
334
335    async fn list(&self, namespace: &str) -> Result<Vec<MemoryEntry>> {
336        let mut result = self.db().await?
337            .query("SELECT key, value, metadata, created_at FROM memories WHERE namespace = $namespace ORDER BY created_at DESC")
338            .bind(("namespace", namespace.to_string()))
339            .await?;
340        let rows: Vec<MemoryRow> = result.take(0)?;
341        Ok(rows
342            .into_iter()
343            .map(|entry| MemoryEntry {
344                key: entry.key,
345                value: entry.value,
346                metadata: entry.metadata,
347                created_at: parse_timestamp(&entry.created_at),
348            })
349            .collect())
350    }
351
352    async fn store_conversation(
353        &self,
354        chat_id: &str,
355        sender_id: &str,
356        role: &str,
357        content: &str,
358    ) -> Result<()> {
359        let now = chrono::Utc::now();
360        let row = ConversationRow {
361            chat_id: chat_id.to_string(),
362            sender_id: sender_id.to_string(),
363            role: role.to_string(),
364            content: content.to_string(),
365            seq: now.timestamp_millis(),
366            created_at: now.to_rfc3339(),
367        };
368        let _: Option<ConversationRow> = self
369            .db()
370            .await?
371            .create("conversations")
372            .content(row)
373            .await?;
374        Ok(())
375    }
376
377    async fn store_conversation_batch(&self, entries: &[(&str, &str, &str, &str)]) -> Result<()> {
378        for (offset, (chat_id, sender_id, role, content)) in entries.iter().enumerate() {
379            let now = chrono::Utc::now();
380            let row = ConversationRow {
381                chat_id: (*chat_id).to_string(),
382                sender_id: (*sender_id).to_string(),
383                role: (*role).to_string(),
384                content: (*content).to_string(),
385                seq: now.timestamp_millis() + offset as i64,
386                created_at: now.to_rfc3339(),
387            };
388            let _: Option<ConversationRow> = self
389                .db()
390                .await?
391                .create("conversations")
392                .content(row)
393                .await?;
394        }
395        Ok(())
396    }
397
398    async fn get_conversation_history(
399        &self,
400        chat_id: &str,
401        limit: usize,
402    ) -> Result<Vec<(String, String)>> {
403        let mut result = self
404            .db()
405            .await?
406            .query(
407                "SELECT * FROM conversations
408                 WHERE chat_id = $chat_id
409                 ORDER BY seq DESC
410                 LIMIT $limit",
411            )
412            .bind(("chat_id", chat_id.to_string()))
413            .bind(("limit", limit as i64))
414            .await?;
415        let mut rows: Vec<ConversationRow> = result.take(0)?;
416        rows.reverse();
417        Ok(rows
418            .into_iter()
419            .map(|row| (row.role, row.content))
420            .collect())
421    }
422
423    async fn clear_conversation(&self, chat_id: &str) -> Result<()> {
424        self.db()
425            .await?
426            .query("DELETE FROM conversations WHERE chat_id = $chat_id")
427            .bind(("chat_id", chat_id.to_string()))
428            .await?
429            .check()?;
430        Ok(())
431    }
432
433    async fn search_conversations(
434        &self,
435        query: &str,
436        limit: usize,
437        chat_id: Option<&str>,
438    ) -> Result<Vec<ConversationSearchHit>> {
439        let query = query.to_lowercase();
440        let mut result = if let Some(chat_id) = chat_id {
441            self.db()
442                .await?
443                .query(
444                    "SELECT * FROM conversations
445                     WHERE chat_id = $chat_id
446                       AND string::lowercase(content) CONTAINS $query
447                     ORDER BY seq DESC
448                     LIMIT $limit",
449                )
450                .bind(("chat_id", chat_id.to_string()))
451                .bind(("query", query))
452                .bind(("limit", limit as i64))
453                .await?
454        } else {
455            self.db()
456                .await?
457                .query(
458                    "SELECT * FROM conversations
459                     WHERE string::lowercase(content) CONTAINS $query
460                     ORDER BY seq DESC
461                     LIMIT $limit",
462                )
463                .bind(("query", query))
464                .bind(("limit", limit as i64))
465                .await?
466        };
467        let rows: Vec<ConversationRow> = result.take(0)?;
468        Ok(rows
469            .into_iter()
470            .map(|row| ConversationSearchHit {
471                chat_id: row.chat_id,
472                role: row.role,
473                content: row.content,
474                created_at: parse_timestamp(&row.created_at),
475            })
476            .collect())
477    }
478
479    async fn get_sticker_cache(&self, sticker_id: &str) -> Result<Option<String>> {
480        let row: Option<StickerRow> = self
481            .db()
482            .await?
483            .select(("sticker_cache", sticker_id))
484            .await?;
485        Ok(row.map(|entry| entry.description))
486    }
487
488    async fn store_sticker_cache(
489        &self,
490        sticker_id: &str,
491        file_id: &str,
492        description: &str,
493    ) -> Result<()> {
494        let row = StickerRow {
495            sticker_id: sticker_id.to_string(),
496            file_id: file_id.to_string(),
497            description: description.to_string(),
498            analyzed_at: chrono::Utc::now().to_rfc3339(),
499        };
500        let _: Option<StickerRow> = self
501            .db()
502            .await?
503            .upsert(("sticker_cache", sticker_id))
504            .content(row)
505            .await?;
506        Ok(())
507    }
508
509    // ── Embeddings ──
510
511    async fn store_embedding(
512        &self,
513        namespace: &str,
514        key: &str,
515        vector: &[f32],
516        text: &str,
517        model: &str,
518    ) -> Result<()> {
519        let row = EmbeddingRow {
520            namespace: namespace.to_string(),
521            key: key.to_string(),
522            dim: Some(vector.len() as i64),
523            model: Some(model.to_string()),
524            vector: vector.to_vec(),
525            text: text.to_string(),
526            created_at: chrono::Utc::now().to_rfc3339(),
527        };
528        let id = Self::memory_id(namespace, key);
529        let _: Option<EmbeddingRow> = self
530            .db()
531            .await?
532            .upsert(("embeddings", &id))
533            .content(row)
534            .await?;
535        Ok(())
536    }
537
538    async fn search_embeddings(
539        &self,
540        namespace: &str,
541        query_vector: &[f32],
542        limit: usize,
543    ) -> Result<Vec<EmbeddingEntry>> {
544        if query_vector.is_empty() {
545            return Ok(Vec::new());
546        }
547        let dim = query_vector.len() as i64;
548
549        // Brute-force cosine scan over the namespace's embeddings of the
550        // matching dimension.
551        //
552        // SurrealDB's KNN operator is not used here. `vector <|K,COSINE|> $v`
553        // only produces matches when an MTREE index is defined on the field,
554        // and there deliberately is none (see SCHEMA_SQL) — the earlier form
555        // `vector <| $v |>` did not even parse, so the KNN path silently
556        // errored and every search already fell through to this scan.
557        //
558        // The `dim` filter is what keeps the comparison meaningful: vectors
559        // produced by a different embedding model have a different length and
560        // are not comparable, so they must never enter the candidate set.
561        // Rows written before `dim` was recorded have no dimension and are
562        // therefore excluded; they are picked up again on their next write.
563        let mut result = self
564            .db()
565            .await?
566            .query("SELECT * FROM embeddings WHERE namespace = $namespace AND dim = $dim")
567            .bind(("namespace", namespace.to_string()))
568            .bind(("dim", dim))
569            .await?;
570        let rows: Vec<EmbeddingRow> = result.take(0)?;
571
572        // `cosine_similarity` returns None for mismatched lengths rather than
573        // scoring them, so a stale row that slipped past the `dim` filter is
574        // dropped instead of ranked.
575        let mut scored: Vec<(f32, EmbeddingRow)> = rows
576            .into_iter()
577            .filter_map(|row| cosine_similarity(query_vector, &row.vector).map(|sim| (sim, row)))
578            .collect();
579        scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
580        scored.truncate(limit);
581
582        Ok(scored
583            .into_iter()
584            .map(|(_, row)| EmbeddingEntry {
585                namespace: row.namespace,
586                key: row.key,
587                vector: row.vector,
588                text: row.text,
589                created_at: parse_timestamp(&row.created_at),
590            })
591            .collect())
592    }
593
594    // ── File indexing ──
595
596    async fn store_file_index(&self, path: &str, hash: &str) -> Result<()> {
597        let row = FileIndexRow {
598            path: path.to_string(),
599            hash: hash.to_string(),
600            last_indexed: chrono::Utc::now().to_rfc3339(),
601        };
602        // Use path hash as record ID to avoid special chars
603        let id = format!("{:x}", md5_hash(path));
604        let _: Option<FileIndexRow> = self.db().await?.upsert(("files", &id)).content(row).await?;
605        Ok(())
606    }
607
608    async fn get_file_index(&self, path: &str) -> Result<Option<FileIndex>> {
609        let id = format!("{:x}", md5_hash(path));
610        let row: Option<FileIndexRow> = self.db().await?.select(("files", &id)).await?;
611        Ok(row.map(|r| FileIndex {
612            path: r.path,
613            hash: r.hash,
614            last_indexed: parse_timestamp(&r.last_indexed),
615        }))
616    }
617
618    // ── Code chunks ──
619
620    async fn store_chunk(
621        &self,
622        file_path: &str,
623        start_line: u32,
624        end_line: u32,
625        content: &str,
626        embedding: Option<&[f32]>,
627    ) -> Result<()> {
628        let row = ChunkRow {
629            file_path: file_path.to_string(),
630            start_line,
631            end_line,
632            content: content.to_string(),
633            embedding: embedding.map(|e| e.to_vec()),
634            created_at: chrono::Utc::now().to_rfc3339(),
635        };
636        let _: Option<ChunkRow> = self.db().await?.create("chunks").content(row).await?;
637        Ok(())
638    }
639
640    async fn get_chunks_for_file(&self, file_path: &str) -> Result<Vec<Chunk>> {
641        let mut result = self
642            .db()
643            .await?
644            .query("SELECT * FROM chunks WHERE file_path = $file_path ORDER BY start_line ASC")
645            .bind(("file_path", file_path.to_string()))
646            .await?;
647        let rows: Vec<ChunkRow> = result.take(0)?;
648        Ok(rows
649            .into_iter()
650            .map(|r| Chunk {
651                file_path: r.file_path,
652                start_line: r.start_line,
653                end_line: r.end_line,
654                content: r.content,
655                embedding: r.embedding,
656                created_at: parse_timestamp(&r.created_at),
657            })
658            .collect())
659    }
660
661    async fn delete_chunks_for_file(&self, file_path: &str) -> Result<()> {
662        self.db()
663            .await?
664            .query("DELETE FROM chunks WHERE file_path = $file_path")
665            .bind(("file_path", file_path.to_string()))
666            .await?;
667        Ok(())
668    }
669}
670
671/// Cosine similarity, or `None` when the two vectors are not comparable.
672///
673/// Vectors of different lengths come from different embedding models and have
674/// no meaningful similarity; returning `None` forces callers to drop the pair
675/// rather than treat a fabricated score as a real one.
676fn cosine_similarity(a: &[f32], b: &[f32]) -> Option<f32> {
677    if a.len() != b.len() || a.is_empty() {
678        return None;
679    }
680
681    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
682    let ma: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
683    let mb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
684
685    if ma == 0.0 || mb == 0.0 {
686        return Some(0.0);
687    }
688
689    Some(dot / (ma * mb))
690}
691
692/// Simple hash for file path → record ID
693fn md5_hash(input: &str) -> u64 {
694    use std::hash::{Hash, Hasher};
695    let mut hasher = std::collections::hash_map::DefaultHasher::new();
696    input.hash(&mut hasher);
697    hasher.finish()
698}
699
700#[cfg(test)]
701mod tests {
702    use super::*;
703
704    #[test]
705    fn test_md5_hash_deterministic() {
706        let h1 = md5_hash("test-path");
707        let h2 = md5_hash("test-path");
708        assert_eq!(h1, h2);
709    }
710
711    #[test]
712    fn test_md5_hash_different_inputs() {
713        let h1 = md5_hash("path-a");
714        let h2 = md5_hash("path-b");
715        assert_ne!(h1, h2);
716    }
717
718    #[tokio::test]
719    async fn test_surreal_store_and_recall() {
720        let dir = tempfile::tempdir().unwrap();
721        let mem = SurrealMemory::new(dir.path()).await.unwrap();
722
723        mem.store("test", "greeting", "hello world", None)
724            .await
725            .unwrap();
726        let val = mem.recall("test", "greeting").await.unwrap();
727        assert!(val.is_some());
728        assert_eq!(val.unwrap().value, "hello world");
729    }
730
731    #[tokio::test]
732    async fn test_surreal_recall_missing() {
733        let dir = tempfile::tempdir().unwrap();
734        let mem = SurrealMemory::new(dir.path()).await.unwrap();
735
736        let val = mem.recall("test", "missing-key").await.unwrap();
737        assert!(val.is_none());
738    }
739
740    #[tokio::test]
741    async fn test_surreal_search() {
742        let dir = tempfile::tempdir().unwrap();
743        let mem = SurrealMemory::new(dir.path()).await.unwrap();
744
745        mem.store("ns", "k1", "the quick brown fox", None)
746            .await
747            .unwrap();
748        mem.store("ns", "k2", "lazy dog sleeps", None)
749            .await
750            .unwrap();
751        mem.store("ns", "k3", "fox runs fast", None).await.unwrap();
752
753        let results: Vec<MemoryEntry> = mem.search("ns", "fox", 10).await.unwrap();
754        assert!(!results.is_empty());
755        assert!(results.iter().any(|e| e.value.contains("fox")));
756    }
757
758    #[tokio::test]
759    async fn test_surreal_delete() {
760        let dir = tempfile::tempdir().unwrap();
761        let mem = SurrealMemory::new(dir.path()).await.unwrap();
762
763        mem.store("ns", "del-key", "to delete", None).await.unwrap();
764        assert!(mem.recall("ns", "del-key").await.unwrap().is_some());
765
766        mem.forget("ns", "del-key").await.unwrap();
767        assert!(mem.recall("ns", "del-key").await.unwrap().is_none());
768    }
769
770    #[tokio::test]
771    async fn test_surreal_conversation_history() {
772        let dir = tempfile::tempdir().unwrap();
773        let mem = SurrealMemory::new(dir.path()).await.unwrap();
774
775        mem.store_conversation("chat-1", "user-1", "user", "Hello")
776            .await
777            .unwrap();
778        // Small delay to ensure different seq timestamps
779        tokio::time::sleep(std::time::Duration::from_millis(5)).await;
780        mem.store_conversation("chat-1", "assistant", "assistant", "Hi there")
781            .await
782            .unwrap();
783
784        let history: Vec<(String, String)> =
785            mem.get_conversation_history("chat-1", 10).await.unwrap();
786        assert_eq!(history.len(), 2);
787        assert_eq!(history[0].1, "Hello");
788        assert_eq!(history[1].1, "Hi there");
789    }
790
791    #[tokio::test]
792    async fn test_surreal_clear_conversation_is_scoped_to_one_chat() {
793        let dir = tempfile::tempdir().unwrap();
794        let mem = SurrealMemory::new(dir.path()).await.unwrap();
795
796        mem.store_conversation("chat-1", "user-1", "user", "keep me out")
797            .await
798            .unwrap();
799        mem.store_conversation("chat-2", "user-1", "user", "keep me")
800            .await
801            .unwrap();
802
803        mem.clear_conversation("chat-1").await.unwrap();
804
805        assert!(mem
806            .get_conversation_history("chat-1", 10)
807            .await
808            .unwrap()
809            .is_empty());
810        let survivors = mem.get_conversation_history("chat-2", 10).await.unwrap();
811        assert_eq!(survivors.len(), 1);
812        assert_eq!(survivors[0].1, "keep me");
813
814        // Clearing an empty chat is not an error.
815        mem.clear_conversation("chat-1").await.unwrap();
816    }
817
818    #[tokio::test]
819    async fn test_surreal_embeddings() {
820        let dir = tempfile::tempdir().unwrap();
821        let mem = SurrealMemory::new(dir.path()).await.unwrap();
822
823        let vec1 = vec![1.0, 0.0, 0.0];
824        let vec2 = vec![0.0, 1.0, 0.0];
825        let vec3 = vec![0.9, 0.1, 0.0];
826
827        mem.store_embedding("ns", "e1", &vec1, "first", "test-model")
828            .await
829            .unwrap();
830        mem.store_embedding("ns", "e2", &vec2, "second", "test-model")
831            .await
832            .unwrap();
833        mem.store_embedding("ns", "e3", &vec3, "third", "test-model")
834            .await
835            .unwrap();
836
837        let results = mem.search_embeddings("ns", &vec1, 2).await.unwrap();
838        assert!(!results.is_empty());
839        // The closest to [1,0,0] should be e1 or e3
840        assert!(results[0].key == "e1" || results[0].key == "e3");
841    }
842
843    #[test]
844    fn test_cosine_similarity_rejects_mismatched_lengths() {
845        assert_eq!(cosine_similarity(&[1.0, 0.0], &[1.0, 0.0, 0.0]), None);
846        assert_eq!(cosine_similarity(&[], &[]), None);
847        assert_eq!(cosine_similarity(&[1.0, 0.0], &[1.0, 0.0]), Some(1.0));
848        assert_eq!(cosine_similarity(&[0.0, 0.0], &[1.0, 0.0]), Some(0.0));
849    }
850
851    #[tokio::test]
852    async fn test_search_embeddings_excludes_other_dimensions() {
853        let dir = tempfile::tempdir().unwrap();
854        let mem = SurrealMemory::new(dir.path()).await.unwrap();
855
856        // Simulates a provider switch: 3-dim rows from the old model coexist
857        // with 8-dim rows from the new one.
858        let old_dim = vec![1.0f32, 0.0, 0.0];
859        let new_dim = vec![0.25f32; 8];
860        mem.store_embedding("ns", "old", &old_dim, "old model", "old-model")
861            .await
862            .unwrap();
863        mem.store_embedding("ns", "new", &new_dim, "new model", "new-model")
864            .await
865            .unwrap();
866
867        let results = mem.search_embeddings("ns", &new_dim, 10).await.unwrap();
868        assert!(
869            results.iter().all(|e| e.key != "old"),
870            "a vector of a different dimension must never be returned"
871        );
872        assert!(results.iter().any(|e| e.key == "new"));
873
874        // And symmetrically for the old dimension.
875        let results = mem.search_embeddings("ns", &old_dim, 10).await.unwrap();
876        assert!(results.iter().all(|e| e.key != "new"));
877        assert!(results.iter().any(|e| e.key == "old"));
878    }
879
880    #[tokio::test]
881    async fn test_search_embeddings_skips_rows_without_dimension() {
882        let dir = tempfile::tempdir().unwrap();
883        let mem = SurrealMemory::new(dir.path()).await.unwrap();
884        let db = mem.db().await.unwrap();
885
886        // A legacy row written before `dim`/`model` were recorded.
887        db.query(
888            "CREATE embeddings:legacy SET namespace = 'ns', key = 'legacy',
889             vector = [1.0, 0.0, 0.0], text = 'legacy', created_at = '2024-01-01T00:00:00Z'",
890        )
891        .await
892        .unwrap();
893
894        let results = mem
895            .search_embeddings("ns", &[1.0, 0.0, 0.0], 10)
896            .await
897            .unwrap();
898        assert!(
899            results.is_empty(),
900            "rows with no recorded dimension must not be returned"
901        );
902    }
903
904    #[tokio::test]
905    async fn test_surreal_file_index() {
906        let dir = tempfile::tempdir().unwrap();
907        let mem = SurrealMemory::new(dir.path()).await.unwrap();
908
909        mem.store_file_index("/src/main.rs", "abc123")
910            .await
911            .unwrap();
912        let idx = mem.get_file_index("/src/main.rs").await.unwrap();
913        assert!(idx.is_some());
914        assert_eq!(idx.unwrap().hash, "abc123");
915
916        let missing = mem.get_file_index("/src/nonexistent.rs").await.unwrap();
917        assert!(missing.is_none());
918    }
919
920    #[tokio::test]
921    async fn test_surreal_chunks() {
922        let dir = tempfile::tempdir().unwrap();
923        let mem = SurrealMemory::new(dir.path()).await.unwrap();
924
925        mem.store_chunk("/src/lib.rs", 1, 10, "fn main() {}", None)
926            .await
927            .unwrap();
928        mem.store_chunk("/src/lib.rs", 11, 20, "fn helper() {}", None)
929            .await
930            .unwrap();
931
932        let chunks = mem.get_chunks_for_file("/src/lib.rs").await.unwrap();
933        assert_eq!(chunks.len(), 2);
934        assert_eq!(chunks[0].start_line, 1);
935        assert_eq!(chunks[1].start_line, 11);
936
937        mem.delete_chunks_for_file("/src/lib.rs").await.unwrap();
938        let empty = mem.get_chunks_for_file("/src/lib.rs").await.unwrap();
939        assert!(empty.is_empty());
940    }
941
942    #[tokio::test]
943    async fn test_surreal_sticker_cache() {
944        let dir = tempfile::tempdir().unwrap();
945        let mem = SurrealMemory::new(dir.path()).await.unwrap();
946
947        mem.store_sticker_cache("stk-1", "file-1", "A happy cat")
948            .await
949            .unwrap();
950        let desc: Option<String> = mem.get_sticker_cache("stk-1").await.unwrap();
951        assert_eq!(desc, Some("A happy cat".to_string()));
952
953        let missing: Option<String> = mem.get_sticker_cache("stk-999").await.unwrap();
954        assert!(missing.is_none());
955    }
956
957    // Reproducible xorshift PRNG so the corpus is realistic (non-zero, spread
958    // over the unit sphere) without pulling in a `rand` dev-dependency.
959    struct Xorshift(u64);
960
961    impl Xorshift {
962        fn next_f32(&mut self) -> f32 {
963            let mut x = self.0;
964            x ^= x << 13;
965            x ^= x >> 7;
966            x ^= x << 17;
967            self.0 = x;
968            ((x >> 40) as f32 / 16_777_216.0) * 2.0 - 1.0
969        }
970
971        fn unit_vector(&mut self, dim: usize) -> Vec<f32> {
972            let mut v: Vec<f32> = (0..dim).map(|_| self.next_f32()).collect();
973            let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt();
974            if norm > 0.0 {
975                for x in v.iter_mut() {
976                    *x /= norm;
977                }
978            }
979            v
980        }
981    }
982
983    /// Latency of `search_embeddings` against the brute-force cosine scan at
984    /// growing corpus sizes. Ignored by default: it writes hundreds of MB and
985    /// takes minutes.
986    ///
987    /// Run with `cargo test --release -p apollo-agent bench_search_embeddings
988    /// -- --ignored --nocapture`. Debug numbers are not meaningful.
989    #[tokio::test]
990    #[ignore = "benchmark: slow, writes a large corpus"]
991    async fn bench_search_embeddings_scaling() {
992        const DIM: usize = 1536;
993        const QUERIES: usize = 100;
994        // A 10k-row corpus at 1536 dims costs ~900MB on disk, so 50k needs
995        // ~4.5GB free. Override with APOLLO_BENCH_SIZES=100,1000 on a small
996        // disk.
997        let sizes: Vec<usize> = match std::env::var("APOLLO_BENCH_SIZES") {
998            Ok(s) => s.split(',').filter_map(|p| p.trim().parse().ok()).collect(),
999            Err(_) => vec![100, 1_000, 10_000, 50_000],
1000        };
1001
1002        for size in sizes {
1003            let dir = tempfile::tempdir().unwrap();
1004            let mem = SurrealMemory::new(dir.path()).await.unwrap();
1005            let mut rng = Xorshift(0x2545_F491_4F6C_DD1D);
1006
1007            let write_start = std::time::Instant::now();
1008            for i in 0..size {
1009                let v = rng.unit_vector(DIM);
1010                mem.store_embedding("bench", &format!("k{i}"), &v, "text", "bench-model")
1011                    .await
1012                    .unwrap();
1013            }
1014            let write_elapsed = write_start.elapsed();
1015
1016            let queries: Vec<Vec<f32>> = (0..QUERIES).map(|_| rng.unit_vector(DIM)).collect();
1017            let mut samples: Vec<u128> = Vec::with_capacity(QUERIES);
1018            for q in &queries {
1019                let start = std::time::Instant::now();
1020                let hits = mem.search_embeddings("bench", q, 10).await.unwrap();
1021                samples.push(start.elapsed().as_micros());
1022                assert_eq!(hits.len(), 10.min(size));
1023            }
1024            let (median, p95, max) = percentiles(&mut samples);
1025
1026            println!(
1027                "size={size:>6} dim={DIM} search_median={median:>8.2}ms search_p95={p95:>8.2}ms \
1028                 search_max={max:>8.2}ms insert_total={:>8.2}s insert_per_row={:>6.2}ms",
1029                write_elapsed.as_secs_f64(),
1030                write_elapsed.as_secs_f64() * 1000.0 / size as f64,
1031            );
1032
1033            // Same ranking, but scored inside SurrealDB so only the top `k`
1034            // rows cross the boundary instead of every candidate vector.
1035            let db = mem.db().await.unwrap();
1036            let mut samples: Vec<u128> = Vec::with_capacity(QUERIES);
1037            for q in &queries {
1038                let start = std::time::Instant::now();
1039                let mut result = db
1040                    .query(
1041                        "SELECT namespace, key, text, created_at, \
1042                         vector::similarity::cosine(vector, $q) AS score \
1043                         FROM embeddings WHERE namespace = $namespace AND dim = $dim \
1044                         ORDER BY score DESC LIMIT 10",
1045                    )
1046                    .bind(("namespace", "bench".to_string()))
1047                    .bind(("dim", DIM as i64))
1048                    .bind(("q", q.clone()))
1049                    .await
1050                    .unwrap();
1051                let rows: Vec<serde_json::Value> = result.take(0).unwrap();
1052                samples.push(start.elapsed().as_micros());
1053                assert_eq!(rows.len(), 10.min(size));
1054            }
1055            let (median, p95, max) = percentiles(&mut samples);
1056            println!(
1057                "size={size:>6} dim={DIM}  indb_median={median:>8.2}ms  indb_p95={p95:>8.2}ms  \
1058                 indb_max={max:>8.2}ms"
1059            );
1060        }
1061    }
1062
1063    /// Feasibility spike for a per-dimension MTREE index: does SurrealDB
1064    /// accept a mixed-dimension table once an MTREE of a fixed DIMENSION
1065    /// exists on `vector`, and does KNN beat the scan?
1066    #[tokio::test]
1067    #[ignore = "benchmark: slow, writes a large corpus"]
1068    async fn bench_mtree_feasibility() {
1069        const DIM: usize = 1536;
1070        const SIZE: usize = 2_000;
1071
1072        let dir = tempfile::tempdir().unwrap();
1073        let mem = SurrealMemory::new(dir.path()).await.unwrap();
1074        let mut rng = Xorshift(0x2545_F491_4F6C_DD1D);
1075        let db = mem.db().await.unwrap();
1076
1077        let define = db
1078            .query(format!(
1079                "DEFINE INDEX IF NOT EXISTS embedding_vector_mtree_{DIM} ON embeddings \
1080                 FIELDS vector MTREE DIMENSION {DIM} DIST COSINE"
1081            ))
1082            .await;
1083        println!("define_mtree_ok={}", define.is_ok());
1084        if let Err(e) = &define {
1085            println!("define_mtree_err={e}");
1086            return;
1087        }
1088
1089        let start = std::time::Instant::now();
1090        for i in 0..SIZE {
1091            let v = rng.unit_vector(DIM);
1092            mem.store_embedding("bench", &format!("k{i}"), &v, "text", "bench-model")
1093                .await
1094                .unwrap();
1095        }
1096        println!(
1097            "mtree_insert_total={:.2}s per_row={:.2}ms",
1098            start.elapsed().as_secs_f64(),
1099            start.elapsed().as_secs_f64() * 1000.0 / SIZE as f64
1100        );
1101
1102        // Does a vector of another dimension survive in the same table?
1103        let mismatched = mem
1104            .store_embedding("bench", "other-dim", &[1.0, 0.0, 0.0], "t", "small-model")
1105            .await;
1106        println!("mixed_dim_insert_ok={}", mismatched.is_ok());
1107        if let Err(e) = &mismatched {
1108            println!("mixed_dim_insert_err={e}");
1109        }
1110
1111        let mut knn: Vec<u128> = Vec::new();
1112        let mut scan: Vec<u128> = Vec::new();
1113        for _ in 0..20 {
1114            let q = rng.unit_vector(DIM);
1115
1116            let start = std::time::Instant::now();
1117            let mut result = db
1118                .query(
1119                    "SELECT namespace, key, text, created_at FROM embeddings \
1120                     WHERE vector <|10,COSINE|> $q",
1121                )
1122                .bind(("q", q.clone()))
1123                .await
1124                .unwrap();
1125            let rows: Vec<serde_json::Value> = result.take(0).unwrap();
1126            knn.push(start.elapsed().as_micros());
1127            assert!(!rows.is_empty(), "KNN returned nothing — index not used");
1128
1129            let start = std::time::Instant::now();
1130            mem.search_embeddings("bench", &q, 10).await.unwrap();
1131            scan.push(start.elapsed().as_micros());
1132        }
1133        let (km, kp, kx) = percentiles(&mut knn);
1134        let (sm, sp, sx) = percentiles(&mut scan);
1135        println!("size={SIZE} knn_median={km:.2}ms knn_p95={kp:.2}ms knn_max={kx:.2}ms");
1136        println!("size={SIZE} scan_median={sm:.2}ms scan_p95={sp:.2}ms scan_max={sx:.2}ms");
1137    }
1138
1139    fn percentiles(samples: &mut [u128]) -> (f64, f64, f64) {
1140        samples.sort_unstable();
1141        (
1142            samples[samples.len() / 2] as f64 / 1000.0,
1143            samples[(samples.len() * 95) / 100] as f64 / 1000.0,
1144            *samples.last().unwrap() as f64 / 1000.0,
1145        )
1146    }
1147}