use std::collections::HashMap;
use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use fs4::fs_std::FileExt;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use ulid::Ulid;
use crate::crypto::{self, KdfParams};
use crate::embed::{self, Embedder};
use crate::error::{MnemoError, Result};
use crate::format::{Header, FLAG_ENCRYPTED, PAGE_SIZE, PAYLOAD, VERSION, WRAPPED_DEK_LEN};
use crate::index::{IndexConfig, IndexInfo, IvfPqIndex};
use crate::memory::{
self, status as essence_status, CommitBatch, CommitReport, EssenceOpKind, EssenceScope,
EssenceSection, Filter, IngestReport, KeyScope, Memory, MemoryPatch, MemoryType, Metric, Scope,
ScoreWeights, ThreadTurn, TurnPolicy, WriteOutcome, CONTINUATION_TIMELINE_ENTRIES,
ESSENCE_DEFAULT_RENDER_BYTES, ESSENCE_TRANSITIONS, MNEMO_META_CONTINUES, MNEMO_META_INVALID_AT,
MNEMO_META_KEY, MNEMO_META_KIND, MNEMO_META_KIND_ESSENCE, MNEMO_META_KIND_THREAD_LINK,
MNEMO_META_KIND_TURN, MNEMO_META_LENGTH, MNEMO_META_PART, MNEMO_META_REFS, MNEMO_META_ROLE,
MNEMO_META_SECTION, MNEMO_META_SEQ, MNEMO_META_SHA256, MNEMO_META_STATUS,
MNEMO_META_STATUS_HISTORY, MNEMO_META_SUPERSEDED_BY, MNEMO_META_SUPERSEDES, MNEMO_META_THREAD,
MNEMO_META_TURN_ID, MNEMO_META_UPDATED_AT, THREAD_TOOL_RESULT_TRUNCATE_BYTES,
THREAD_TURN_CHUNK_BYTES,
};
use crate::pager::Pager;
use crate::result_cache::{
self, BatchState, CacheBudget, CacheDirectoryEntry, CacheEntry, CacheFlushPolicy, CachePutOpts,
CacheStats, CachedValue, CounterState, DirectoryIndex, SemanticCachePutOpts,
};
use crate::wal;
const DEFAULT_WAL_PAGES: u64 = 8;
const MIN_WAL_PAGES: u64 = 2;
pub const DEFAULT_MAX_SNAPSHOTS: usize = 256;
const TORN_HEADER_SCAN_CHUNK: usize = 1 << 20;
fn scan_header_recovery_candidates(
file: &mut File,
passphrase: &str,
) -> Result<Option<Vec<wal::Frame>>> {
let file_len = file.seek(SeekFrom::End(0))?;
if file_len < PAGE_SIZE as u64 * 2 {
return Ok(None); }
let mut best: Option<(u64, Vec<wal::Frame>)> = None;
let page_sz = PAGE_SIZE as u64;
let mut offset = page_sz;
let mut chunk = vec![0u8; TORN_HEADER_SCAN_CHUNK];
while offset < file_len {
let want = (TORN_HEADER_SCAN_CHUNK as u64).min(file_len - offset) as usize;
chunk.resize(want, 0);
file.seek(SeekFrom::Start(offset))?;
let mut filled = 0;
while filled < want {
match file.read(&mut chunk[filled..])? {
0 => break,
n => filled += n,
}
}
if filled == 0 {
break;
}
let mut inner = 0usize;
while inner + 4 <= filled {
if &chunk[inner..inner + 4] == wal::FRAME_MAGIC_BYTES {
let page_no = (offset + inner as u64) / page_sz;
if let Some((txn_id, frames)) = try_recover_candidate(file, page_no, passphrase)? {
let keep = match &best {
None => true,
Some((id, _)) => txn_id > *id,
};
if keep {
best = Some((txn_id, frames));
}
}
}
inner += PAGE_SIZE;
}
offset += filled as u64;
}
Ok(best.map(|(_, f)| f))
}
fn try_recover_candidate(
file: &mut File,
candidate_page_no: u64,
passphrase: &str,
) -> Result<Option<(u64, Vec<wal::Frame>)>> {
let Some((txn_id, frames)) = wal::scan_committed_txn(file, candidate_page_no)? else {
return Ok(None);
};
let header_bytes = match frames.iter().find(|(p, _)| *p == 0) {
Some((_, b)) => b,
None => return Ok(None),
};
if header_bytes.len() != PAGE_SIZE {
return Ok(None);
}
let mut hbuf = [0u8; PAGE_SIZE];
hbuf.copy_from_slice(header_bytes);
let candidate = match Header::from_page(&hbuf) {
Ok(h) => h,
Err(_) => return Ok(None),
};
if candidate.wal_start != candidate_page_no || candidate.wal_seq != txn_id {
return Ok(None);
}
let kdf = KdfParams {
m_cost: candidate.m_cost,
t_cost: candidate.t_cost,
p_cost: candidate.p_cost,
};
let kek = match crypto::derive_kek(passphrase.as_bytes(), &candidate.salt, kdf) {
Ok(k) => k,
Err(_) => return Ok(None),
};
let dek = match crypto::unwrap_dek(&kek, &candidate.dek_nonce, &candidate.wrapped_dek) {
Ok(d) => d,
Err(_) => return Ok(None),
};
if candidate.validate_seal(&dek).is_err() {
return Ok(None);
}
Ok(Some((txn_id, frames)))
}
#[derive(Clone, Copy, Debug)]
pub struct MnemoConfig {
pub dimensions: usize,
pub kdf: KdfParams,
pub wal_pages_initial: u64,
pub max_snapshots: usize,
}
impl Default for MnemoConfig {
fn default() -> Self {
Self {
dimensions: 768,
kdf: KdfParams::secure(),
wal_pages_initial: DEFAULT_WAL_PAGES,
max_snapshots: DEFAULT_MAX_SNAPSHOTS,
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct CatalogEntry {
id: u128,
start_page: u64,
page_count: u32,
len: u32,
deleted: bool,
accessed_at: i64,
access_count: u32,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct CatalogEntryV4 {
id: u128,
start_page: u64,
page_count: u32,
len: u32,
deleted: bool,
}
struct FlushPrelude {
cat_bytes: Option<Vec<u8>>,
idx_bytes: Option<Vec<u8>>,
cache_bytes: Option<Vec<u8>>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
struct Snapshot {
txn_id: u64,
created_at: i64,
catalog_start: u64,
catalog_pages: u64,
catalog_len: u64,
index_start: u64,
index_pages: u64,
index_len: u64,
memory_count: u64,
}
#[derive(Clone, Copy, Debug)]
pub struct SnapshotInfo {
pub txn_id: u64,
pub created_at: i64,
pub memory_count: u64,
}
#[derive(Clone, Debug)]
pub struct RecallRequest {
pub query: Vec<f32>,
pub top_k: usize,
pub memory_types: Option<Vec<MemoryType>>,
pub agent_id: Option<String>,
pub metric: Metric,
pub weights: ScoreWeights,
pub n_probe: Option<usize>,
pub n_rerank: Option<usize>,
pub track_access: bool,
pub filter: Filter,
pub max_bytes: Option<usize>,
}
impl RecallRequest {
pub fn new(query: Vec<f32>) -> Self {
Self {
query,
top_k: 10,
memory_types: None,
agent_id: None,
metric: Metric::Cosine,
weights: ScoreWeights::default(),
n_probe: None,
n_rerank: None,
track_access: true,
filter: Filter::default(),
max_bytes: None,
}
}
pub fn filter(mut self, filter: Filter) -> Self {
self.filter = filter;
self
}
pub fn max_bytes(mut self, n: usize) -> Self {
self.max_bytes = Some(n);
self
}
pub fn from_text(embedder: &dyn Embedder, query_text: &str) -> Result<Self> {
let mut vectors = embedder.embed(&[query_text])?;
let v = vectors.pop().ok_or_else(|| {
MnemoError::Invalid("embedder returned no vector for the query text".into())
})?;
Ok(Self::new(v))
}
pub fn top_k(mut self, k: usize) -> Self {
self.top_k = k;
self
}
pub fn types(mut self, t: Vec<MemoryType>) -> Self {
self.memory_types = Some(t);
self
}
pub fn agent(mut self, agent_id: impl Into<String>) -> Self {
self.agent_id = Some(agent_id.into());
self
}
pub fn metric(mut self, metric: Metric) -> Self {
self.metric = metric;
self
}
pub fn weights(mut self, weights: ScoreWeights) -> Self {
self.weights = weights;
self
}
pub fn n_probe(mut self, n: usize) -> Self {
self.n_probe = Some(n);
self
}
pub fn n_rerank(mut self, n: usize) -> Self {
self.n_rerank = Some(n);
self
}
pub fn track_access(mut self, track: bool) -> Self {
self.track_access = track;
self
}
}
#[derive(Clone, Debug)]
pub struct RecallResult {
pub memory: Memory,
pub score: f32,
pub similarity: f32,
}
#[derive(Clone, Debug)]
pub struct RecallResponse {
pub hits: Vec<RecallResult>,
pub truncated: bool,
pub used_exact_scan: bool,
}
#[derive(Clone, Debug)]
pub struct Stats {
pub memories: usize,
pub deleted: usize,
pub dimensions: usize,
pub file_bytes: u64,
pub agents: Vec<String>,
pub encrypted: bool,
pub created_at: i64,
pub index: Option<IndexInfo>,
pub wal_pages: u64,
pub fsyncs_last_flush: u32,
}
#[derive(Clone, Copy, Debug, Default)]
pub struct CompactReport {
pub before: usize,
pub after: usize,
pub cache_before: usize,
pub cache_after: usize,
}
pub struct Mnemo {
pager: Pager,
header: Header,
catalog: Vec<CatalogEntry>,
index: HashMap<u128, usize>,
#[allow(dead_code)]
path: PathBuf,
dimensions: usize,
kdf: KdfParams,
dirty_catalog: bool,
ann: Option<IvfPqIndex>,
dirty_index: bool,
manifest: Vec<Snapshot>,
max_snapshots: usize,
read_only: bool,
cache_dir: Vec<CacheDirectoryEntry>,
cache_idx: DirectoryIndex,
dirty_cache: bool,
cache_budgets: std::collections::HashMap<String, CacheBudget>,
cache_flush_policy: CacheFlushPolicy,
cache_batch: BatchState,
cache_counters: CounterState,
embedder: Option<std::sync::Arc<dyn Embedder>>,
fsyncs_last_flush: u32,
agent_set: Option<std::collections::BTreeSet<String>>,
key_index: Option<std::collections::HashMap<KeySlot, u128>>,
superseded: Option<std::collections::HashSet<u128>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub(crate) struct KeySlot {
pub(crate) scope_owner: Option<String>,
pub(crate) key: String,
}
fn open_with_lock(path: &Path, write: bool, create: bool) -> Result<File> {
let mut opts = OpenOptions::new();
opts.read(true);
if write {
opts.write(true);
}
if create {
opts.create(true).truncate(true);
}
let file = opts.open(path)?;
let result = if write {
FileExt::try_lock_exclusive(&file)
} else {
FileExt::try_lock_shared(&file)
};
match result {
Ok(()) => Ok(file),
Err(e) if is_lock_conflict(&e) => Err(MnemoError::Locked {
path: path.to_path_buf(),
}),
Err(e) => Err(MnemoError::Io(e)),
}
}
fn paths_refer_to_same_file(a: &Path, b: &Path) -> Option<bool> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let ma = std::fs::metadata(a).ok()?;
let mb = std::fs::metadata(b).ok()?;
Some(ma.dev() == mb.dev() && ma.ino() == mb.ino())
}
#[cfg(not(unix))]
{
let ca = std::fs::canonicalize(a).ok()?;
let cb = std::fs::canonicalize(b).ok()?;
Some(ca == cb)
}
}
fn temp_sibling(dest: &Path) -> PathBuf {
let mut buf = dest.as_os_str().to_os_string();
buf.push(".export-tmp");
PathBuf::from(buf)
}
fn is_lock_conflict(e: &std::io::Error) -> bool {
if e.kind() == std::io::ErrorKind::WouldBlock {
return true;
}
#[cfg(windows)]
if let Some(code) = e.raw_os_error() {
if code == 33 || code == 32 {
return true;
}
}
false
}
fn read_run_bytes(pager: &mut Pager, start: u64, pages: u64, len: u64) -> Result<Vec<u8>> {
let mut buf = Vec::with_capacity(len as usize);
for i in 0..pages {
buf.extend_from_slice(&pager.read_page(start + i)?);
}
buf.truncate(len as usize);
Ok(buf)
}
fn load_catalog(pager: &mut Pager, start: u64, pages: u64, len: u64) -> Result<Vec<CatalogEntry>> {
if pages == 0 {
return Ok(Vec::new());
}
let buf = read_run_bytes(pager, start, pages, len)?;
rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
}
fn load_catalog_v4(
pager: &mut Pager,
start: u64,
pages: u64,
len: u64,
) -> Result<Vec<CatalogEntryV4>> {
if pages == 0 {
return Ok(Vec::new());
}
let buf = read_run_bytes(pager, start, pages, len)?;
rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
}
fn migrate_v4_catalog(pager: &mut Pager, v4: Vec<CatalogEntryV4>) -> Result<Vec<CatalogEntry>> {
let mut out = Vec::with_capacity(v4.len());
for e in &v4 {
let (accessed_at, access_count) = if e.deleted {
(0i64, 0u32)
} else {
let mut buf = Vec::with_capacity(e.len as usize);
for i in 0..e.page_count as u64 {
buf.extend_from_slice(&pager.read_page(e.start_page + i)?);
}
buf.truncate(e.len as usize);
let mem: Memory = rmp_serde::from_slice(&buf)
.map_err(|err| MnemoError::Serialize(err.to_string()))?;
(mem.accessed_at, mem.access_count)
};
out.push(CatalogEntry {
id: e.id,
start_page: e.start_page,
page_count: e.page_count,
len: e.len,
deleted: e.deleted,
accessed_at,
access_count,
});
}
Ok(out)
}
fn load_manifest(pager: &mut Pager, start: u64, pages: u64, len: u64) -> Result<Vec<Snapshot>> {
if pages == 0 {
return Ok(Vec::new());
}
let buf = read_run_bytes(pager, start, pages, len)?;
rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
}
fn load_cache_directory(
pager: &mut Pager,
start: u64,
pages: u64,
len: u64,
) -> Result<Vec<CacheDirectoryEntry>> {
if pages == 0 {
return Ok(Vec::new());
}
let buf = read_run_bytes(pager, start, pages, len)?;
rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
}
fn load_index(
pager: &mut Pager,
start: u64,
pages: u64,
len: u64,
dims: usize,
) -> Result<Option<IvfPqIndex>> {
if pages == 0 {
return Ok(None);
}
let buf = read_run_bytes(pager, start, pages, len)?;
let mut idx: IvfPqIndex =
rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
if idx.dims() != dims {
return Err(MnemoError::Invalid(
"ANN index dimensionality does not match the database".into(),
));
}
idx.rebuild_assignment();
Ok(Some(idx))
}
fn is_manifest(m: &Memory) -> bool {
m.metadata
.get("topic")
.and_then(|v| v.as_str())
.map(|s| s.eq_ignore_ascii_case("manifest"))
.unwrap_or(false)
}
fn build_id_index(catalog: &[CatalogEntry]) -> HashMap<u128, usize> {
let mut m = HashMap::with_capacity(catalog.len());
for (i, e) in catalog.iter().enumerate() {
m.insert(e.id, i);
}
m
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
fn take_utf8_prefix(s: &str, max_bytes: usize) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
let mut cut = 0usize;
for (idx, _) in s.char_indices() {
if idx > max_bytes {
break;
}
cut = idx;
}
s[..cut].to_string()
}
fn status_of(m: &Memory) -> Option<&str> {
m.metadata.get(MNEMO_META_STATUS).and_then(|v| v.as_str())
}
fn render_essence_item(section: EssenceSection, m: &Memory) -> String {
if section == EssenceSection::Timeline {
let date = format_unix_date(m.created_at);
format!("- [{date}] {}\n", m.content)
} else {
let status = status_of(m).unwrap_or(essence_status::OPEN);
format!("- [{status}] {}\n", m.content)
}
}
fn format_unix_date(secs: i64) -> String {
let days_total = secs.div_euclid(86_400);
let (y, m, d) = civil_from_days(days_total);
format!("{y:04}-{m:02}-{d:02}")
}
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn chunk_utf8(s: &str, max_bytes: usize) -> Vec<String> {
if s.len() <= max_bytes {
return vec![s.to_string()];
}
let mut out = Vec::new();
let mut rest = s;
while rest.len() > max_bytes {
let head = take_utf8_prefix(rest, max_bytes);
let head_bytes = head.len();
out.push(head);
rest = &rest[head_bytes..];
}
if !rest.is_empty() {
out.push(rest.to_string());
}
out
}
impl Mnemo {
pub fn create(
path: impl Into<PathBuf>,
passphrase: &str,
config: MnemoConfig,
) -> Result<Mnemo> {
let path: PathBuf = path.into();
if config.dimensions == 0 {
return Err(MnemoError::Invalid("dimensions must be > 0".into()));
}
let file = open_with_lock(&path, true, true)?;
let salt = crypto::random_salt();
let dek = crypto::random_dek();
let kek = crypto::derive_kek(passphrase.as_bytes(), &salt, config.kdf)?;
let dek_nonce = crypto::random_nonce();
let wrapped = crypto::wrap_dek(&kek, &dek_nonce, &dek)?;
if wrapped.len() != WRAPPED_DEK_LEN {
return Err(MnemoError::Crypto("unexpected wrapped DEK length".into()));
}
let mut wrapped_dek = [0u8; WRAPPED_DEK_LEN];
wrapped_dek.copy_from_slice(&wrapped);
let mut header = Header {
version: VERSION,
page_size: PAGE_SIZE as u32,
flags: FLAG_ENCRYPTED,
dimensions: config.dimensions as u32,
created_at: memory::now_secs(),
write_counter: 0,
next_page: 1 + config.wal_pages_initial.max(MIN_WAL_PAGES),
catalog_start: 0,
catalog_pages: 0,
catalog_len: 0,
vector_count: 0,
m_cost: config.kdf.m_cost,
t_cost: config.kdf.t_cost,
p_cost: config.kdf.p_cost,
salt,
dek_nonce,
wrapped_dek,
index_start: 0,
index_pages: 0,
index_len: 0,
wal_start: 1,
wal_pages: config.wal_pages_initial.max(MIN_WAL_PAGES),
wal_seq: 0,
manifest_start: 0,
manifest_pages: 0,
manifest_len: 0,
seal_nonce: None,
seal_tag: None,
cache_start: 0,
cache_pages: 0,
cache_len: 0,
};
let mut pager = Pager::new(file, dek, 0, VERSION);
let mut page = header.to_page();
header.apply_seal(&mut page, pager.dek())?;
pager.write_raw(0, &page)?;
pager.sync()?;
Ok(Mnemo {
pager,
header,
catalog: Vec::new(),
index: HashMap::new(),
path,
dimensions: config.dimensions,
kdf: config.kdf,
dirty_catalog: false,
ann: None,
dirty_index: false,
manifest: Vec::new(),
max_snapshots: config.max_snapshots,
read_only: false,
cache_dir: Vec::new(),
cache_idx: DirectoryIndex::new(),
dirty_cache: false,
cache_budgets: std::collections::HashMap::new(),
cache_flush_policy: CacheFlushPolicy::default(),
cache_batch: BatchState::default(),
cache_counters: CounterState::default(),
embedder: None,
fsyncs_last_flush: 0,
agent_set: None,
key_index: None,
superseded: None,
})
}
pub fn open(path: impl Into<PathBuf>, passphrase: &str) -> Result<Mnemo> {
let path: PathBuf = path.into();
let mut file = open_with_lock(&path, true, false)?;
let mut hbuf = [0u8; PAGE_SIZE];
file.seek(SeekFrom::Start(0))?;
file.read_exact(&mut hbuf)?;
let mut header = match Header::from_page(&hbuf) {
Ok(h) => h,
Err(e) => {
let scan = scan_header_recovery_candidates(&mut file, passphrase)?;
let frames = scan.ok_or(e)?;
for (page_no, bytes) in &frames {
if bytes.len() != PAGE_SIZE {
return Err(MnemoError::Invalid("WAL frame is not page-sized".into()));
}
file.seek(SeekFrom::Start(page_no * PAGE_SIZE as u64))?;
file.write_all(bytes)?;
}
file.sync_all()?;
crate::bump_fsync();
file.seek(SeekFrom::Start(0))?;
file.read_exact(&mut hbuf)?;
Header::from_page(&hbuf)?
}
};
if let Some(frames) = wal::recover(
&mut file,
header.wal_start,
header.wal_pages,
header.wal_seq,
)? {
for (page_no, bytes) in &frames {
if bytes.len() != PAGE_SIZE {
return Err(MnemoError::Invalid("WAL frame is not page-sized".into()));
}
file.seek(SeekFrom::Start(page_no * PAGE_SIZE as u64))?;
file.write_all(bytes)?;
}
file.sync_all()?;
crate::bump_fsync();
file.seek(SeekFrom::Start(0))?;
file.read_exact(&mut hbuf)?;
header = Header::from_page(&hbuf)?;
}
let kdf = KdfParams {
m_cost: header.m_cost,
t_cost: header.t_cost,
p_cost: header.p_cost,
};
let kek = crypto::derive_kek(passphrase.as_bytes(), &header.salt, kdf)?;
let dek = crypto::unwrap_dek(&kek, &header.dek_nonce, &header.wrapped_dek)?;
header.validate_seal(&dek)?;
let on_disk_version = header.version;
let mut pager = Pager::new(file, dek, header.write_counter, on_disk_version);
let dimensions = header.dimensions as usize;
let (catalog, migrated_from_v4) = if on_disk_version >= 5 {
(
load_catalog(
&mut pager,
header.catalog_start,
header.catalog_pages,
header.catalog_len,
)?,
false,
)
} else if on_disk_version == 4 {
let v4 = load_catalog_v4(
&mut pager,
header.catalog_start,
header.catalog_pages,
header.catalog_len,
)?;
(migrate_v4_catalog(&mut pager, v4)?, true)
} else {
return Err(MnemoError::UnsupportedVersion(on_disk_version));
};
let index = build_id_index(&catalog);
let ann = load_index(
&mut pager,
header.index_start,
header.index_pages,
header.index_len,
dimensions,
)?;
let mut manifest = load_manifest(
&mut pager,
header.manifest_start,
header.manifest_pages,
header.manifest_len,
)?;
let migrated_pages_to_v6 = on_disk_version < 6;
if migrated_pages_to_v6 {
for e in &catalog {
if e.deleted {
continue;
}
for i in 0..e.page_count as u64 {
let payload = pager.read_page(e.start_page + i)?;
pager.write_page(e.start_page + i, &payload)?;
}
}
pager.set_version(6);
}
let migrated_header_to_v7 = on_disk_version < 7;
if migrated_header_to_v7 {
pager.set_version(7);
}
let migrated_header_to_v8 = on_disk_version < 8;
if migrated_header_to_v8 {
pager.set_version(VERSION);
}
let migrated = migrated_from_v4
|| migrated_pages_to_v6
|| migrated_header_to_v7
|| migrated_header_to_v8;
if migrated {
header.version = VERSION;
manifest.clear();
}
let dirty_index = migrated_pages_to_v6 && ann.is_some();
let cache_dir = load_cache_directory(
&mut pager,
header.cache_start,
header.cache_pages,
header.cache_len,
)?;
let cache_idx = DirectoryIndex::build(&cache_dir);
Ok(Mnemo {
pager,
header,
catalog,
index,
path,
dimensions,
kdf,
dirty_catalog: migrated,
ann,
dirty_index,
manifest,
max_snapshots: DEFAULT_MAX_SNAPSHOTS,
read_only: false,
cache_dir,
cache_idx,
dirty_cache: false,
cache_budgets: std::collections::HashMap::new(),
cache_flush_policy: CacheFlushPolicy::default(),
cache_batch: BatchState::default(),
cache_counters: CounterState::default(),
embedder: None,
fsyncs_last_flush: 0,
agent_set: None,
key_index: None,
superseded: None,
})
}
pub fn open_read_only(path: impl Into<PathBuf>, passphrase: &str) -> Result<Mnemo> {
let path: PathBuf = path.into();
let mut file = open_with_lock(&path, false, false)?;
let mut hbuf = [0u8; PAGE_SIZE];
file.seek(SeekFrom::Start(0))?;
file.read_exact(&mut hbuf)?;
let header = match Header::from_page(&hbuf) {
Ok(h) => h,
Err(parse_err) => {
let recoverable = scan_header_recovery_candidates(&mut file, passphrase)?;
return Err(match (parse_err, recoverable) {
(_, Some(_)) => MnemoError::NeedsWriteOpen {
reason: "torn header page needs recovery from WAL",
},
(e, None) => e,
});
}
};
if header.version < VERSION {
return Err(MnemoError::NeedsWriteOpen {
reason: "pending format-version migration",
});
}
if wal::recover(
&mut file,
header.wal_start,
header.wal_pages,
header.wal_seq,
)?
.is_some()
{
return Err(MnemoError::NeedsWriteOpen {
reason: "pending WAL recovery",
});
}
let kdf = KdfParams {
m_cost: header.m_cost,
t_cost: header.t_cost,
p_cost: header.p_cost,
};
let kek = crypto::derive_kek(passphrase.as_bytes(), &header.salt, kdf)?;
let dek = crypto::unwrap_dek(&kek, &header.dek_nonce, &header.wrapped_dek)?;
header.validate_seal(&dek)?;
let mut pager = Pager::new(file, dek, header.write_counter, header.version);
let dimensions = header.dimensions as usize;
let catalog = load_catalog(
&mut pager,
header.catalog_start,
header.catalog_pages,
header.catalog_len,
)?;
let index = build_id_index(&catalog);
let ann = load_index(
&mut pager,
header.index_start,
header.index_pages,
header.index_len,
dimensions,
)?;
let manifest = load_manifest(
&mut pager,
header.manifest_start,
header.manifest_pages,
header.manifest_len,
)?;
let cache_dir = load_cache_directory(
&mut pager,
header.cache_start,
header.cache_pages,
header.cache_len,
)?;
let cache_idx = DirectoryIndex::build(&cache_dir);
Ok(Mnemo {
pager,
header,
catalog,
index,
path,
dimensions,
kdf,
dirty_catalog: false,
ann,
dirty_index: false,
manifest,
max_snapshots: DEFAULT_MAX_SNAPSHOTS,
read_only: true,
cache_dir,
cache_idx,
dirty_cache: false,
cache_budgets: std::collections::HashMap::new(),
cache_flush_policy: CacheFlushPolicy::default(),
cache_batch: BatchState::default(),
cache_counters: CounterState::default(),
embedder: None,
fsyncs_last_flush: 0,
agent_set: None,
key_index: None,
superseded: None,
})
}
pub fn set_max_snapshots(&mut self, max: usize) {
self.max_snapshots = max;
}
pub fn dimensions(&self) -> usize {
self.dimensions
}
pub fn len(&self) -> usize {
self.catalog.iter().filter(|e| !e.deleted).count()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
fn write_record(&mut self, bytes: &[u8]) -> Result<(u64, u32)> {
let pc = bytes.len().div_ceil(PAYLOAD).max(1);
let start = self.header.next_page;
self.header.next_page += pc as u64;
for i in 0..pc {
let lo = i * PAYLOAD;
let hi = ((i + 1) * PAYLOAD).min(bytes.len());
self.pager.write_page(start + i as u64, &bytes[lo..hi])?;
}
Ok((start, pc as u32))
}
fn read_record(&mut self, e: &CatalogEntry) -> Result<Vec<u8>> {
let mut buf = Vec::with_capacity(e.len as usize);
for i in 0..e.page_count as u64 {
buf.extend_from_slice(&self.pager.read_page(e.start_page + i)?);
}
buf.truncate(e.len as usize);
Ok(buf)
}
fn read_memory(&mut self, e: &CatalogEntry) -> Result<Memory> {
let bytes = self.read_record(e)?;
let mut m: Memory =
rmp_serde::from_slice(&bytes).map_err(|err| MnemoError::Serialize(err.to_string()))?;
m.accessed_at = e.accessed_at;
m.access_count = e.access_count;
Ok(m)
}
fn put(&mut self, mut m: Memory) -> Result<Ulid> {
if m.vector.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: m.vector.len(),
});
}
if embed::is_zero_vector(&m.vector) {
return Err(MnemoError::Invalid(
"all-zero vector refused: it scores 0.0 on every cosine query and \
pollutes recall; check your embedder returned a real embedding"
.into(),
));
}
if m.id == Ulid::nil() {
m.id = Ulid::new();
}
let id_u: u128 = m.id.0;
let bytes = rmp_serde::to_vec(&m).map_err(|e| MnemoError::Serialize(e.to_string()))?;
let (start, pc) = self.write_record(&bytes)?;
let (prev_accessed, prev_count) = match self.index.get(&id_u).copied() {
Some(idx) => (
self.catalog[idx].accessed_at,
self.catalog[idx].access_count,
),
None => (m.accessed_at, m.access_count),
};
let entry = CatalogEntry {
id: id_u,
start_page: start,
page_count: pc,
len: bytes.len() as u32,
deleted: false,
accessed_at: prev_accessed,
access_count: prev_count,
};
match self.index.get(&id_u).copied() {
Some(idx) => self.catalog[idx] = entry,
None => {
self.index.insert(id_u, self.catalog.len());
self.catalog.push(entry);
}
}
self.dirty_catalog = true;
if let Some(ann) = &mut self.ann {
ann.add(id_u, &m.vector);
self.dirty_index = true;
}
if let Some(set) = &mut self.agent_set {
set.insert(m.agent_id.clone());
}
Ok(m.id)
}
pub fn set_embedder<E: Embedder + 'static>(&mut self, embedder: E) -> Result<()> {
let identity = embedder.identity();
if identity.dimensions != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: identity.dimensions,
});
}
let onboarding = self.about()?;
let mut upsert_target: Option<usize> = None;
for (i, m) in onboarding.iter().enumerate() {
if let Some(saw) = embed::read_identity_from_metadata(&m.metadata) {
if saw != identity {
return Err(embed::identity_mismatch_err(&saw, &identity));
}
self.embedder = Some(std::sync::Arc::new(embedder));
return Ok(());
}
if upsert_target.is_none() {
upsert_target = Some(i);
}
}
if !self.read_only {
if let Some(idx) = upsert_target {
let mut m = onboarding[idx].clone();
embed::write_identity_into_metadata(&mut m.metadata, &identity);
self.put(m)?;
self.flush()?;
}
}
self.embedder = Some(std::sync::Arc::new(embedder));
Ok(())
}
pub fn embedder(&self) -> Option<&dyn Embedder> {
self.embedder.as_deref()
}
pub fn embedder_arc(&self) -> Option<std::sync::Arc<dyn Embedder>> {
self.embedder.clone()
}
pub fn remember_text(&mut self, content: &str, memory_type: MemoryType) -> Result<Ulid> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let embedder = self.embedder.as_ref().ok_or_else(|| {
MnemoError::Invalid(
"no embedder registered on this handle; call set_embedder first".into(),
)
})?;
let mut vectors = embedder.embed(&[content])?;
let vector = vectors.pop().ok_or_else(|| {
MnemoError::Invalid("embedder returned no vector for the input text".into())
})?;
if vector.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: vector.len(),
});
}
let memory = Memory::new(content.to_string(), memory_type, vector);
self.remember(memory)
}
pub fn remember(&mut self, memory: Memory) -> Result<Ulid> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
self.put(memory)
}
pub fn get(&mut self, id: &Ulid) -> Result<Memory> {
let idx = *self
.index
.get(&id.0)
.ok_or_else(|| MnemoError::NotFound(id.to_string()))?;
let entry = self.catalog[idx].clone();
if entry.deleted {
return Err(MnemoError::NotFound(id.to_string()));
}
self.read_memory(&entry)
}
pub fn delete(&mut self, id: &Ulid) -> Result<()> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let idx = *self
.index
.get(&id.0)
.ok_or_else(|| MnemoError::NotFound(id.to_string()))?;
if !self.catalog[idx].deleted {
self.catalog[idx].deleted = true;
self.dirty_catalog = true;
}
if let Some(ann) = &mut self.ann {
ann.remove(id.0);
self.dirty_index = true;
}
self.agent_set = None;
self.key_index = None;
self.superseded = None;
Ok(())
}
fn key_slot_for(memory: &Memory, key: &str) -> crate::store::KeySlot {
let scope_owner = match memory.scope {
Scope::Private => Some(memory.agent_id.clone()),
Scope::Shared => None,
};
crate::store::KeySlot {
scope_owner,
key: key.to_string(),
}
}
fn ensure_key_caches(&mut self) -> Result<()> {
if self.key_index.is_some() && self.superseded.is_some() {
return Ok(());
}
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let mut keys: std::collections::HashMap<crate::store::KeySlot, u128> =
std::collections::HashMap::new();
let mut superseded: std::collections::HashSet<u128> = std::collections::HashSet::new();
for e in &entries {
let m = self.read_memory(e)?;
if m.metadata.get(MNEMO_META_SUPERSEDED_BY).is_some() {
superseded.insert(e.id);
continue;
}
if let Some(Value::String(k)) = m.metadata.get(MNEMO_META_KEY) {
let slot = Self::key_slot_for(&m, k);
keys.entry(slot)
.and_modify(|cur| {
if *cur < e.id {
*cur = e.id;
}
})
.or_insert(e.id);
}
}
self.key_index = Some(keys);
self.superseded = Some(superseded);
Ok(())
}
fn is_identical_keyed_write(incoming: &Memory, live: &Memory) -> bool {
if incoming.content != live.content {
return false;
}
if incoming.memory_type != live.memory_type {
return false;
}
if (incoming.importance - live.importance).abs() > f32::EPSILON {
return false;
}
if incoming.vector != live.vector {
return false;
}
let strip = |m: &Map<String, Value>| -> Map<String, Value> {
m.iter()
.filter(|(k, _)| !k.starts_with(crate::memory::MNEMO_META_NS))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
};
strip(&incoming.metadata) == strip(&live.metadata)
}
pub fn remember_keyed(
&mut self,
mut memory: Memory,
key: &str,
) -> Result<(Ulid, WriteOutcome)> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
if key.is_empty() {
return Err(MnemoError::Invalid(
"remember_keyed: 'key' must not be empty".into(),
));
}
self.ensure_key_caches()?;
let slot = Self::key_slot_for(&memory, key);
let existing = self.key_index.as_ref().and_then(|k| k.get(&slot).copied());
memory
.metadata
.insert(MNEMO_META_KEY.into(), Value::String(key.to_string()));
if let Some(prev_id_u) = existing {
let prev_idx = *self
.index
.get(&prev_id_u)
.ok_or_else(|| MnemoError::Invalid("key_index drift".into()))?;
let prev_entry = self.catalog[prev_idx].clone();
let prev_mem = self.read_memory(&prev_entry)?;
if Self::is_identical_keyed_write(&memory, &prev_mem) {
return Ok((Ulid(prev_id_u), WriteOutcome::Unchanged));
}
let now = memory::now_secs();
memory.id = Ulid::nil(); memory.metadata.insert(
MNEMO_META_SUPERSEDES.into(),
Value::String(Ulid(prev_id_u).to_string()),
);
let new_id = self.put(memory)?;
let mut prev_mut = prev_mem;
prev_mut.metadata.insert(
MNEMO_META_SUPERSEDED_BY.into(),
Value::String(new_id.to_string()),
);
prev_mut
.metadata
.insert(MNEMO_META_INVALID_AT.into(), Value::Number(now.into()));
self.put(prev_mut)?;
if let Some(k) = self.key_index.as_mut() {
k.insert(slot, new_id.0);
}
if let Some(s) = self.superseded.as_mut() {
s.insert(prev_id_u);
}
return Ok((new_id, WriteOutcome::Superseded));
}
memory.id = Ulid::nil();
let new_id = self.put(memory)?;
if let Some(k) = self.key_index.as_mut() {
k.insert(slot, new_id.0);
}
Ok((new_id, WriteOutcome::Added))
}
pub fn remember_keyed_text(
&mut self,
content: &str,
memory_type: MemoryType,
key: &str,
) -> Result<(Ulid, WriteOutcome)> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let embedder = self.embedder.as_ref().ok_or_else(|| {
MnemoError::Invalid(
"no embedder registered on this handle; call set_embedder first".into(),
)
})?;
let mut vectors = embedder.embed(&[content])?;
let vector = vectors.pop().ok_or_else(|| {
MnemoError::Invalid("embedder returned no vector for the input text".into())
})?;
if vector.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: vector.len(),
});
}
let memory = Memory::new(content.to_string(), memory_type, vector);
self.remember_keyed(memory, key)
}
pub fn update(&mut self, id: &Ulid, patch: MemoryPatch) -> Result<WriteOutcome> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
if patch.content.is_some() && patch.vector.is_none() {
return Err(MnemoError::Invalid(
"update: a content change needs a matching vector (or call update_text \
to re-embed through the registered embedder)"
.into(),
));
}
let idx = *self
.index
.get(&id.0)
.ok_or_else(|| MnemoError::NotFound(id.to_string()))?;
if self.catalog[idx].deleted {
return Err(MnemoError::NotFound(id.to_string()));
}
let entry = self.catalog[idx].clone();
let existing = self.read_memory(&entry)?;
let mut cand = existing.clone();
if let Some(c) = &patch.content {
cand.content = c.clone();
}
if let Some(v) = &patch.vector {
if v.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: v.len(),
});
}
cand.vector = v.clone();
}
if let Some(i) = patch.importance {
cand.importance = i.clamp(0.0, 1.0);
}
if let Some(mt) = patch.memory_type {
cand.memory_type = mt;
}
if let Some(new_meta) = patch.metadata {
let reserved: Vec<(String, Value)> = existing
.metadata
.iter()
.filter(|(k, _)| k.starts_with(crate::memory::MNEMO_META_NS))
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
cand.metadata = new_meta;
for (k, v) in reserved {
cand.metadata.insert(k, v);
}
}
if let Some(ttl) = patch.ttl_secs {
cand.ttl_secs = ttl;
}
if cand.content == existing.content
&& cand.vector == existing.vector
&& (cand.importance - existing.importance).abs() <= f32::EPSILON
&& cand.memory_type == existing.memory_type
&& cand.metadata == existing.metadata
&& cand.ttl_secs == existing.ttl_secs
{
return Ok(WriteOutcome::Unchanged);
}
let now = memory::now_secs();
cand.metadata
.insert(MNEMO_META_UPDATED_AT.into(), Value::Number(now.into()));
cand.id = existing.id;
cand.created_at = existing.created_at;
self.put(cand)?;
self.key_index = None;
Ok(WriteOutcome::Updated)
}
pub fn update_text(
&mut self,
id: &Ulid,
new_text: &str,
mut patch: MemoryPatch,
) -> Result<WriteOutcome> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let embedder = self.embedder.as_ref().ok_or_else(|| {
MnemoError::Invalid(
"no embedder registered on this handle; call set_embedder first".into(),
)
})?;
let mut vectors = embedder.embed(&[new_text])?;
let vector = vectors.pop().ok_or_else(|| {
MnemoError::Invalid("embedder returned no vector for the input text".into())
})?;
if vector.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: vector.len(),
});
}
patch.content = Some(new_text.to_string());
patch.vector = Some(vector);
self.update(id, patch)
}
pub fn history(&mut self, key: &str, agent_id: Option<&str>) -> Result<Vec<Memory>> {
let scope = match agent_id {
Some(a) => KeyScope::Private(a.to_string()),
None => KeyScope::private_default(),
};
self.history_in(key, scope)
}
pub fn history_in(&mut self, key: &str, scope: KeyScope) -> Result<Vec<Memory>> {
self.ensure_key_caches()?;
let slot = crate::store::KeySlot {
scope_owner: match scope {
KeyScope::Private(a) => Some(a),
KeyScope::Shared => None,
},
key: key.to_string(),
};
let live_id = self.key_index.as_ref().and_then(|k| k.get(&slot).copied());
let mut out = Vec::new();
let mut cursor = live_id;
while let Some(id_u) = cursor {
let idx = *self.index.get(&id_u).ok_or_else(|| {
MnemoError::Invalid("history: superseded chain references missing id".into())
})?;
let entry = self.catalog[idx].clone();
if entry.deleted {
break;
}
let m = self.read_memory(&entry)?;
let next = m
.metadata
.get(MNEMO_META_SUPERSEDES)
.and_then(|v| v.as_str())
.and_then(|s| Ulid::from_string(s).ok())
.map(|u| u.0);
out.push(m);
cursor = next;
}
Ok(out)
}
pub fn ingest_thread(&mut self, thread_id: &str, turns: &[ThreadTurn]) -> Result<IngestReport> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
if thread_id.is_empty() {
return Err(MnemoError::Invalid(
"ingest_thread: 'thread_id' must not be empty".into(),
));
}
let mut report = IngestReport::default();
if turns.is_empty() {
return Ok(report);
}
let existing_turn_ids = self.collect_turn_ids_in_thread(thread_id)?;
let mut next_seq = self.max_seq_in_thread(thread_id)?.map_or(0, |n| n + 1);
struct Pending {
turn_id: String,
role: String,
created_at: i64,
parts: Vec<String>,
truncation: Option<(String, u64)>,
}
let mut pending: Vec<Pending> = Vec::new();
let mut all_parts: Vec<String> = Vec::new();
let now = memory::now_secs();
for t in turns {
if t.turn_id.is_empty() {
return Err(MnemoError::Invalid(
"ingest_thread: turn_id must not be empty".into(),
));
}
if existing_turn_ids.contains(&t.turn_id) {
report.skipped_existing_turns += 1;
continue;
}
let content_bytes = t.content.len();
let is_tool = t.role == "tool";
let (parts, truncation) =
if is_tool && content_bytes > THREAD_TOOL_RESULT_TRUNCATE_BYTES {
report.truncated_turns += 1;
let hash = {
use sha2::{Digest, Sha256};
let h = Sha256::digest(t.content.as_bytes());
hex_encode(&h)
};
let truncated = take_utf8_prefix(&t.content, THREAD_TOOL_RESULT_TRUNCATE_BYTES);
(vec![truncated], Some((hash, content_bytes as u64)))
} else if content_bytes > THREAD_TURN_CHUNK_BYTES {
report.chunked_turns += 1;
(chunk_utf8(&t.content, THREAD_TURN_CHUNK_BYTES), None)
} else {
(vec![t.content.clone()], None)
};
for p in &parts {
all_parts.push(p.clone());
}
pending.push(Pending {
turn_id: t.turn_id.clone(),
role: t.role.clone(),
created_at: t.timestamp.unwrap_or(now),
parts,
truncation,
});
}
if pending.is_empty() {
return Ok(report);
}
let embedder = self.embedder.as_ref().ok_or_else(|| {
MnemoError::Invalid(
"ingest_thread: no embedder registered on this handle; call set_embedder first"
.into(),
)
})?;
let as_refs: Vec<&str> = all_parts.iter().map(|s| s.as_str()).collect();
let vectors = embedder.embed(&as_refs)?;
if vectors.len() != all_parts.len() {
return Err(MnemoError::Invalid(format!(
"ingest_thread: embedder returned {} vectors for {} parts",
vectors.len(),
all_parts.len()
)));
}
for v in &vectors {
if v.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: v.len(),
});
}
}
let mut vec_cursor = 0usize;
for p in &pending {
let this_seq = next_seq;
next_seq += 1;
for (part_idx, part_text) in p.parts.iter().enumerate() {
let vec = vectors[vec_cursor].clone();
vec_cursor += 1;
let mut m =
Memory::new(part_text.clone(), MemoryType::Episodic, vec).with_importance(0.5);
m.created_at = p.created_at;
m.accessed_at = p.created_at;
m.metadata.insert(
MNEMO_META_KIND.into(),
Value::String(MNEMO_META_KIND_TURN.into()),
);
m.metadata.insert(
MNEMO_META_THREAD.into(),
Value::String(thread_id.to_string()),
);
m.metadata
.insert(MNEMO_META_TURN_ID.into(), Value::String(p.turn_id.clone()));
m.metadata
.insert(MNEMO_META_SEQ.into(), Value::Number(this_seq.into()));
m.metadata
.insert(MNEMO_META_ROLE.into(), Value::String(p.role.clone()));
if p.parts.len() > 1 {
m.metadata.insert(
MNEMO_META_PART.into(),
Value::Number((part_idx as u64).into()),
);
}
if let Some((sha, len)) = &p.truncation {
m.metadata
.insert(MNEMO_META_SHA256.into(), Value::String(sha.clone()));
m.metadata
.insert(MNEMO_META_LENGTH.into(), Value::Number((*len).into()));
}
self.put(m)?;
report.added_records += 1;
}
}
Ok(report)
}
fn collect_turn_ids_in_thread(
&mut self,
thread_id: &str,
) -> Result<std::collections::HashSet<String>> {
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let mut out = std::collections::HashSet::new();
for e in &entries {
let m = self.read_memory(e)?;
if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(thread_id) {
continue;
}
if let Some(tid) = m.metadata.get(MNEMO_META_TURN_ID).and_then(|v| v.as_str()) {
out.insert(tid.to_string());
}
}
Ok(out)
}
fn max_seq_in_thread(&mut self, thread_id: &str) -> Result<Option<u64>> {
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let mut best: Option<u64> = None;
for e in &entries {
let m = self.read_memory(e)?;
if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(thread_id) {
continue;
}
if let Some(n) = m.metadata.get(MNEMO_META_SEQ).and_then(|v| v.as_u64()) {
best = Some(best.map_or(n, |cur| cur.max(n)));
}
}
Ok(best)
}
pub fn thread_export(&mut self, thread_id: &str) -> Result<Vec<Memory>> {
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let mut out: Vec<Memory> = Vec::new();
for e in &entries {
let m = self.read_memory(e)?;
if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(thread_id) {
continue;
}
if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
!= Some(MNEMO_META_KIND_TURN)
{
continue;
}
out.push(m);
}
out.sort_by(|a, b| {
let seq_a = a
.metadata
.get(MNEMO_META_SEQ)
.and_then(|v| v.as_u64())
.unwrap_or(0);
let seq_b = b
.metadata
.get(MNEMO_META_SEQ)
.and_then(|v| v.as_u64())
.unwrap_or(0);
let part_a = a
.metadata
.get(MNEMO_META_PART)
.and_then(|v| v.as_u64())
.unwrap_or(0);
let part_b = b
.metadata
.get(MNEMO_META_PART)
.and_then(|v| v.as_u64())
.unwrap_or(0);
seq_a.cmp(&seq_b).then_with(|| part_a.cmp(&part_b))
});
Ok(out)
}
pub fn thread_forget(&mut self, thread_id: &str) -> Result<usize> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let mut victims: Vec<Ulid> = Vec::new();
for e in &entries {
let m = self.read_memory(e)?;
if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(thread_id) {
continue;
}
if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
!= Some(MNEMO_META_KIND_TURN)
{
continue;
}
victims.push(m.id);
}
let n = victims.len();
for id in &victims {
self.delete(id)?;
}
Ok(n)
}
pub fn essence_add(
&mut self,
scope: EssenceScope,
section: EssenceSection,
text: &str,
refs: Option<Vec<String>>,
) -> Result<Ulid> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
if section == EssenceSection::Timeline {
return Err(MnemoError::Invalid(
"essence_add: use essence_append_timeline for the timeline section".into(),
));
}
if text.is_empty() {
return Err(MnemoError::Invalid(
"essence_add: text must not be empty".into(),
));
}
if text.len() > section.item_max_bytes() {
return Err(MnemoError::Invalid(format!(
"essence_add: '{}' items must be <= {} bytes; got {}",
section.as_str(),
section.item_max_bytes(),
text.len()
)));
}
let now = memory::now_secs();
let mem = self.build_essence_record(scope, section, text, refs, Some(now))?;
let id = self.put(mem)?;
Ok(id)
}
pub fn essence_append_timeline(
&mut self,
scope: EssenceScope,
text: &str,
refs: Option<Vec<String>>,
) -> Result<Ulid> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
if text.is_empty() {
return Err(MnemoError::Invalid(
"essence_append_timeline: text must not be empty".into(),
));
}
if text.len() > EssenceSection::Timeline.item_max_bytes() {
return Err(MnemoError::Invalid(format!(
"essence_append_timeline: entries must be <= {} bytes; got {}",
EssenceSection::Timeline.item_max_bytes(),
text.len()
)));
}
let mem = self.build_essence_record(scope, EssenceSection::Timeline, text, refs, None)?;
let id = self.put(mem)?;
Ok(id)
}
pub fn essence_update(
&mut self,
id: &Ulid,
text: &str,
refs: Option<Vec<String>>,
) -> Result<WriteOutcome> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let existing = self.load_essence_record(id)?;
let section = self.section_of(&existing)?;
if section == EssenceSection::Timeline {
return Err(MnemoError::Invalid(
"essence_update: timeline entries are append-only; append a new one instead".into(),
));
}
if text.is_empty() {
return Err(MnemoError::Invalid(
"essence_update: text must not be empty".into(),
));
}
if text.len() > section.item_max_bytes() {
return Err(MnemoError::Invalid(format!(
"essence_update: '{}' items must be <= {} bytes; got {}",
section.as_str(),
section.item_max_bytes(),
text.len()
)));
}
let mut patch = MemoryPatch {
content: Some(text.to_string()),
..Default::default()
};
patch.vector = Some(existing.vector.clone());
if let Some(r) = refs {
let mut meta = Map::new();
meta.insert(
MNEMO_META_REFS.into(),
Value::Array(r.into_iter().map(Value::String).collect()),
);
patch.metadata = Some(meta);
}
self.update(id, patch)
}
pub fn essence_resolve(&mut self, id: &Ulid) -> Result<WriteOutcome> {
self.essence_transition_status(id, essence_status::RESOLVED)
}
pub fn essence_remove(&mut self, id: &Ulid) -> Result<()> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
self.delete(id)
}
pub fn essence(&mut self, scope: EssenceScope, max_bytes: usize) -> Result<String> {
let max_bytes = if max_bytes == 0 {
ESSENCE_DEFAULT_RENDER_BYTES
} else {
max_bytes
};
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let mut by_section: std::collections::BTreeMap<&'static str, Vec<Memory>> =
std::collections::BTreeMap::new();
for section in EssenceSection::render_order() {
by_section.insert(section.as_str(), Vec::new());
}
for e in &entries {
let m = self.read_memory(e)?;
if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
!= Some(MNEMO_META_KIND_ESSENCE)
{
continue;
}
let rec_thread = m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str());
let matches_scope = match (&scope, rec_thread) {
(EssenceScope::Project, None) => true,
(EssenceScope::Thread(_), None) => true, (EssenceScope::Thread(t), Some(rt)) => rt == t,
(EssenceScope::Project, Some(_)) => false,
};
if !matches_scope {
continue;
}
let section_str = m
.metadata
.get(MNEMO_META_SECTION)
.and_then(|v| v.as_str())
.unwrap_or("timeline");
by_section
.entry(
EssenceSection::parse(section_str)
.map(|s| s.as_str())
.unwrap_or("timeline"),
)
.or_default()
.push(m);
}
for (name, items) in by_section.iter_mut() {
let newest_first = matches!(*name, "decisions" | "timeline");
items.sort_by(|a, b| {
let a_resolved = status_of(a) == Some(essence_status::RESOLVED);
let b_resolved = status_of(b) == Some(essence_status::RESOLVED);
match (a_resolved, b_resolved) {
(false, true) => std::cmp::Ordering::Less,
(true, false) => std::cmp::Ordering::Greater,
_ => {
if newest_first {
b.created_at.cmp(&a.created_at)
} else {
a.created_at.cmp(&b.created_at)
}
}
}
});
}
let header_reserve = 256usize; let body_budget = max_bytes.saturating_sub(header_reserve);
let per_section_budget = |section: EssenceSection| -> usize {
match section {
EssenceSection::Intent => body_budget / 8,
EssenceSection::Conventions => body_budget / 6,
EssenceSection::Decisions => body_budget / 4,
EssenceSection::State => body_budget / 8,
EssenceSection::Open => body_budget / 8,
EssenceSection::Next => body_budget / 12,
EssenceSection::Lessons => body_budget / 12,
EssenceSection::Timeline => body_budget / 4,
}
};
let mut out = String::with_capacity(max_bytes);
let scope_label = match &scope {
EssenceScope::Thread(t) => format!("thread `{t}`"),
EssenceScope::Project => "project".to_string(),
};
out.push_str(&format!("# essence — {scope_label}\n\n"));
if let EssenceScope::Thread(new_id) = &scope {
if let Some(prev_id) = self.predecessor_thread(new_id)? {
let continuation = self
.render_continuation_prelude(&prev_id, max_bytes.saturating_sub(out.len()))?;
if !continuation.is_empty() && out.len() + continuation.len() <= max_bytes {
out.push_str(&continuation);
}
}
}
for section in EssenceSection::render_order() {
let items = match by_section.get(section.as_str()) {
Some(v) if !v.is_empty() => v,
_ => continue,
};
let budget = per_section_budget(*section);
let section_header = format!("## {}\n", section.as_str());
if out.len() + section_header.len() > max_bytes {
break;
}
out.push_str(§ion_header);
let mut used = 0usize;
let mut rendered = 0usize;
for item in items {
let line = render_essence_item(*section, item);
if used + line.len() > budget {
break;
}
if out.len() + line.len() > max_bytes {
break;
}
out.push_str(&line);
used += line.len();
rendered += 1;
}
if *section == EssenceSection::Timeline && rendered < items.len() {
let fold = format!("- +{} earlier milestones\n", items.len() - rendered);
if out.len() + fold.len() <= max_bytes {
out.push_str(&fold);
}
}
if out.len() < max_bytes {
out.push('\n');
}
}
let pointer = match &scope {
EssenceScope::Thread(t) => format!(
"> full history: `mnemo thread export <file> --thread {t}` or `recall --thread {t}`\n"
),
EssenceScope::Project => String::from(
"> full history: `mnemo thread export <file> --thread <id>` or `recall --thread <id>`\n",
),
};
if out.len() + pointer.len() <= max_bytes {
out.push_str(&pointer);
}
Ok(out)
}
fn build_essence_record(
&self,
scope: EssenceScope,
section: EssenceSection,
text: &str,
refs: Option<Vec<String>>,
status_at: Option<i64>,
) -> Result<Memory> {
if let Some(e) = self.embedder.as_ref() {
let mut vs = e.embed(&[text])?;
let v = vs.pop().ok_or_else(|| {
MnemoError::Invalid("essence: embedder returned no vector".into())
})?;
let mut m = Memory::new(text.to_string(), MemoryType::Semantic, v).with_importance(0.5);
m.metadata.insert(
MNEMO_META_KIND.into(),
Value::String(MNEMO_META_KIND_ESSENCE.into()),
);
m.metadata.insert(
MNEMO_META_SECTION.into(),
Value::String(section.as_str().into()),
);
if let EssenceScope::Thread(t) = &scope {
m.metadata
.insert(MNEMO_META_THREAD.into(), Value::String(t.clone()));
}
if let Some(r) = refs {
m.metadata.insert(
MNEMO_META_REFS.into(),
Value::Array(r.into_iter().map(Value::String).collect()),
);
}
if let Some(at) = status_at {
m.metadata.insert(
MNEMO_META_STATUS.into(),
Value::String(essence_status::OPEN.into()),
);
m.metadata.insert(
MNEMO_META_STATUS_HISTORY.into(),
Value::Array(vec![serde_json::json!({
"status": essence_status::OPEN,
"at": at,
})]),
);
}
Ok(m)
} else {
let dims = self.dimensions.max(1);
let scale = 1.0 / (dims as f32).sqrt();
let v = vec![scale; dims];
let mut m = Memory::new(text.to_string(), MemoryType::Semantic, v).with_importance(0.5);
m.metadata.insert(
MNEMO_META_KIND.into(),
Value::String(MNEMO_META_KIND_ESSENCE.into()),
);
m.metadata.insert(
MNEMO_META_SECTION.into(),
Value::String(section.as_str().into()),
);
if let EssenceScope::Thread(t) = &scope {
m.metadata
.insert(MNEMO_META_THREAD.into(), Value::String(t.clone()));
}
if let Some(r) = refs {
m.metadata.insert(
MNEMO_META_REFS.into(),
Value::Array(r.into_iter().map(Value::String).collect()),
);
}
if let Some(at) = status_at {
m.metadata.insert(
MNEMO_META_STATUS.into(),
Value::String(essence_status::OPEN.into()),
);
m.metadata.insert(
MNEMO_META_STATUS_HISTORY.into(),
Value::Array(vec![serde_json::json!({
"status": essence_status::OPEN,
"at": at,
})]),
);
}
Ok(m)
}
}
fn load_essence_record(&mut self, id: &Ulid) -> Result<Memory> {
let m = self.get(id)?;
if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str()) != Some(MNEMO_META_KIND_ESSENCE)
{
return Err(MnemoError::Invalid(format!(
"essence: record {id} is not an essence record (missing `_mnemo.kind = essence`)"
)));
}
Ok(m)
}
fn section_of(&self, m: &Memory) -> Result<EssenceSection> {
let s = m
.metadata
.get(MNEMO_META_SECTION)
.and_then(|v| v.as_str())
.ok_or_else(|| {
MnemoError::Invalid("essence: record missing `_mnemo.section`".into())
})?;
EssenceSection::parse(s)
.ok_or_else(|| MnemoError::Invalid(format!("essence: unknown section '{s}' on record")))
}
fn essence_transition_status(&mut self, id: &Ulid, to: &str) -> Result<WriteOutcome> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let existing = self.load_essence_record(id)?;
let section = self.section_of(&existing)?;
if !section.has_status_lifecycle() {
return Err(MnemoError::Invalid(format!(
"essence_transition: '{}' section has no status lifecycle",
section.as_str()
)));
}
let from = existing
.metadata
.get(MNEMO_META_STATUS)
.and_then(|v| v.as_str())
.unwrap_or(essence_status::OPEN);
if from == to {
return Ok(WriteOutcome::Unchanged);
}
let allowed = ESSENCE_TRANSITIONS
.iter()
.any(|(f, t)| *f == from && *t == to);
if !allowed {
return Err(MnemoError::Invalid(format!(
"essence_transition: '{from}' → '{to}' is not a valid transition for essence"
)));
}
let mut new = existing.clone();
new.metadata
.insert(MNEMO_META_STATUS.into(), Value::String(to.to_string()));
let now = memory::now_secs();
let history_entry = serde_json::json!({ "status": to, "at": now });
let history = new
.metadata
.entry(MNEMO_META_STATUS_HISTORY.to_string())
.or_insert_with(|| Value::Array(Vec::new()));
if let Value::Array(arr) = history {
arr.push(history_entry);
}
new.metadata
.insert(MNEMO_META_UPDATED_AT.into(), Value::Number(now.into()));
new.id = existing.id;
new.created_at = existing.created_at;
self.put(new)?;
Ok(WriteOutcome::Updated)
}
pub fn thread_start(&mut self, new_thread_id: &str, from_thread_id: &str) -> Result<Ulid> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
if new_thread_id.is_empty() || from_thread_id.is_empty() {
return Err(MnemoError::Invalid(
"thread_start: 'new_thread_id' and 'from_thread_id' must not be empty".into(),
));
}
if new_thread_id == from_thread_id {
return Err(MnemoError::Invalid(
"thread_start: 'new_thread_id' must differ from 'from_thread_id'".into(),
));
}
let dims = self.dimensions.max(1);
let scale = 1.0 / (dims as f32).sqrt();
let v = vec![scale; dims];
let content = format!("thread link: {new_thread_id} continues from {from_thread_id}");
let mut m = Memory::new(content, MemoryType::Semantic, v).with_importance(0.1);
m.metadata.insert(
MNEMO_META_KIND.into(),
Value::String(MNEMO_META_KIND_THREAD_LINK.into()),
);
m.metadata.insert(
MNEMO_META_THREAD.into(),
Value::String(new_thread_id.to_string()),
);
m.metadata.insert(
MNEMO_META_CONTINUES.into(),
Value::String(from_thread_id.to_string()),
);
let id = self.put(m)?;
Ok(id)
}
fn render_continuation_prelude(
&mut self,
prev_thread: &str,
max_bytes: usize,
) -> Result<String> {
if max_bytes == 0 {
return Ok(String::new());
}
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let mut open_items: Vec<Memory> = Vec::new();
let mut next_items: Vec<Memory> = Vec::new();
let mut timeline_items: Vec<Memory> = Vec::new();
for e in &entries {
let m = self.read_memory(e)?;
if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
!= Some(MNEMO_META_KIND_ESSENCE)
{
continue;
}
if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(prev_thread) {
continue;
}
let section = match m
.metadata
.get(MNEMO_META_SECTION)
.and_then(|v| v.as_str())
.and_then(EssenceSection::parse)
{
Some(s) => s,
None => continue,
};
let status = m
.metadata
.get(MNEMO_META_STATUS)
.and_then(|v| v.as_str())
.unwrap_or(essence_status::OPEN);
match section {
EssenceSection::Open if status == essence_status::OPEN => {
open_items.push(m);
}
EssenceSection::Next if status == essence_status::OPEN => {
next_items.push(m);
}
EssenceSection::Timeline => timeline_items.push(m),
_ => {}
}
}
timeline_items.sort_by_key(|m| std::cmp::Reverse(m.created_at));
timeline_items.truncate(CONTINUATION_TIMELINE_ENTRIES);
timeline_items.reverse();
let mut out = String::new();
let header = format!("## continued from `{prev_thread}`\n");
if header.len() > max_bytes {
return Ok(String::new());
}
out.push_str(&header);
if !open_items.is_empty() {
let sub = "### open\n";
if out.len() + sub.len() <= max_bytes {
out.push_str(sub);
}
for m in &open_items {
let line = format!("- {}\n", m.content);
if out.len() + line.len() > max_bytes {
break;
}
out.push_str(&line);
}
}
if !next_items.is_empty() {
let sub = "### next\n";
if out.len() + sub.len() <= max_bytes {
out.push_str(sub);
}
for m in &next_items {
let line = format!("- {}\n", m.content);
if out.len() + line.len() > max_bytes {
break;
}
out.push_str(&line);
}
}
if !timeline_items.is_empty() {
let sub = "### recent milestones\n";
if out.len() + sub.len() <= max_bytes {
out.push_str(sub);
}
for m in &timeline_items {
let line = format!("- {}\n", m.content);
if out.len() + line.len() > max_bytes {
break;
}
out.push_str(&line);
}
}
if out.len() < max_bytes {
out.push('\n');
}
Ok(out)
}
fn predecessor_thread(&mut self, thread_id: &str) -> Result<Option<String>> {
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
for e in &entries {
let m = self.read_memory(e)?;
if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
!= Some(MNEMO_META_KIND_THREAD_LINK)
{
continue;
}
let this = m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str());
if this != Some(thread_id) {
continue;
}
return Ok(m
.metadata
.get(MNEMO_META_CONTINUES)
.and_then(|v| v.as_str())
.map(String::from));
}
Ok(None)
}
pub fn commit_deltas(&mut self, mut batch: CommitBatch) -> Result<CommitReport> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
for (i, f) in batch.facts.iter().enumerate() {
if f.memory.vector.len() != self.dimensions {
return Err(MnemoError::Invalid(format!(
"commit_deltas: facts[{i}] vector has {} dims; this file is {}",
f.memory.vector.len(),
self.dimensions
)));
}
if let Some(k) = &f.key {
if k.is_empty() {
return Err(MnemoError::Invalid(format!(
"commit_deltas: facts[{i}] key is empty"
)));
}
}
}
for (i, op) in batch.essence_ops.iter().enumerate() {
match op.kind {
EssenceOpKind::Add => {
if op.scope.is_none() {
return Err(MnemoError::Invalid(format!(
"commit_deltas: essence_ops[{i}] Add missing 'scope'"
)));
}
let section = op.section.ok_or_else(|| {
MnemoError::Invalid(format!(
"commit_deltas: essence_ops[{i}] Add missing 'section'"
))
})?;
if section == EssenceSection::Timeline {
return Err(MnemoError::Invalid(format!(
"commit_deltas: essence_ops[{i}] Add on Timeline; use AppendTimeline"
)));
}
let text = op.text.as_deref().ok_or_else(|| {
MnemoError::Invalid(format!(
"commit_deltas: essence_ops[{i}] Add missing 'text'"
))
})?;
if text.is_empty() || text.len() > section.item_max_bytes() {
return Err(MnemoError::Invalid(format!(
"commit_deltas: essence_ops[{i}] Add text length invalid (0 or > {})",
section.item_max_bytes()
)));
}
}
EssenceOpKind::AppendTimeline => {
if op.scope.is_none() {
return Err(MnemoError::Invalid(format!(
"commit_deltas: essence_ops[{i}] AppendTimeline missing 'scope'"
)));
}
let text = op.text.as_deref().ok_or_else(|| {
MnemoError::Invalid(format!(
"commit_deltas: essence_ops[{i}] AppendTimeline missing 'text'"
))
})?;
if text.is_empty() || text.len() > EssenceSection::Timeline.item_max_bytes() {
return Err(MnemoError::Invalid(format!(
"commit_deltas: essence_ops[{i}] AppendTimeline text length invalid (0 or > {})",
EssenceSection::Timeline.item_max_bytes()
)));
}
}
EssenceOpKind::Update => {
if op.id.is_none() || op.text.is_none() {
return Err(MnemoError::Invalid(format!(
"commit_deltas: essence_ops[{i}] Update needs 'id' and 'text'"
)));
}
}
EssenceOpKind::Resolve | EssenceOpKind::Remove => {
if op.id.is_none() {
return Err(MnemoError::Invalid(format!(
"commit_deltas: essence_ops[{i}] needs 'id'"
)));
}
}
}
}
if !batch.turns.is_empty() && batch.thread_id.is_none() {
return Err(MnemoError::Invalid(
"commit_deltas: 'turns' non-empty but 'thread_id' missing".into(),
));
}
let mut report = CommitReport::default();
for f in batch.facts.drain(..) {
match f.key {
Some(k) => {
let (_id, outcome) = self.remember_keyed(f.memory, &k)?;
match outcome {
WriteOutcome::Added => report.facts_added += 1,
WriteOutcome::Superseded => report.facts_superseded += 1,
WriteOutcome::Unchanged => report.facts_unchanged += 1,
WriteOutcome::Updated => report.facts_superseded += 1,
}
}
None => {
self.remember(f.memory)?;
report.facts_added += 1;
}
}
}
for op in batch.essence_ops.drain(..) {
match op.kind {
EssenceOpKind::Add => {
self.essence_add(
op.scope.expect("validated"),
op.section.expect("validated"),
op.text.as_deref().expect("validated"),
op.refs,
)?;
report.essence_ops_applied += 1;
}
EssenceOpKind::AppendTimeline => {
self.essence_append_timeline(
op.scope.expect("validated"),
op.text.as_deref().expect("validated"),
op.refs,
)?;
report.essence_ops_applied += 1;
}
EssenceOpKind::Update => {
let outcome = self.essence_update(
op.id.as_ref().expect("validated"),
op.text.as_deref().expect("validated"),
op.refs,
)?;
match outcome {
WriteOutcome::Updated | WriteOutcome::Added | WriteOutcome::Superseded => {
report.essence_ops_applied += 1
}
WriteOutcome::Unchanged => report.essence_ops_unchanged += 1,
}
}
EssenceOpKind::Resolve => {
let outcome = self.essence_resolve(op.id.as_ref().expect("validated"))?;
match outcome {
WriteOutcome::Updated | WriteOutcome::Added | WriteOutcome::Superseded => {
report.essence_ops_applied += 1
}
WriteOutcome::Unchanged => report.essence_ops_unchanged += 1,
}
}
EssenceOpKind::Remove => {
self.essence_remove(op.id.as_ref().expect("validated"))?;
report.essence_ops_applied += 1;
}
}
}
match batch.turn_policy {
TurnPolicy::Archive => {
if !batch.turns.is_empty() {
let thread_id = batch.thread_id.as_deref().expect("validated above");
let r = self.ingest_thread(thread_id, &batch.turns)?;
report.turns_added += r.added_records;
report.turns_skipped += r.skipped_existing_turns;
report.turns_truncated += r.truncated_turns;
report.turns_chunked += r.chunked_turns;
}
}
TurnPolicy::ExpireAfter(ttl) => {
if !batch.turns.is_empty() {
let thread_id = batch.thread_id.as_deref().expect("validated above");
let r = self.ingest_thread(thread_id, &batch.turns)?;
report.turns_added += r.added_records;
report.turns_skipped += r.skipped_existing_turns;
report.turns_truncated += r.truncated_turns;
report.turns_chunked += r.chunked_turns;
let want_ids: std::collections::HashSet<String> =
batch.turns.iter().map(|t| t.turn_id.clone()).collect();
let records = self.thread_export(thread_id)?;
for rec in records {
if let Some(tid) = rec
.metadata
.get(MNEMO_META_TURN_ID)
.and_then(|v| v.as_str())
{
if want_ids.contains(tid) && rec.ttl_secs.is_none() {
let _ = self.update(
&rec.id,
MemoryPatch {
ttl_secs: Some(Some(ttl)),
..Default::default()
},
)?;
}
}
}
}
}
TurnPolicy::Drop => {
}
}
self.flush()?;
Ok(report)
}
pub fn memories(&mut self) -> Result<Vec<Memory>> {
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let mut out = Vec::with_capacity(entries.len());
for e in &entries {
out.push(self.read_memory(e)?);
}
Ok(out)
}
pub fn about(&mut self) -> Result<Vec<Memory>> {
let mut out: Vec<Memory> = self
.memories()?
.into_iter()
.filter(|m| !m.metadata.contains_key(MNEMO_META_SUPERSEDED_BY))
.filter(|m| {
m.metadata
.get("area")
.and_then(|v| v.as_str())
.map(|s| s.eq_ignore_ascii_case("onboarding"))
.unwrap_or(false)
})
.collect();
out.sort_by(|a, b| {
let a_manifest = is_manifest(a);
let b_manifest = is_manifest(b);
b_manifest
.cmp(&a_manifest)
.then_with(|| b.importance.total_cmp(&a.importance))
.then_with(|| a.created_at.cmp(&b.created_at))
});
Ok(out)
}
pub fn search(
&mut self,
query: &[f32],
top_k: usize,
metric: Metric,
) -> Result<Vec<(Memory, f32)>> {
if query.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: query.len(),
});
}
let now = memory::now_secs();
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let mut scored: Vec<(Memory, f32)> = Vec::new();
for e in &entries {
let m = self.read_memory(e)?;
if m.is_expired(now) {
continue;
}
let sim = memory::similarity(metric, query, &m.vector);
scored.push((m, sim));
}
scored.sort_by(|a, b| b.1.total_cmp(&a.1));
scored.truncate(top_k);
Ok(scored)
}
fn score_once(
&mut self,
req: &RecallRequest,
now: i64,
force_exact: bool,
) -> Result<Vec<RecallResult>> {
let entries: Vec<CatalogEntry> = match (&self.ann, force_exact) {
(Some(ann), false) => {
let ids = ann.query(&req.query, req.n_probe, req.n_rerank);
ids.iter()
.filter_map(|id| self.index.get(id).copied())
.map(|i| self.catalog[i].clone())
.filter(|e| !e.deleted)
.collect()
}
_ => self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect(),
};
let mut scored: Vec<RecallResult> = Vec::with_capacity(entries.len());
for e in &entries {
if self.superseded.as_ref().is_some_and(|s| s.contains(&e.id)) {
continue;
}
let m = self.read_memory(e)?;
if m.metadata.contains_key(MNEMO_META_SUPERSEDED_BY) {
continue;
}
if m.is_expired(now) {
continue;
}
if let Some(types) = &req.memory_types {
if !types.contains(&m.memory_type) {
continue;
}
}
if let Some(agent) = &req.agent_id {
let visible = m.agent_id == *agent || m.scope == Scope::Shared;
if !visible {
continue;
}
}
if !req.filter.is_empty() && !req.filter.matches(&m) {
continue;
}
if !req.filter.mentions_mnemo_meta()
&& matches!(
m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str()),
Some(MNEMO_META_KIND_TURN)
| Some(MNEMO_META_KIND_ESSENCE)
| Some(MNEMO_META_KIND_THREAD_LINK)
)
{
continue;
}
let sim = memory::similarity(req.metric, &req.query, &m.vector);
let age_secs = (now - m.created_at) as f32;
let score = req
.weights
.score(sim, age_secs, m.importance, m.access_count);
scored.push(RecallResult {
memory: m,
score,
similarity: sim,
});
}
Ok(scored)
}
pub fn recall(&mut self, req: &RecallRequest) -> Result<Vec<RecallResult>> {
Ok(self.recall_budgeted(req)?.hits)
}
pub fn recall_budgeted(&mut self, req: &RecallRequest) -> Result<RecallResponse> {
if req.query.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: req.query.len(),
});
}
if self.read_only && req.track_access {
return Err(MnemoError::ReadOnly);
}
let live_count = self.catalog.iter().filter(|e| !e.deleted).count();
let live_non_superseded = live_count;
let now = memory::now_secs();
let mut scored = self.score_once(req, now, false)?;
let mut used_exact_scan = self.ann.is_none();
if scored.len() < req.top_k && live_non_superseded > req.top_k {
if let Some(ann) = &self.ann {
let (default_probe, default_rerank) = ann.effective_defaults();
let base_probe = req.n_probe.unwrap_or(default_probe);
let base_rerank = req.n_rerank.unwrap_or(default_rerank);
let widen_probe = base_probe.saturating_mul(2);
let widen_rerank = base_rerank.saturating_mul(2);
if widen_probe > base_probe || widen_rerank > base_rerank {
let widened = RecallRequest {
n_probe: Some(widen_probe),
n_rerank: Some(widen_rerank),
..req.clone()
};
scored = self.score_once(&widened, now, false)?;
}
if scored.len() < req.top_k {
scored = self.score_once(req, now, true)?;
used_exact_scan = true;
}
}
}
scored.sort_by(|a, b| b.score.total_cmp(&a.score));
scored.truncate(req.top_k);
if req.track_access {
for r in &mut scored {
if let Some(&idx) = self.index.get(&r.memory.id.0) {
let entry = &mut self.catalog[idx];
entry.accessed_at = now;
entry.access_count = entry.access_count.saturating_add(1);
r.memory.accessed_at = entry.accessed_at;
r.memory.access_count = entry.access_count;
}
}
if !scored.is_empty() {
self.dirty_catalog = true;
}
}
let truncated = if let Some(budget) = req.max_bytes {
let orig_len = scored.len();
let mut running = 0usize;
let mut cut_at: Option<usize> = None;
for (i, r) in scored.iter().enumerate() {
let w = r.memory.content.len();
if i == 0 {
running += w;
continue;
}
if running.saturating_add(w) > budget {
cut_at = Some(i);
break;
}
running += w;
}
if let Some(idx) = cut_at {
scored.truncate(idx);
scored.len() < orig_len
} else {
false
}
} else {
false
};
Ok(RecallResponse {
hits: scored,
truncated,
used_exact_scan,
})
}
pub fn build_index(&mut self) -> Result<IndexInfo> {
self.build_index_with(IndexConfig::default())
}
pub fn build_index_with(&mut self, cfg: IndexConfig) -> Result<IndexInfo> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let mems = self.memories()?;
if mems.is_empty() {
return Err(MnemoError::Invalid(
"cannot build an index over an empty database".into(),
));
}
let items: Vec<(u128, &[f32])> =
mems.iter().map(|m| (m.id.0, m.vector.as_slice())).collect();
let idx = IvfPqIndex::build(self.dimensions, &items, cfg)?;
let info = idx.info();
self.ann = Some(idx);
self.dirty_index = true;
Ok(info)
}
pub fn rebuild_index(&mut self) -> Result<IndexInfo> {
self.build_index_with(IndexConfig::default())
}
pub fn drop_index(&mut self) {
if self.read_only {
return;
}
if self.ann.is_some() {
self.ann = None;
self.dirty_index = true;
}
}
pub fn has_index(&self) -> bool {
self.ann.is_some()
}
fn seal_run(&mut self, bytes: &[u8], frames: &mut Vec<wal::Frame>) -> Result<(u64, u32)> {
let pc = bytes.len().div_ceil(PAYLOAD).max(1);
let start = self.header.next_page;
self.header.next_page += pc as u64;
for i in 0..pc {
let lo = i * PAYLOAD;
let hi = ((i + 1) * PAYLOAD).min(bytes.len());
let page_no = start + i as u64;
let sealed = self.pager.seal_page(page_no, &bytes[lo..hi])?;
frames.push((page_no, sealed.to_vec()));
}
Ok((start, pc as u32))
}
fn ensure_wal_capacity(&mut self, frame_pages: usize) -> Result<()> {
let need = wal::txn_byte_len(frame_pages, PAGE_SIZE);
if need <= self.header.wal_pages * PAGE_SIZE as u64 {
return Ok(());
}
let req = need.div_ceil(PAGE_SIZE as u64);
let new_pages = (req + req / 2 + 4).max(DEFAULT_WAL_PAGES);
let old_wal_start = self.header.wal_start;
let new_start = self.header.next_page;
self.header.next_page += new_pages;
self.header.wal_start = new_start;
self.header.wal_pages = new_pages;
self.header.wal_seq += 1;
self.relocate_header_through_wal(old_wal_start)
}
fn relocate_header_through_wal(&mut self, old_wal_start: u64) -> Result<()> {
self.header.write_counter = self.pager.write_counter;
let mut page = self.header.to_page();
self.header.apply_seal(&mut page, self.pager.dek())?;
let txn_id = self.header.wal_seq;
let frames = vec![(0u64, page.to_vec())];
wal::commit(
self.pager.file_mut(),
self.header.wal_start,
self.header.wal_pages,
txn_id,
&frames,
)?;
self.pager.write_raw(0, &page)?;
self.pager.sync()?;
if old_wal_start != self.header.wal_start {
wal::invalidate_region(self.pager.file_mut(), old_wal_start)?;
}
Ok(())
}
fn checkpoint(&mut self, frames: &[wal::Frame]) -> Result<()> {
for (page_no, bytes) in frames {
let mut img = [0u8; PAGE_SIZE];
img.copy_from_slice(bytes);
self.pager.write_sealed(*page_no, &img)?;
}
self.pager.sync()?;
Ok(())
}
pub fn flush(&mut self) -> Result<()> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let fsync_before = crate::fsync_count();
if !self.dirty_catalog && !self.dirty_index && !self.dirty_cache {
self.fsyncs_last_flush = 0;
return Ok(());
}
let is_memory_or_index_flush = self.dirty_catalog || self.dirty_index;
let ctx = self.prepare_for_flush()?;
self.pager.flush()?;
let mut frames: Vec<wal::Frame> = Vec::new();
if let Some(bytes) = &ctx.cat_bytes {
let (start, pages) = self.seal_run(bytes, &mut frames)?;
self.header.catalog_start = start;
self.header.catalog_pages = pages as u64;
self.header.catalog_len = bytes.len() as u64;
}
if self.dirty_index {
match &ctx.idx_bytes {
Some(bytes) => {
let (start, pages) = self.seal_run(bytes, &mut frames)?;
self.header.index_start = start;
self.header.index_pages = pages as u64;
self.header.index_len = bytes.len() as u64;
}
None => {
self.header.index_start = 0;
self.header.index_pages = 0;
self.header.index_len = 0;
}
}
}
if self.dirty_cache {
match &ctx.cache_bytes {
Some(bytes) if !self.cache_dir.is_empty() => {
let (start, pages) = self.seal_run(bytes, &mut frames)?;
self.header.cache_start = start;
self.header.cache_pages = pages as u64;
self.header.cache_len = bytes.len() as u64;
}
_ => {
self.header.cache_start = 0;
self.header.cache_pages = 0;
self.header.cache_len = 0;
}
}
}
let live = self.len() as u64;
let txn_id = self.header.wal_seq + 1;
let mut manifest = self.manifest.clone();
if is_memory_or_index_flush {
if self.max_snapshots > 0 {
while manifest.len() >= self.max_snapshots {
manifest.remove(0);
}
}
manifest.push(Snapshot {
txn_id,
created_at: memory::now_secs(),
catalog_start: self.header.catalog_start,
catalog_pages: self.header.catalog_pages,
catalog_len: self.header.catalog_len,
index_start: self.header.index_start,
index_pages: self.header.index_pages,
index_len: self.header.index_len,
memory_count: live,
});
let man_bytes =
rmp_serde::to_vec(&manifest).map_err(|e| MnemoError::Serialize(e.to_string()))?;
let (m_start, m_pages) = self.seal_run(&man_bytes, &mut frames)?;
self.header.manifest_start = m_start;
self.header.manifest_pages = m_pages as u64;
self.header.manifest_len = man_bytes.len() as u64;
}
self.header.vector_count = live;
self.header.write_counter = self.pager.write_counter;
self.header.wal_seq = txn_id;
let mut hpage = self.header.to_page();
self.header.apply_seal(&mut hpage, self.pager.dek())?;
frames.push((0, hpage.to_vec()));
let (wal_start, wal_pages) = (self.header.wal_start, self.header.wal_pages);
wal::commit(self.pager.file_mut(), wal_start, wal_pages, txn_id, &frames)?;
self.checkpoint(&frames)?;
self.manifest = manifest;
self.dirty_catalog = false;
self.dirty_index = false;
self.dirty_cache = false;
self.cache_batch.reset();
self.fsyncs_last_flush =
(crate::fsync_count().wrapping_sub(fsync_before)).min(u32::MAX as u64) as u32;
Ok(())
}
fn prepare_for_flush(&mut self) -> Result<FlushPrelude> {
let cat_bytes = if self.dirty_catalog {
Some(
rmp_serde::to_vec(&self.catalog)
.map_err(|e| MnemoError::Serialize(e.to_string()))?,
)
} else {
None
};
let idx_bytes: Option<Vec<u8>> = if self.dirty_index {
match &self.ann {
Some(ann) => {
Some(rmp_serde::to_vec(ann).map_err(|e| MnemoError::Serialize(e.to_string()))?)
}
None => None,
}
} else {
None
};
let cache_bytes: Option<Vec<u8>> = if self.dirty_cache {
Some(
rmp_serde::to_vec(&self.cache_dir)
.map_err(|e| MnemoError::Serialize(e.to_string()))?,
)
} else {
None
};
let cat_pc = cat_bytes
.as_ref()
.map_or(0, |b| b.len().div_ceil(PAYLOAD).max(1));
let idx_pc = idx_bytes
.as_ref()
.map_or(0, |b| b.len().div_ceil(PAYLOAD).max(1));
let cache_pc = cache_bytes
.as_ref()
.map_or(0, |b| b.len().div_ceil(PAYLOAD).max(1));
let man_upper = 9 + (self.manifest.len() + 1) * 82;
let man_pc_est = man_upper.div_ceil(PAYLOAD).max(1);
self.ensure_wal_capacity(cat_pc + idx_pc + cache_pc + man_pc_est + 1)?;
Ok(FlushPrelude {
cat_bytes,
idx_bytes,
cache_bytes,
})
}
pub fn close(&mut self) -> Result<()> {
if self.read_only {
return Ok(());
}
self.flush()
}
#[doc(hidden)]
pub fn __crash_partial_flush_for_testing(&mut self) -> Result<()> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
if !self.dirty_catalog && !self.dirty_index {
return Ok(());
}
let _prelude = self.prepare_for_flush()?;
self.pager.flush()
}
pub fn set_cache_capacity(&mut self, pages: usize) {
self.pager.set_cache_capacity(pages);
}
pub fn page_cache_stats(&self) -> (usize, usize) {
self.pager.cache_stats()
}
pub fn cache_put(
&mut self,
namespace: &str,
key: &str,
value: &[u8],
opts: CachePutOpts,
) -> Result<()> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let key_hash = result_cache::hash_key(key);
let key_preview = result_cache::key_preview(key);
let now = memory::now_secs();
let size_bytes = value.len() as u64;
let entry = CacheEntry {
namespace: namespace.to_string(),
key_hash,
key_preview: key_preview.clone(),
value: value.to_vec(),
content_type: opts.content_type.clone(),
created_at: now,
accessed_at: now,
access_count: 0,
ttl_secs: opts.ttl_secs,
cost_hint_ms: opts.cost_hint_ms,
size_bytes,
vector: None,
model: None,
};
let bytes = rmp_serde::to_vec(&entry).map_err(|e| MnemoError::Serialize(e.to_string()))?;
let (start_page, page_count) = self.write_record(&bytes)?;
if let Some(prev_pos) = self.cache_idx.get(namespace, &key_hash) {
self.cache_dir[prev_pos].deleted = true;
self.cache_idx.remove(namespace, &key_hash);
}
let dir_entry = CacheDirectoryEntry {
namespace: namespace.to_string(),
key_hash,
start_page,
page_count,
len: bytes.len() as u32,
deleted: false,
accessed_at: now,
access_count: 0,
size_bytes,
ttl_secs: opts.ttl_secs,
created_at: now,
vector: None,
model: None,
};
let pos = self.cache_dir.len();
self.cache_dir.push(dir_entry);
self.cache_idx.insert(namespace, key_hash, pos);
self.evict_to_budget(namespace)?;
self.dirty_cache = true;
self.cache_batch.record_dirty();
if self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
self.flush()?;
}
Ok(())
}
pub fn cache_get(&mut self, namespace: &str, key: &str) -> Result<Option<CachedValue>> {
if !self.read_only && self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
self.flush()?;
}
let key_hash = result_cache::hash_key(key);
let pos = match self.cache_idx.get(namespace, &key_hash) {
Some(p) => p,
None => {
self.cache_counters.misses += 1;
return Ok(None);
}
};
let now = memory::now_secs();
let (start_page, page_count, len, ttl_secs, expired, created_at) = {
let e = &self.cache_dir[pos];
(
e.start_page,
e.page_count,
e.len,
e.ttl_secs,
e.is_expired(now),
e.created_at,
)
};
if expired {
self.cache_counters.misses += 1;
return Ok(None);
}
let mut buf = Vec::with_capacity(len as usize);
for i in 0..page_count as u64 {
buf.extend_from_slice(&self.pager.read_page(start_page + i)?);
}
buf.truncate(len as usize);
let entry: CacheEntry =
rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
if !self.read_only {
self.cache_dir[pos].accessed_at = now;
self.cache_dir[pos].access_count = self.cache_dir[pos].access_count.saturating_add(1);
self.dirty_cache = true;
}
self.cache_counters.hits += 1;
let access_count = self.cache_dir[pos].access_count;
let accessed_at = if self.read_only {
self.cache_dir[pos].accessed_at
} else {
now
};
Ok(Some(CachedValue {
value: entry.value,
content_type: entry.content_type,
created_at,
accessed_at,
access_count,
ttl_secs,
}))
}
pub fn cache_delete(&mut self, namespace: &str, key: &str) -> Result<()> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let key_hash = result_cache::hash_key(key);
if let Some(pos) = self.cache_idx.get(namespace, &key_hash) {
self.cache_dir[pos].deleted = true;
self.cache_idx.remove(namespace, &key_hash);
self.dirty_cache = true;
self.cache_batch.record_dirty();
if self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
self.flush()?;
}
}
Ok(())
}
pub fn cache_purge(&mut self, namespace: Option<&str>, expired_only: bool) -> Result<usize> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let now = memory::now_secs();
let mut purged = 0usize;
for pos in 0..self.cache_dir.len() {
if self.cache_dir[pos].deleted {
continue;
}
if let Some(ns) = namespace {
if self.cache_dir[pos].namespace != ns {
continue;
}
}
if expired_only && !self.cache_dir[pos].is_expired(now) {
continue;
}
let key_hash = self.cache_dir[pos].key_hash;
let ns = self.cache_dir[pos].namespace.clone();
self.cache_dir[pos].deleted = true;
self.cache_idx.remove(&ns, &key_hash);
purged += 1;
}
if purged > 0 {
self.dirty_cache = true;
self.cache_batch.record_dirty();
if self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
self.flush()?;
}
}
Ok(purged)
}
pub fn cache_stats(&self, namespace: Option<&str>) -> CacheStats {
let mut entries = 0usize;
let mut bytes = 0u64;
for e in &self.cache_dir {
if e.deleted {
continue;
}
if let Some(ns) = namespace {
if e.namespace != ns {
continue;
}
}
entries += 1;
bytes += e.size_bytes;
}
let hits = self.cache_counters.hits;
let misses = self.cache_counters.misses;
let total = hits + misses;
let hit_rate = if total > 0 {
hits as f64 / total as f64
} else {
0.0
};
CacheStats {
entries,
bytes,
hits,
misses,
hit_rate,
evictions: self.cache_counters.evictions,
}
}
pub fn set_cache_budget(&mut self, namespace: &str, budget: CacheBudget) {
self.cache_budgets.insert(namespace.to_string(), budget);
}
pub fn set_cache_flush_policy(&mut self, policy: CacheFlushPolicy) {
self.cache_flush_policy = policy;
}
fn evict_to_budget(&mut self, namespace: &str) -> Result<()> {
let budget = self
.cache_budgets
.get(namespace)
.copied()
.unwrap_or_default();
loop {
let mut live_count = 0usize;
let mut live_bytes = 0u64;
for e in &self.cache_dir {
if !e.deleted && e.namespace == namespace {
live_count += 1;
live_bytes += e.size_bytes;
}
}
if live_count <= budget.max_entries && live_bytes <= budget.max_bytes {
return Ok(());
}
let victim_pos = self
.cache_dir
.iter()
.enumerate()
.filter(|(_, e)| !e.deleted && e.namespace == namespace)
.min_by_key(|(_, e)| e.accessed_at)
.map(|(i, _)| i);
let Some(pos) = victim_pos else {
return Ok(());
};
let key_hash = self.cache_dir[pos].key_hash;
let ns = self.cache_dir[pos].namespace.clone();
self.cache_dir[pos].deleted = true;
self.cache_idx.remove(&ns, &key_hash);
self.cache_counters.evictions += 1;
}
}
pub fn cache_put_semantic(
&mut self,
namespace: &str,
key: &str,
vector: Vec<f32>,
value: &[u8],
opts: SemanticCachePutOpts,
) -> Result<()> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
if vector.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: vector.len(),
});
}
let key_hash = result_cache::hash_key(key);
let key_preview = result_cache::key_preview(key);
let now = memory::now_secs();
let size_bytes = value.len() as u64;
let entry = CacheEntry {
namespace: namespace.to_string(),
key_hash,
key_preview: key_preview.clone(),
value: value.to_vec(),
content_type: opts.content_type.clone(),
created_at: now,
accessed_at: now,
access_count: 0,
ttl_secs: opts.ttl_secs,
cost_hint_ms: opts.cost_hint_ms,
size_bytes,
vector: Some(vector.clone()),
model: Some(opts.model.clone()),
};
let bytes = rmp_serde::to_vec(&entry).map_err(|e| MnemoError::Serialize(e.to_string()))?;
let (start_page, page_count) = self.write_record(&bytes)?;
if let Some(prev_pos) = self.cache_idx.get(namespace, &key_hash) {
self.cache_dir[prev_pos].deleted = true;
self.cache_idx.remove(namespace, &key_hash);
}
let dir_entry = CacheDirectoryEntry {
namespace: namespace.to_string(),
key_hash,
start_page,
page_count,
len: bytes.len() as u32,
deleted: false,
accessed_at: now,
access_count: 0,
size_bytes,
ttl_secs: opts.ttl_secs,
created_at: now,
vector: Some(vector),
model: Some(opts.model),
};
let pos = self.cache_dir.len();
self.cache_dir.push(dir_entry);
self.cache_idx.insert(namespace, key_hash, pos);
self.evict_to_budget(namespace)?;
self.dirty_cache = true;
self.cache_batch.record_dirty();
if self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
self.flush()?;
}
Ok(())
}
pub fn cache_get_semantic(
&mut self,
namespace: &str,
query_vector: &[f32],
threshold: f32,
model: &str,
) -> Result<Option<(CachedValue, f32)>> {
if query_vector.len() != self.dimensions {
return Err(MnemoError::DimensionMismatch {
expected: self.dimensions,
got: query_vector.len(),
});
}
if !self.read_only && self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
self.flush()?;
}
let now = memory::now_secs();
let mut best: Option<(usize, f32)> = None;
for (i, e) in self.cache_dir.iter().enumerate() {
if e.deleted || e.namespace != namespace || e.is_expired(now) {
continue;
}
let Some(ref v) = e.vector else { continue };
let Some(ref m) = e.model else { continue };
if m != model {
continue;
}
let sim = memory::cosine(query_vector, v);
if best.map(|(_, s)| sim > s).unwrap_or(true) {
best = Some((i, sim));
}
}
let Some((pos, sim)) = best else {
self.cache_counters.misses += 1;
return Ok(None);
};
if sim < threshold {
self.cache_counters.misses += 1;
return Ok(None);
}
let (start_page, page_count, len, ttl_secs, created_at) = {
let e = &self.cache_dir[pos];
(e.start_page, e.page_count, e.len, e.ttl_secs, e.created_at)
};
let mut buf = Vec::with_capacity(len as usize);
for i in 0..page_count as u64 {
buf.extend_from_slice(&self.pager.read_page(start_page + i)?);
}
buf.truncate(len as usize);
let entry: CacheEntry =
rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
if !self.read_only {
self.cache_dir[pos].accessed_at = now;
self.cache_dir[pos].access_count = self.cache_dir[pos].access_count.saturating_add(1);
self.dirty_cache = true;
}
self.cache_counters.hits += 1;
let access_count = self.cache_dir[pos].access_count;
let accessed_at = if self.read_only {
self.cache_dir[pos].accessed_at
} else {
now
};
Ok(Some((
CachedValue {
value: entry.value,
content_type: entry.content_type,
created_at,
accessed_at,
access_count,
ttl_secs,
},
sim,
)))
}
pub fn session(&mut self, agent_id: impl Into<String>) -> crate::session::Session<'_> {
crate::session::Session::new(self, agent_id.into())
}
pub fn snapshots(&self) -> Vec<SnapshotInfo> {
let mut v: Vec<SnapshotInfo> = self
.manifest
.iter()
.map(|s| SnapshotInfo {
txn_id: s.txn_id,
created_at: s.created_at,
memory_count: s.memory_count,
})
.collect();
v.sort_by_key(|s| s.txn_id);
v
}
fn apply_snapshot(&mut self, snap: &Snapshot) -> Result<()> {
let catalog = load_catalog(
&mut self.pager,
snap.catalog_start,
snap.catalog_pages,
snap.catalog_len,
)?;
let ann = load_index(
&mut self.pager,
snap.index_start,
snap.index_pages,
snap.index_len,
self.dimensions,
)?;
self.index = build_id_index(&catalog);
self.catalog = catalog;
self.ann = ann;
self.dirty_catalog = true;
self.dirty_index = true;
self.flush()
}
pub fn restore_to(&mut self, txn_id: u64) -> Result<SnapshotInfo> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let snap = self
.manifest
.iter()
.find(|s| s.txn_id == txn_id)
.cloned()
.ok_or_else(|| MnemoError::NotFound(format!("snapshot for transaction {txn_id}")))?;
self.apply_snapshot(&snap)?;
Ok(SnapshotInfo {
txn_id: snap.txn_id,
created_at: snap.created_at,
memory_count: snap.memory_count,
})
}
pub fn restore_to_time(&mut self, unix_secs: i64) -> Result<SnapshotInfo> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let snap = self
.manifest
.iter()
.filter(|s| s.created_at <= unix_secs)
.max_by_key(|s| s.txn_id)
.cloned()
.ok_or_else(|| {
MnemoError::NotFound(format!("no snapshot at or before time {unix_secs}"))
})?;
self.apply_snapshot(&snap)?;
Ok(SnapshotInfo {
txn_id: snap.txn_id,
created_at: snap.created_at,
memory_count: snap.memory_count,
})
}
pub fn kdf_params(&self) -> KdfParams {
self.kdf
}
pub fn export_encrypted(&mut self, dest: impl AsRef<Path>) -> Result<()> {
let dest = dest.as_ref();
if paths_refer_to_same_file(&self.path, dest).unwrap_or(false) {
return Err(MnemoError::Invalid(format!(
"export_encrypted refuses to overwrite the open database at {}",
dest.display()
)));
}
if !self.read_only {
self.flush()?;
}
let parent = dest.parent().ok_or_else(|| {
MnemoError::Invalid(format!(
"export_encrypted: dest {} has no parent directory",
dest.display()
))
})?;
let tmp = temp_sibling(dest);
{
let src = self.pager.file_mut();
src.seek(SeekFrom::Start(0))?;
let mut out = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp)?;
let mut buf = vec![0u8; 1 << 20]; loop {
let n = src.read(&mut buf)?;
if n == 0 {
break;
}
out.write_all(&buf[..n])?;
}
out.sync_all()?;
crate::bump_fsync();
}
if let Err(e) = std::fs::rename(&tmp, dest) {
let _ = std::fs::remove_file(&tmp);
return Err(MnemoError::Io(e));
}
#[cfg(unix)]
{
if let Ok(dir) = File::open(parent) {
let _ = dir.sync_all();
crate::bump_fsync();
}
}
#[cfg(not(unix))]
{
let _ = parent;
}
Ok(())
}
pub fn rekey(&mut self, new_passphrase: &str, kdf: KdfParams) -> Result<()> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
self.flush()?;
let new_salt = crypto::random_salt();
let new_kek = crypto::derive_kek(new_passphrase.as_bytes(), &new_salt, kdf)?;
let new_nonce = crypto::random_nonce();
let wrapped = crypto::wrap_dek(&new_kek, &new_nonce, self.pager.dek())?;
if wrapped.len() != WRAPPED_DEK_LEN {
return Err(MnemoError::Crypto("unexpected wrapped DEK length".into()));
}
let mut wrapped_dek = [0u8; WRAPPED_DEK_LEN];
wrapped_dek.copy_from_slice(&wrapped);
self.header.salt = new_salt;
self.header.dek_nonce = new_nonce;
self.header.wrapped_dek = wrapped_dek;
self.header.m_cost = kdf.m_cost;
self.header.t_cost = kdf.t_cost;
self.header.p_cost = kdf.p_cost;
self.kdf = kdf;
let old_wal_start = self.header.wal_start;
let new_pages = self.header.wal_pages;
let new_start = self.header.next_page;
self.header.next_page += new_pages;
self.header.wal_start = new_start;
self.header.wal_pages = new_pages;
self.header.wal_seq += 1;
self.relocate_header_through_wal(old_wal_start)
}
pub fn verify(&mut self) -> Result<usize> {
let entries: Vec<CatalogEntry> = self
.catalog
.iter()
.filter(|e| !e.deleted)
.cloned()
.collect();
let mut n = entries.len();
for e in &entries {
let bytes = self.read_record(e)?;
let _m: Memory = rmp_serde::from_slice(&bytes)
.map_err(|err| MnemoError::Serialize(err.to_string()))?;
}
let cache_positions: Vec<(u64, u32, u32)> = self
.cache_dir
.iter()
.filter(|e| !e.deleted)
.map(|e| (e.start_page, e.page_count, e.len))
.collect();
for (start_page, page_count, len) in cache_positions {
let mut buf = Vec::with_capacity(len as usize);
for i in 0..page_count as u64 {
buf.extend_from_slice(&self.pager.read_page(start_page + i)?);
}
buf.truncate(len as usize);
let _c: CacheEntry = rmp_serde::from_slice(&buf)
.map_err(|err| MnemoError::Serialize(err.to_string()))?;
n += 1;
}
Ok(n)
}
pub fn stats(&mut self) -> Result<Stats> {
let deleted = self.catalog.iter().filter(|e| e.deleted).count();
if self.agent_set.is_none() {
let built: std::collections::BTreeSet<String> =
self.memories()?.into_iter().map(|m| m.agent_id).collect();
self.agent_set = Some(built);
}
let agents: Vec<String> = self
.agent_set
.as_ref()
.expect("agent_set just filled above")
.iter()
.cloned()
.collect();
let file_bytes = self.header.next_page * PAGE_SIZE as u64;
Ok(Stats {
memories: self.len(),
deleted,
dimensions: self.dimensions,
file_bytes,
agents,
encrypted: self.header.flags & FLAG_ENCRYPTED != 0,
created_at: self.header.created_at,
index: self.ann.as_ref().map(|a| a.info()),
wal_pages: self.header.wal_pages,
fsyncs_last_flush: self.fsyncs_last_flush,
})
}
pub fn compact_file(path: &str, passphrase: &str) -> Result<CompactReport> {
let mut old = Mnemo::open(path, passphrase)?;
let dims = old.dimensions;
let kdf = old.kdf;
let tmp = format!("{path}.compact-tmp");
let mut new = Mnemo::create(
&tmp,
passphrase,
MnemoConfig {
dimensions: dims,
kdf,
..Default::default()
},
)?;
let want_index = old.ann.is_some();
let now = memory::now_secs();
let all = old.memories()?;
let before = all.len();
let mut after = 0;
for m in all {
if m.is_expired(now) {
continue;
}
new.remember(m)?;
after += 1;
}
if want_index && after > 0 {
new.build_index_with(IndexConfig::default())?;
}
let mut cache_before = 0usize;
let mut cache_after = 0usize;
let positions: Vec<usize> = (0..old.cache_dir.len()).collect();
for pos in positions {
let keep = {
let e = &old.cache_dir[pos];
!e.deleted && !e.is_expired(now)
};
if keep {
cache_before += 1;
new.copy_cache_entry_from(&mut old, pos)?;
cache_after += 1;
}
}
new.flush()?;
drop(new);
drop(old);
std::fs::rename(&tmp, path)?;
Ok(CompactReport {
before,
after,
cache_before,
cache_after,
})
}
pub(crate) fn copy_cache_entry_from(&mut self, old: &mut Mnemo, pos: usize) -> Result<()> {
if self.read_only {
return Err(MnemoError::ReadOnly);
}
let (start_page, page_count, len, dir_accessed_at, dir_access_count) = {
let e = &old.cache_dir[pos];
(
e.start_page,
e.page_count,
e.len,
e.accessed_at,
e.access_count,
)
};
let mut buf = Vec::with_capacity(len as usize);
for i in 0..page_count as u64 {
buf.extend_from_slice(&old.pager.read_page(start_page + i)?);
}
buf.truncate(len as usize);
let mut entry: CacheEntry =
rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
entry.accessed_at = dir_accessed_at;
entry.access_count = dir_access_count;
let bytes =
rmp_serde::to_vec(&entry).map_err(|err| MnemoError::Serialize(err.to_string()))?;
let (new_start_page, new_page_count) = self.write_record(&bytes)?;
let dir_entry = CacheDirectoryEntry {
namespace: entry.namespace.clone(),
key_hash: entry.key_hash,
start_page: new_start_page,
page_count: new_page_count,
len: bytes.len() as u32,
deleted: false,
accessed_at: dir_accessed_at,
access_count: dir_access_count,
size_bytes: entry.size_bytes,
ttl_secs: entry.ttl_secs,
created_at: entry.created_at,
vector: entry.vector.clone(),
model: entry.model.clone(),
};
let new_pos = self.cache_dir.len();
self.cache_idx
.insert(&entry.namespace, entry.key_hash, new_pos);
self.cache_dir.push(dir_entry);
self.dirty_cache = true;
Ok(())
}
}