use crate::index::layout;
use anyhow::{Context, Result, bail};
use rusqlite::{Connection, OptionalExtension, params};
use std::path::Path;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
pub const DEFAULT_MAX_NOTES: usize = 1000;
pub const MAX_NOTES_ENV: &str = "SEMANTEX_MEMORY_MAX_NOTES";
const NOTE_SOURCE: &str = "agent";
const MAX_SAVE_ATTEMPTS: u32 = 5;
pub const SCOPE_CONVENTIONS: &[&str] =
&["global", "file:<rel_path>", "module:<dir>", "task:<slug>"];
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Note {
pub id: i64,
pub created_ts: i64,
pub updated_ts: i64,
pub scope: String,
pub content: String,
pub tags: Vec<String>,
pub source: String,
}
fn parse_tags(raw: &str) -> Vec<String> {
raw.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
fn format_tags(tags: &[String]) -> String {
let mut cleaned: Vec<String> = tags
.iter()
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
cleaned.sort();
cleaned.dedup();
cleaned.join(",")
}
fn now_ts() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
static KEY_COUNTER: AtomicU64 = AtomicU64::new(0);
fn generate_key() -> String {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let counter = KEY_COUNTER.fetch_add(1, Ordering::Relaxed);
format!("{nanos:x}-{counter:x}-{:x}", std::process::id())
}
fn max_notes() -> usize {
std::env::var(MAX_NOTES_ENV)
.ok()
.and_then(|v| v.trim().parse::<usize>().ok())
.unwrap_or(DEFAULT_MAX_NOTES)
}
fn escape_fts5_token(token: &str) -> String {
format!("\"{}\"", token.replace('"', "\"\""))
}
fn build_match_query(query: &str) -> Option<String> {
let tokens: Vec<String> = query
.split_whitespace()
.filter(|t| !t.is_empty())
.map(escape_fts5_token)
.collect();
if tokens.is_empty() {
None
} else {
Some(tokens.join(" OR "))
}
}
pub struct MemoryStore {
conn: Connection,
fts5_enabled: bool,
}
impl MemoryStore {
pub fn open(project_root: &Path) -> Result<Self> {
let conn = layout::open_memory_db(&layout::memory_db_path(project_root))
.context("Failed to open memory.db")?;
conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?;
let fts5_enabled = ensure_migrations(&conn)?;
Ok(Self { conn, fts5_enabled })
}
#[cfg(test)]
fn open_at(db_path: &Path) -> Result<Self> {
let conn = layout::open_memory_db(db_path)?;
let fts5_enabled = ensure_migrations(&conn)?;
Ok(Self { conn, fts5_enabled })
}
#[must_use]
pub fn fts5_enabled(&self) -> bool {
self.fts5_enabled
}
pub fn save(&self, content: &str, scope: &str, tags: &[String]) -> Result<i64> {
let content = content.trim();
if content.is_empty() {
bail!("memory note content must not be empty");
}
let scope = scope.trim();
if scope.is_empty() {
bail!("memory note scope must not be empty");
}
let tags_str = format_tags(tags);
let ts = now_ts();
let mut last_err = None;
for _ in 0..MAX_SAVE_ATTEMPTS {
let key = generate_key();
let result = self.conn.execute(
"INSERT INTO notes (created_ts, updated_ts, scope, key, content, source, tags)
VALUES (?1, ?1, ?2, ?3, ?4, ?5, ?6)",
params![ts, scope, key, content, NOTE_SOURCE, tags_str],
);
match result {
Ok(_) => {
let id = self.conn.last_insert_rowid();
self.enforce_cap()?;
return Ok(id);
}
Err(e) if is_unique_violation(&e) => {
last_err = Some(e);
}
Err(e) => return Err(e).context("Failed to insert memory note"),
}
}
Err(anyhow::anyhow!(
"Failed to insert memory note after {MAX_SAVE_ATTEMPTS} attempts (key collisions): {last_err:?}"
))
}
pub fn recall(
&self,
query: Option<&str>,
scope: Option<&str>,
limit: usize,
) -> Result<Vec<Note>> {
let limit = limit.clamp(1, 200);
let query = query.map(str::trim).filter(|q| !q.is_empty());
let Some(query) = query else {
return self.list(scope, limit);
};
if self.fts5_enabled
&& let Some(match_expr) = build_match_query(query)
{
return self.recall_fts5(&match_expr, scope, limit);
}
self.recall_like(query, scope, limit)
}
fn recall_fts5(
&self,
match_expr: &str,
scope: Option<&str>,
limit: usize,
) -> Result<Vec<Note>> {
let mut stmt = self.conn.prepare(
"SELECT n.id, n.created_ts, n.updated_ts, n.scope, n.content, n.tags, n.source
FROM notes_fts
JOIN notes n ON n.id = notes_fts.rowid
WHERE notes_fts MATCH ?1
AND (?2 IS NULL OR n.scope = ?2)
ORDER BY bm25(notes_fts, 3.0, 1.0) ASC, n.created_ts DESC
LIMIT ?3",
)?;
let rows = stmt.query_map(params![match_expr, scope, limit as i64], row_to_note)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.context("Failed to recall memory notes (fts5 path)")
}
fn recall_like(&self, query: &str, scope: Option<&str>, limit: usize) -> Result<Vec<Note>> {
let tokens: Vec<String> = query
.split_whitespace()
.filter(|t| !t.is_empty())
.map(str::to_lowercase)
.collect();
if tokens.is_empty() {
return self.list(scope, limit);
}
let mut score_expr = String::new();
let mut sql_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
for token in &tokens {
if !score_expr.is_empty() {
score_expr.push_str(" + ");
}
score_expr.push_str(
"(CASE WHEN instr(lower(content), ?) > 0 OR instr(lower(tags), ?) > 0 THEN 1 ELSE 0 END)",
);
let like_token = format!("%{token}%");
sql_params.push(Box::new(like_token.clone()));
sql_params.push(Box::new(like_token));
}
let sql = format!(
"SELECT id, created_ts, updated_ts, scope, content, tags, source, ({score_expr}) AS score
FROM notes
WHERE (?{scope_idx} IS NULL OR scope = ?{scope_idx})
AND ({score_expr}) > 0
ORDER BY score DESC, created_ts DESC
LIMIT ?{limit_idx}",
scope_idx = tokens.len() * 2 + 1,
limit_idx = tokens.len() * 2 + 2,
);
sql_params.push(Box::new(scope.map(str::to_string)));
sql_params.push(Box::new(limit as i64));
let mut stmt = self.conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = sql_params.iter().map(AsRef::as_ref).collect();
let rows = stmt.query_map(param_refs.as_slice(), |row| {
Ok(Note {
id: row.get(0)?,
created_ts: row.get(1)?,
updated_ts: row.get(2)?,
scope: row.get(3)?,
content: row.get(4)?,
tags: parse_tags(&row.get::<_, String>(5)?),
source: row.get(6)?,
})
})?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.context("Failed to recall memory notes (LIKE fallback path)")
}
pub fn list(&self, scope: Option<&str>, limit: usize) -> Result<Vec<Note>> {
let limit = limit.clamp(1, 200);
let mut stmt = self.conn.prepare(
"SELECT id, created_ts, updated_ts, scope, content, tags, source
FROM notes
WHERE (?1 IS NULL OR scope = ?1)
ORDER BY created_ts DESC, id DESC
LIMIT ?2",
)?;
let rows = stmt.query_map(params![scope, limit as i64], row_to_note)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.context("Failed to list memory notes")
}
pub fn get(&self, id: i64) -> Result<Option<Note>> {
self.conn
.query_row(
"SELECT id, created_ts, updated_ts, scope, content, tags, source
FROM notes WHERE id = ?1",
params![id],
row_to_note,
)
.optional()
.context("Failed to get memory note")
}
pub fn delete(&self, id: i64) -> Result<bool> {
let changed = self
.conn
.execute("DELETE FROM notes WHERE id = ?1", params![id])
.context("Failed to delete memory note")?;
Ok(changed > 0)
}
#[must_use]
pub fn count(&self) -> i64 {
self.conn
.query_row("SELECT COUNT(*) FROM notes", [], |r| r.get(0))
.unwrap_or(0)
}
fn enforce_cap(&self) -> Result<()> {
let cap = max_notes();
if cap == 0 {
return Ok(()); }
let count: i64 = self
.conn
.query_row("SELECT COUNT(*) FROM notes", [], |r| r.get(0))?;
let count = count.max(0) as usize;
if count <= cap {
return Ok(());
}
let overflow = count - cap;
let deleted = self.conn.execute(
"DELETE FROM notes WHERE id IN (
SELECT id FROM notes ORDER BY created_ts ASC, id ASC LIMIT ?1
)",
params![overflow as i64],
)?;
tracing::warn!(
deleted,
cap,
"memory.db notes exceeded the per-project cap ({MAX_NOTES_ENV}={cap}); \
evicted the oldest {deleted} note(s)"
);
Ok(())
}
}
fn row_to_note(row: &rusqlite::Row) -> rusqlite::Result<Note> {
Ok(Note {
id: row.get(0)?,
created_ts: row.get(1)?,
updated_ts: row.get(2)?,
scope: row.get(3)?,
content: row.get(4)?,
tags: parse_tags(&row.get::<_, String>(5)?),
source: row.get(6)?,
})
}
fn is_unique_violation(e: &rusqlite::Error) -> bool {
matches!(
e,
rusqlite::Error::SqliteFailure(err, _)
if err.code == rusqlite::ErrorCode::ConstraintViolation
)
}
fn ensure_migrations(conn: &Connection) -> Result<bool> {
let has_tags: i64 = conn.query_row(
"SELECT COUNT(*) FROM pragma_table_info('notes') WHERE name = 'tags'",
[],
|r| r.get(0),
)?;
if has_tags == 0 {
conn.execute(
"ALTER TABLE notes ADD COLUMN tags TEXT NOT NULL DEFAULT ''",
[],
)
.context("Failed to add `tags` column to memory.db notes table")?;
}
let fts_existed: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'notes_fts'",
[],
|r| r.get(0),
)?;
if fts_existed > 0 {
return Ok(true);
}
match conn.execute_batch(
"CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
content, tags, content='notes', content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS notes_memory_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, content, tags) VALUES (new.id, new.content, new.tags);
END;
CREATE TRIGGER IF NOT EXISTS notes_memory_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, content, tags) VALUES('delete', old.id, old.content, old.tags);
END;
CREATE TRIGGER IF NOT EXISTS notes_memory_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, content, tags) VALUES('delete', old.id, old.content, old.tags);
INSERT INTO notes_fts(rowid, content, tags) VALUES (new.id, new.content, new.tags);
END;",
) {
Ok(()) => {
conn.execute("INSERT INTO notes_fts(notes_fts) VALUES('rebuild')", [])
.context("Failed to rebuild notes_fts after creation")?;
Ok(true)
}
Err(e) => {
tracing::warn!(
"FTS5 unavailable for memory.db notes ({e}); falling back to tokenized \
LIKE scoring for semantex_memory_recall"
);
Ok(false)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn store() -> (TempDir, MemoryStore) {
let dir = TempDir::new().unwrap();
let db_path = dir.path().join("memory.db");
let store = MemoryStore::open_at(&db_path).unwrap();
(dir, store)
}
#[test]
fn save_and_get_roundtrip() {
let (_dir, store) = store();
let id = store
.save(
"remember to use WAL mode",
"global",
&["sqlite".to_string()],
)
.unwrap();
let note = store.get(id).unwrap().expect("note exists");
assert_eq!(note.content, "remember to use WAL mode");
assert_eq!(note.scope, "global");
assert_eq!(note.tags, vec!["sqlite".to_string()]);
assert_eq!(note.source, NOTE_SOURCE);
assert!(note.created_ts > 0);
}
#[test]
fn save_rejects_empty_content_or_scope() {
let (_dir, store) = store();
assert!(store.save("", "global", &[]).is_err());
assert!(store.save(" ", "global", &[]).is_err());
assert!(store.save("hello", "", &[]).is_err());
assert!(store.save("hello", " ", &[]).is_err());
}
#[test]
fn tags_are_normalized_sorted_and_deduped() {
let (_dir, store) = store();
let id = store
.save(
"note",
"global",
&[
"zeta".to_string(),
"alpha".to_string(),
"alpha".to_string(),
" beta ".to_string(),
],
)
.unwrap();
let note = store.get(id).unwrap().unwrap();
assert_eq!(note.tags, vec!["alpha", "beta", "zeta"]);
}
#[test]
fn save_generates_distinct_ids_for_repeated_calls() {
let (_dir, store) = store();
let a = store.save("first", "global", &[]).unwrap();
let b = store.save("second", "global", &[]).unwrap();
let c = store.save("first", "global", &[]).unwrap(); assert!(a != b && b != c && a != c);
}
#[test]
fn list_orders_most_recent_first_and_filters_scope() {
let (_dir, store) = store();
store.save("one", "global", &[]).unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
store.save("two", "file:src/lib.rs", &[]).unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
store.save("three", "global", &[]).unwrap();
let all = store.list(None, 10).unwrap();
assert_eq!(all.len(), 3);
assert_eq!(all[0].content, "three", "most recent first");
let scoped = store.list(Some("global"), 10).unwrap();
assert_eq!(scoped.len(), 2);
assert!(scoped.iter().all(|n| n.scope == "global"));
}
#[test]
fn delete_removes_note_and_reports_whether_one_existed() {
let (_dir, store) = store();
let id = store.save("gone soon", "global", &[]).unwrap();
assert!(store.delete(id).unwrap());
assert!(store.get(id).unwrap().is_none());
assert!(!store.delete(id).unwrap(), "second delete finds nothing");
assert!(!store.delete(999_999).unwrap());
}
#[test]
fn recall_none_query_behaves_like_list() {
let (_dir, store) = store();
store.save("alpha note", "global", &[]).unwrap();
store.save("beta note", "global", &[]).unwrap();
let recalled = store.recall(None, None, 10).unwrap();
let listed = store.list(None, 10).unwrap();
assert_eq!(recalled, listed);
}
#[test]
fn recall_ranks_by_match_quality() {
let (_dir, store) = store();
store
.save("unrelated content about parsing json files", "global", &[])
.unwrap();
store
.save("sqlite schema migration guard notes", "global", &[])
.unwrap();
store
.save(
"migration guard migration guard: always add a guard before altering the schema migration",
"global",
&[],
)
.unwrap();
let results = store.recall(Some("migration guard"), None, 10).unwrap();
assert_eq!(
results.len(),
2,
"unrelated note must not match `migration guard`: {results:?}"
);
assert!(results[0].content.starts_with("migration guard migration"));
assert!(
!results.iter().any(|n| n.content.starts_with("unrelated")),
"unrelated note should never appear: {results:?}"
);
}
#[test]
fn recall_filters_by_scope() {
let (_dir, store) = store();
store
.save("shared note about caching", "global", &[])
.unwrap();
store
.save(
"caching detail specific to this file",
"file:src/cache.rs",
&[],
)
.unwrap();
let scoped = store
.recall(Some("caching"), Some("file:src/cache.rs"), 10)
.unwrap();
assert_eq!(scoped.len(), 1);
assert_eq!(scoped[0].scope, "file:src/cache.rs");
}
#[test]
fn recall_handles_fts5_special_characters_without_erroring() {
let (_dir, store) = store();
store.save("normal note", "global", &[]).unwrap();
for weird in [
"\"quoted\"",
"a-b:c*(d)",
"NOT AND OR",
"'; DROP TABLE notes; --",
] {
let result = store.recall(Some(weird), None, 10);
assert!(
result.is_ok(),
"query {weird:?} should not error: {result:?}"
);
}
}
#[test]
fn cap_behaviour_serial() {
let orig = std::env::var(MAX_NOTES_ENV).ok();
unsafe { std::env::set_var(MAX_NOTES_ENV, "3") };
{
let (_dir, store) = store();
for i in 0..5 {
store.save(&format!("note {i}"), "global", &[]).unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
}
assert_eq!(store.count(), 3, "cap must evict down to 3");
let remaining = store.list(None, 10).unwrap();
let contents: Vec<&str> = remaining.iter().map(|n| n.content.as_str()).collect();
assert!(contents.contains(&"note 4"));
assert!(contents.contains(&"note 3"));
assert!(contents.contains(&"note 2"));
assert!(!contents.contains(&"note 0"));
assert!(!contents.contains(&"note 1"));
}
unsafe { std::env::set_var(MAX_NOTES_ENV, "0") };
{
let (_dir, store) = store();
for i in 0..10 {
store.save(&format!("note {i}"), "global", &[]).unwrap();
}
assert_eq!(store.count(), 10);
}
match orig {
Some(v) => unsafe { std::env::set_var(MAX_NOTES_ENV, v) },
None => unsafe { std::env::remove_var(MAX_NOTES_ENV) },
}
}
#[test]
fn open_via_layout_creates_migrated_schema_and_fts5_is_enabled_here() {
let dir = TempDir::new().unwrap();
let store = MemoryStore::open(dir.path()).unwrap();
assert!(store.fts5_enabled());
assert!(layout::memory_db_path(dir.path()).exists());
}
#[test]
fn reopening_an_existing_memory_db_is_idempotent() {
let dir = TempDir::new().unwrap();
let id = {
let store = MemoryStore::open(dir.path()).unwrap();
store.save("persisted note", "global", &[]).unwrap()
};
let store = MemoryStore::open(dir.path()).unwrap();
let note = store.get(id).unwrap().expect("note survives reopen");
assert_eq!(note.content, "persisted note");
}
#[test]
fn branch_switch_leaves_memory_db_untouched() {
use crate::index::storage::ChunkStore;
let dir = TempDir::new().unwrap();
let project = dir.path();
let container = layout::container_dir(project);
std::fs::create_dir_all(&container).unwrap();
let chunk_store = ChunkStore::open(&container.join("chunks.db")).unwrap();
drop(chunk_store);
std::fs::write(
container.join("meta.json"),
serde_json::to_string(&serde_json::json!({"chunk_count": 0})).unwrap(),
)
.unwrap();
let first_note_id = {
let store = MemoryStore::open(project).unwrap();
store.save("branch A note", "global", &[]).unwrap()
};
layout::mirror_into_branch_dir(project, "branch-a-00000000").unwrap();
let second_note_id = {
let store = MemoryStore::open(project).unwrap();
store.save("branch B note", "global", &[]).unwrap()
};
layout::mirror_into_branch_dir(project, "branch-b-11111111").unwrap();
layout::restore_branch_dir_into_root(project, "branch-a-00000000").unwrap();
let store = MemoryStore::open(project).unwrap();
let note_a = store.get(first_note_id).unwrap();
let note_b = store.get(second_note_id).unwrap();
assert!(
note_a.is_some(),
"branch A's note must survive mirroring away and restoring back"
);
assert!(
note_b.is_some(),
"branch B's note must survive even though branch A's snapshot was restored \
over the code index — memory.db is shared, not per-branch"
);
assert_eq!(store.count(), 2);
}
}