mnemo_engine/store.rs
1//! The Mnemo database — ties the storage engine and the memory model together.
2//!
3//! Layout of a populated file:
4//!
5//! ```text
6//! page 0 : Header (unencrypted)
7//! pages 1..W : write-ahead log region
8//! pages W.. : encrypted record runs + catalog runs + index +
9//! snapshot manifest (append-only)
10//! ```
11//!
12//! Durability is provided by a **write-ahead log** ([`crate::wal`]). A `flush`
13//! is one transaction: record data pages are written copy-on-write to fresh
14//! pages, then the new catalog, ANN index, and header are logged to the WAL
15//! and committed with a single fsync. A checkpoint then folds the WAL into the
16//! home pages. A crash before the commit leaves the previous state intact; a
17//! crash after it is repaired by replaying the WAL on open.
18//!
19//! Because pages are only ever appended, every past transaction's runs survive
20//! on disk. Each flush also appends an entry to a **snapshot manifest**, so
21//! [`Mnemo::restore_to`] can reinstate any past state. Stale pages — and the
22//! history along with them — are reclaimed by [`Mnemo::compact_file`].
23
24use std::collections::HashMap;
25use std::fs::{File, OpenOptions};
26use std::io::{Read, Seek, SeekFrom, Write};
27use std::path::{Path, PathBuf};
28
29use fs4::fs_std::FileExt;
30use serde::{Deserialize, Serialize};
31use serde_json::{Map, Value};
32use ulid::Ulid;
33
34use crate::crypto::{self, KdfParams};
35use crate::embed::{self, Embedder};
36use crate::error::{MnemoError, Result};
37use crate::format::{Header, FLAG_ENCRYPTED, PAGE_SIZE, PAYLOAD, VERSION, WRAPPED_DEK_LEN};
38use crate::index::{IndexConfig, IndexInfo, IvfPqIndex};
39use crate::memory::{
40 self, status as essence_status, CommitBatch, CommitReport, EssenceOpKind, EssenceScope,
41 EssenceSection, Filter, IngestReport, KeyScope, Memory, MemoryPatch, MemoryType, Metric, Scope,
42 ScoreWeights, ThreadTurn, TurnPolicy, WriteOutcome, CONTINUATION_TIMELINE_ENTRIES,
43 ESSENCE_DEFAULT_RENDER_BYTES, ESSENCE_TRANSITIONS, MNEMO_META_CONTINUES, MNEMO_META_INVALID_AT,
44 MNEMO_META_KEY, MNEMO_META_KIND, MNEMO_META_KIND_ESSENCE, MNEMO_META_KIND_THREAD_LINK,
45 MNEMO_META_KIND_TURN, MNEMO_META_LENGTH, MNEMO_META_PART, MNEMO_META_REFS, MNEMO_META_ROLE,
46 MNEMO_META_SECTION, MNEMO_META_SEQ, MNEMO_META_SHA256, MNEMO_META_STATUS,
47 MNEMO_META_STATUS_HISTORY, MNEMO_META_SUPERSEDED_BY, MNEMO_META_SUPERSEDES, MNEMO_META_THREAD,
48 MNEMO_META_TURN_ID, MNEMO_META_UPDATED_AT, THREAD_TOOL_RESULT_TRUNCATE_BYTES,
49 THREAD_TURN_CHUNK_BYTES,
50};
51use crate::pager::Pager;
52use crate::result_cache::{
53 self, BatchState, CacheBudget, CacheDirectoryEntry, CacheEntry, CacheFlushPolicy, CachePutOpts,
54 CacheStats, CachedValue, CounterState, DirectoryIndex, SemanticCachePutOpts,
55};
56use crate::wal;
57
58/// Default initial size of the write-ahead log region, in pages (64 KiB).
59/// The region grows automatically when a transaction's control plane outgrows
60/// it, so this is a *floor* on the fresh-file footprint, not a cap on
61/// transaction size. Was 64 pages (512 KiB) in v0.1.0 — lowered because that
62/// reservation dominated small-file size (~62% of a 31-memory dogfood file).
63const DEFAULT_WAL_PAGES: u64 = 8;
64
65/// Hard floor on the WAL reservation. A single header-page flush already
66/// needs one full DATA frame plus a COMMIT frame; two pages are the bare
67/// minimum that lets the first transaction commit without immediate growth.
68const MIN_WAL_PAGES: u64 = 2;
69
70/// Default cap on retained snapshot manifest entries. Each `flush` appends
71/// one entry; without a cap, long-running processes accumulate entries
72/// forever, growing per-flush manifest-serialize cost as O(total flushes)
73/// and the manifest run size on disk in lockstep. 256 keeps roughly a
74/// week of hourly snapshots while staying tiny (256 × ~82 bytes ≈ 21 KiB
75/// of manifest). Set `MnemoConfig::max_snapshots = 0` to disable the cap.
76pub const DEFAULT_MAX_SNAPSHOTS: usize = 256;
77
78/// Chunk size for the torn-header scan's streaming read of the file.
79/// We walk the file 1 MiB at a time looking for 8 KiB-aligned `MWAL`
80/// magic. There is **no** cap on how far a given candidate's frame
81/// chain may extend — a 1M-vector IVF+PQ relocation can push a single
82/// transaction well past 16 MiB — so each candidate is read frame by
83/// frame via [`wal::scan_committed_txn`] rather than slurped into a
84/// bounded buffer. (DUR-1 R-002.1 part 2.)
85const TORN_HEADER_SCAN_CHUNK: usize = 1 << 20; // 1 MiB
86
87/// Walk the file for every 8 KiB-aligned page whose first four bytes are
88/// `MWAL`, treating each as a candidate WAL-region start, and return the
89/// frames of the highest-`txn_id` committed transaction whose header
90/// frame fully validates under `passphrase`.
91///
92/// A candidate is accepted only if:
93/// * `wal::scan_committed_txn` returns a committed transaction (chained
94/// CRC verifies, single `txn_id`, `KIND_COMMIT` closes it);
95/// * that transaction contains a `(0, header_page)` frame;
96/// * the header parses with `wal_start == candidate_page_no` and
97/// `wal_seq == txn_id` (so a frame cannot forge a region it was not
98/// written into);
99/// * deriving the KEK from `passphrase + header.salt`, unwrapping the
100/// wrapped DEK, and validating the header seal all succeed.
101///
102/// Returns `Ok(None)` when the scan finds no valid candidate — the
103/// caller is expected to propagate the original parse error so
104/// non-mnemo files still report `BadMagic`.
105///
106/// DUR-1 (R-002.1 part 2): there is no fixed scan window — the whole
107/// file is scanned. Reading at page granularity (not byte granularity)
108/// is sufficient because every WAL region in the format is page-aligned
109/// and every WAL commit writes its first frame at the region's start.
110fn scan_header_recovery_candidates(
111 file: &mut File,
112 passphrase: &str,
113) -> Result<Option<Vec<wal::Frame>>> {
114 let file_len = file.seek(SeekFrom::End(0))?;
115 if file_len < PAGE_SIZE as u64 * 2 {
116 return Ok(None); // No room for even one WAL page past the header.
117 }
118
119 let mut best: Option<(u64, Vec<wal::Frame>)> = None;
120 let page_sz = PAGE_SIZE as u64;
121 let mut offset = page_sz; // Skip page 0.
122
123 // Stream through the file in 1 MiB chunks, scanning each chunk for
124 // page-aligned `MWAL` candidates. Each candidate's frame chain is
125 // then read incrementally via `wal::scan_committed_txn`, which has
126 // no window cap — a 1M-vector IVF+PQ relocation can span tens of
127 // MiB in one committed transaction.
128 let mut chunk = vec![0u8; TORN_HEADER_SCAN_CHUNK];
129 while offset < file_len {
130 let want = (TORN_HEADER_SCAN_CHUNK as u64).min(file_len - offset) as usize;
131 chunk.resize(want, 0);
132 file.seek(SeekFrom::Start(offset))?;
133 let mut filled = 0;
134 while filled < want {
135 match file.read(&mut chunk[filled..])? {
136 0 => break,
137 n => filled += n,
138 }
139 }
140 if filled == 0 {
141 break;
142 }
143 let mut inner = 0usize;
144 while inner + 4 <= filled {
145 if &chunk[inner..inner + 4] == wal::FRAME_MAGIC_BYTES {
146 let page_no = (offset + inner as u64) / page_sz;
147 if let Some((txn_id, frames)) = try_recover_candidate(file, page_no, passphrase)? {
148 let keep = match &best {
149 None => true,
150 Some((id, _)) => txn_id > *id,
151 };
152 if keep {
153 best = Some((txn_id, frames));
154 }
155 }
156 }
157 inner += PAGE_SIZE;
158 }
159 offset += filled as u64;
160 }
161
162 Ok(best.map(|(_, f)| f))
163}
164
165/// Try to recover a committed transaction from `candidate_page_no`,
166/// returning `(txn_id, frames)` only if the candidate's
167/// `(0, header_page)` frame fully validates: the frame chain commits
168/// (via [`wal::scan_committed_txn`]'s streaming reader), the header
169/// parses with `wal_start == candidate_page_no` **and**
170/// `wal_seq == txn_id`, the DEK unwraps under `passphrase`, and the
171/// AES-GCM seal validates. DUR-1 (R-002.1 item 2) requires the
172/// `wal_seq == txn_id` check and no fixed scan window — the streaming
173/// reader handles transactions of arbitrary size.
174fn try_recover_candidate(
175 file: &mut File,
176 candidate_page_no: u64,
177 passphrase: &str,
178) -> Result<Option<(u64, Vec<wal::Frame>)>> {
179 let Some((txn_id, frames)) = wal::scan_committed_txn(file, candidate_page_no)? else {
180 return Ok(None);
181 };
182
183 // Must contain a header frame at page 0.
184 let header_bytes = match frames.iter().find(|(p, _)| *p == 0) {
185 Some((_, b)) => b,
186 None => return Ok(None),
187 };
188 if header_bytes.len() != PAGE_SIZE {
189 return Ok(None);
190 }
191 let mut hbuf = [0u8; PAGE_SIZE];
192 hbuf.copy_from_slice(header_bytes);
193 let candidate = match Header::from_page(&hbuf) {
194 Ok(h) => h,
195 Err(_) => return Ok(None),
196 };
197
198 // The frame's claimed region must match where we found it, and the
199 // header frame's `wal_seq` must match the committed transaction's
200 // `txn_id` — this is what pins the frame to the region it was
201 // written into and keeps a stale checkpointed-and-copied frame
202 // from being accepted as a new region's root.
203 if candidate.wal_start != candidate_page_no || candidate.wal_seq != txn_id {
204 return Ok(None);
205 }
206 // DEK unwrap under this candidate's own salt + KDF params.
207 let kdf = KdfParams {
208 m_cost: candidate.m_cost,
209 t_cost: candidate.t_cost,
210 p_cost: candidate.p_cost,
211 };
212 let kek = match crypto::derive_kek(passphrase.as_bytes(), &candidate.salt, kdf) {
213 Ok(k) => k,
214 Err(_) => return Ok(None),
215 };
216 let dek = match crypto::unwrap_dek(&kek, &candidate.dek_nonce, &candidate.wrapped_dek) {
217 Ok(d) => d,
218 Err(_) => return Ok(None),
219 };
220 if candidate.validate_seal(&dek).is_err() {
221 return Ok(None);
222 }
223
224 Ok(Some((txn_id, frames)))
225}
226
227/// Configuration for creating a new database.
228#[derive(Clone, Copy, Debug)]
229pub struct MnemoConfig {
230 /// Embedding dimensionality. Every stored vector must match this.
231 pub dimensions: usize,
232 /// Key-derivation parameters.
233 pub kdf: KdfParams,
234 /// Initial size of the WAL region in 8 KiB pages. Default is
235 /// [`DEFAULT_WAL_PAGES`] (8 pages = 64 KiB), which keeps fresh files
236 /// small; the region auto-grows when a transaction's control plane needs
237 /// more space, so raising this is only a hint for the expected steady-state
238 /// transaction size. Write-heavy databases that routinely flush large
239 /// catalogs or ANN indexes can set this higher (e.g. 64 = 512 KiB) to
240 /// avoid early grow events; tiny embedded uses can leave it at the
241 /// default. Clamped to a minimum of [`MIN_WAL_PAGES`] (2 pages).
242 pub wal_pages_initial: u64,
243 /// Maximum number of snapshot manifest entries to retain. Each `flush`
244 /// appends one entry; once the cap is hit, the *oldest* entry is dropped
245 /// from the in-memory manifest before the new one is added, so the
246 /// retained set is always the most-recent N. Defaults to
247 /// [`DEFAULT_MAX_SNAPSHOTS`] (256). Set to `0` to disable the cap and
248 /// retain every snapshot forever (the pre-v0.3 behavior).
249 ///
250 /// Pages referenced *only* by pruned snapshots stay on disk until the
251 /// next [`Mnemo::compact_file`], which reclaims them. Point-in-time
252 /// recovery via [`Mnemo::restore_to`] into a pruned `txn_id` returns
253 /// [`MnemoError::NotFound`].
254 ///
255 /// Apply to an already-open database with [`Mnemo::set_max_snapshots`].
256 pub max_snapshots: usize,
257}
258
259impl Default for MnemoConfig {
260 fn default() -> Self {
261 Self {
262 dimensions: 768,
263 kdf: KdfParams::secure(),
264 wal_pages_initial: DEFAULT_WAL_PAGES,
265 max_snapshots: DEFAULT_MAX_SNAPSHOTS,
266 }
267 }
268}
269
270/// Catalog entry: maps a memory ID to its page run on disk.
271///
272/// **Schema (v5+):** carries `accessed_at` and `access_count` so `recall`
273/// can update access stats by mutating the catalog entry rather than
274/// rewriting the entire record (Phase 2.1 of the improvement plan). The
275/// values in a memory's serialized record body become a stale snapshot
276/// after the first recall; [`Mnemo::read_memory`] re-populates them from
277/// the catalog so consumers always see the live values.
278#[derive(Serialize, Deserialize, Clone, Debug)]
279struct CatalogEntry {
280 /// ULID as a raw `u128`.
281 id: u128,
282 start_page: u64,
283 page_count: u32,
284 /// Exact serialized byte length of the record.
285 len: u32,
286 deleted: bool,
287 /// Unix seconds of the most recent recall hit (or write, for fresh entries).
288 accessed_at: i64,
289 /// How many times `recall` has surfaced this memory.
290 access_count: u32,
291}
292
293/// Frozen v4 catalog entry shape, used only by the v4→v5 migration path in
294/// [`Mnemo::open`]. Its layout matches the catalog encoding written by
295/// pre-v5 builds (5 positional fields). Do not change this struct.
296#[derive(Serialize, Deserialize, Clone, Debug)]
297struct CatalogEntryV4 {
298 id: u128,
299 start_page: u64,
300 page_count: u32,
301 len: u32,
302 deleted: bool,
303}
304
305/// Returned by [`Mnemo::prepare_for_flush`] and consumed by [`Mnemo::flush`].
306/// Carries the already-serialized control-plane bytes through the prelude
307/// so we don't re-serialize after the lease has been persisted.
308struct FlushPrelude {
309 cat_bytes: Option<Vec<u8>>,
310 idx_bytes: Option<Vec<u8>>,
311 /// Serialized result-cache directory bytes when `dirty_cache` is
312 /// set (Phase 10.1). `None` if the cache didn't change since the
313 /// last flush — in that case the header keeps its current
314 /// `cache_start` / `cache_pages` / `cache_len` and no fresh cache
315 /// run is written.
316 cache_bytes: Option<Vec<u8>>,
317}
318
319/// One entry in the append-only snapshot manifest. Because record, catalog,
320/// and index pages are only ever *appended*, the runs a past flush wrote are
321/// still on disk; a `Snapshot` is the set of pointers needed to reconstruct
322/// the database exactly as that flush left it.
323#[derive(Serialize, Deserialize, Clone, Debug)]
324struct Snapshot {
325 txn_id: u64,
326 created_at: i64,
327 catalog_start: u64,
328 catalog_pages: u64,
329 catalog_len: u64,
330 index_start: u64,
331 index_pages: u64,
332 index_len: u64,
333 memory_count: u64,
334}
335
336/// A restorable point in a database's history — see [`Mnemo::snapshots`].
337#[derive(Clone, Copy, Debug)]
338pub struct SnapshotInfo {
339 /// Id of the transaction that produced this snapshot (monotonic from 1).
340 pub txn_id: u64,
341 /// When the snapshot was committed (unix seconds).
342 pub created_at: i64,
343 /// Live memory count captured in the snapshot.
344 pub memory_count: u64,
345}
346
347/// A retrieval request for [`Mnemo::recall`].
348#[derive(Clone, Debug)]
349pub struct RecallRequest {
350 /// Query embedding.
351 pub query: Vec<f32>,
352 /// Maximum results to return.
353 pub top_k: usize,
354 /// Restrict to these memory types (`None` = all types).
355 pub memory_types: Option<Vec<MemoryType>>,
356 /// Restrict to a single agent's view: its own memories plus shared ones.
357 pub agent_id: Option<String>,
358 /// Similarity metric.
359 pub metric: Metric,
360 /// Multi-signal score weights.
361 pub weights: ScoreWeights,
362 /// Index override: partitions to probe (`None` = index default). Ignored
363 /// when no ANN index is present.
364 pub n_probe: Option<usize>,
365 /// Index override: candidates to rerank (`None` = index default). Ignored
366 /// when no ANN index is present.
367 pub n_rerank: Option<usize>,
368 /// Whether to update each result's `accessed_at` and `access_count` in
369 /// the catalog. Defaults to `true`, matching pre-v5 behavior.
370 ///
371 /// Set to `false` for **fully read-only recall** — useful for batch
372 /// scoring, dry-run scoring, or recalls run by introspection tooling
373 /// (e.g. `mnemo about` consumers) where you don't want the score's own
374 /// observation to perturb the database. With `false`, recall does not
375 /// dirty the catalog and the next `flush` is a no-op.
376 pub track_access: bool,
377 /// Phase 12.6 pre-scoring filter: `session_id` + AND-ed metadata
378 /// conditions applied before the candidate is scored. Default is
379 /// [`Filter::default`] (no constraints). When the ANN index is on
380 /// and this filter is selective enough to starve the shortlist
381 /// below `top_k`, Phase 12.1's widen-then-exact-scan logic kicks
382 /// in so the result still carries up to `top_k` correct hits.
383 pub filter: Filter,
384 /// Phase 12.8 post-scoring budget: stop adding hits once the UTF-8
385 /// bytes of their `content` fields (not the serialized record
386 /// body) would exceed this many bytes. `None` (default) means no
387 /// budget and every hit through `top_k` is returned. If the top-1
388 /// hit is already over budget it is still returned — callers that
389 /// need an absolute cap should also pre-size `top_k`.
390 ///
391 /// Counts only the UTF-8 `content` bytes so the number is stable
392 /// across the Rust / Python / CLI / MCP surfaces; the JSON or
393 /// MessagePack envelope is excluded. Roughly 4 bytes per BPE
394 /// token for English text — a 4 KB budget comfortably holds ~1K
395 /// tokens.
396 ///
397 /// Observable through [`RecallResponse::truncated`]
398 /// ([`Mnemo::recall_budgeted`]): `true` iff at least one scored
399 /// hit was dropped by this budget, `false` otherwise.
400 pub max_bytes: Option<usize>,
401}
402
403impl RecallRequest {
404 /// A request with sensible defaults for a query embedding.
405 pub fn new(query: Vec<f32>) -> Self {
406 Self {
407 query,
408 top_k: 10,
409 memory_types: None,
410 agent_id: None,
411 metric: Metric::Cosine,
412 weights: ScoreWeights::default(),
413 n_probe: None,
414 n_rerank: None,
415 track_access: true,
416 filter: Filter::default(),
417 max_bytes: None,
418 }
419 }
420
421 /// Phase 12.6: attach a pre-scoring filter. See [`Filter`] and
422 /// [`FilterTerm`] for term shapes.
423 pub fn filter(mut self, filter: Filter) -> Self {
424 self.filter = filter;
425 self
426 }
427
428 /// Phase 12.8: cap the total UTF-8 bytes of returned `content`
429 /// fields. See [`RecallRequest::max_bytes`].
430 pub fn max_bytes(mut self, n: usize) -> Self {
431 self.max_bytes = Some(n);
432 self
433 }
434
435 /// Build a request by embedding `query_text` with the supplied
436 /// embedder (Phase 3). Shorthand for `embedder.embed(&[text])` +
437 /// [`RecallRequest::new`]; same defaults as `new`. Fails if the
438 /// embedder returns an empty vector list or an embedding failure.
439 pub fn from_text(embedder: &dyn Embedder, query_text: &str) -> Result<Self> {
440 let mut vectors = embedder.embed(&[query_text])?;
441 let v = vectors.pop().ok_or_else(|| {
442 MnemoError::Invalid("embedder returned no vector for the query text".into())
443 })?;
444 Ok(Self::new(v))
445 }
446 /// Set the result cap.
447 pub fn top_k(mut self, k: usize) -> Self {
448 self.top_k = k;
449 self
450 }
451 /// Restrict to specific memory types.
452 pub fn types(mut self, t: Vec<MemoryType>) -> Self {
453 self.memory_types = Some(t);
454 self
455 }
456 /// Restrict to one agent's view.
457 pub fn agent(mut self, agent_id: impl Into<String>) -> Self {
458 self.agent_id = Some(agent_id.into());
459 self
460 }
461 /// Set the similarity metric (default: cosine).
462 pub fn metric(mut self, metric: Metric) -> Self {
463 self.metric = metric;
464 self
465 }
466 /// Replace the multi-signal score weights.
467 pub fn weights(mut self, weights: ScoreWeights) -> Self {
468 self.weights = weights;
469 self
470 }
471 /// Override the number of IVF partitions probed (accuracy/speed dial).
472 pub fn n_probe(mut self, n: usize) -> Self {
473 self.n_probe = Some(n);
474 self
475 }
476 /// Override the number of candidates reranked exactly (accuracy dial).
477 pub fn n_rerank(mut self, n: usize) -> Self {
478 self.n_rerank = Some(n);
479 self
480 }
481 /// Toggle whether recall updates `accessed_at` / `access_count` on
482 /// the returned memories' catalog entries (default `true`). Pass
483 /// `false` for a fully read-only recall — see the field doc.
484 pub fn track_access(mut self, track: bool) -> Self {
485 self.track_access = track;
486 self
487 }
488}
489
490/// One scored result from [`Mnemo::recall`].
491#[derive(Clone, Debug)]
492pub struct RecallResult {
493 /// The retrieved memory.
494 pub memory: Memory,
495 /// Combined multi-signal score.
496 pub score: f32,
497 /// The bare similarity component (before other signals).
498 pub similarity: f32,
499}
500
501/// Phase 12.8 budgeted-recall envelope returned by
502/// [`Mnemo::recall_budgeted`]. Wraps the `Vec<RecallResult>` the
503/// legacy [`Mnemo::recall`] returns so callers can tell whether
504/// [`RecallRequest::max_bytes`] cut anything.
505#[derive(Clone, Debug)]
506pub struct RecallResponse {
507 /// Scored hits, best first, truncated to at most `top_k` and then
508 /// to fit the `max_bytes` budget if set.
509 pub hits: Vec<RecallResult>,
510 /// `true` iff the `max_bytes` budget dropped at least one hit
511 /// that would otherwise have been returned (i.e. the result is
512 /// shorter than what `top_k` alone would produce).
513 pub truncated: bool,
514 /// Phase 12.1 + 12.6 observability: `true` iff recall fell
515 /// through to an exact scan (ANN shortlist too narrow after
516 /// filtering, and the widen pass also came up short, so the
517 /// index was skipped for one full-corpus pass). Monitoring on
518 /// this flag is how callers detect selective-filter regressions
519 /// or supersession chains outgrowing the ANN shortlist.
520 pub used_exact_scan: bool,
521}
522
523/// Summary statistics for a database.
524#[derive(Clone, Debug)]
525pub struct Stats {
526 /// Live (non-deleted) memory count.
527 pub memories: usize,
528 /// Tombstoned entries awaiting compaction.
529 pub deleted: usize,
530 /// Embedding dimensionality.
531 pub dimensions: usize,
532 /// File size in bytes.
533 pub file_bytes: u64,
534 /// Distinct agent IDs present.
535 pub agents: Vec<String>,
536 /// Whether pages are encrypted (always true in v1).
537 pub encrypted: bool,
538 /// Creation time (unix seconds).
539 pub created_at: i64,
540 /// ANN index shape, if an index has been built.
541 pub index: Option<IndexInfo>,
542 /// Current size of the write-ahead log region, in 8 KiB pages.
543 pub wal_pages: u64,
544 /// Fsyncs issued by the most recent successful [`Mnemo::flush`] on
545 /// this handle. `0` on a fresh handle before any flush, or when
546 /// `flush()` short-circuited (nothing dirty). Phase 6 instrumentation
547 /// for the Phase 11.1 write-latency work — the planner wants this
548 /// before deciding whether cutting fsync count is a viable
549 /// durability-preserving fix.
550 pub fsyncs_last_flush: u32,
551}
552
553/// Result of a [`Mnemo::compact_file`] run.
554#[derive(Clone, Copy, Debug, Default)]
555pub struct CompactReport {
556 /// Live memories before compaction.
557 pub before: usize,
558 /// Live memories after compaction (expired ones dropped).
559 pub after: usize,
560 /// Live, non-expired cache entries before compaction. FIX-1
561 /// R-001.1: compaction now preserves the result cache instead of
562 /// silently dropping it.
563 pub cache_before: usize,
564 /// Live, non-expired cache entries after compaction (tombstoned
565 /// and TTL-expired ones dropped).
566 pub cache_after: usize,
567}
568
569/// An encrypted, single-file agent memory database.
570pub struct Mnemo {
571 pager: Pager,
572 header: Header,
573 catalog: Vec<CatalogEntry>,
574 index: HashMap<u128, usize>,
575 #[allow(dead_code)]
576 path: PathBuf,
577 dimensions: usize,
578 kdf: KdfParams,
579 /// Set whenever the catalog changes; drives whether `flush` rewrites it.
580 dirty_catalog: bool,
581 /// Optional IVF+PQ approximate-nearest-neighbour index.
582 ann: Option<IvfPqIndex>,
583 /// Set whenever the ANN index changes; drives whether `flush` rewrites it.
584 dirty_index: bool,
585 /// Append-only manifest of committed snapshots, oldest first. Capped
586 /// at [`Mnemo::max_snapshots`] entries on every flush — the oldest
587 /// entries are pruned first.
588 manifest: Vec<Snapshot>,
589 /// Maximum manifest entries to retain across flushes. `0` disables
590 /// the cap (retain forever). Defaults to [`DEFAULT_MAX_SNAPSHOTS`].
591 max_snapshots: usize,
592 /// If `true`, this handle was opened via [`Mnemo::open_read_only`] and
593 /// every mutating API returns [`MnemoError::ReadOnly`]. The underlying
594 /// file was opened without write permission and holds an OS shared
595 /// lock; the OS would reject a write() anyway, but gating in Rust
596 /// gives callers a clean, typed error.
597 read_only: bool,
598 /// Result-cache directory (Phase 10.1). See [`crate::result_cache`].
599 /// Loaded from `header.cache_start` on open; persisted on `flush`
600 /// via the same seal_run pipeline as catalog/index/manifest.
601 cache_dir: Vec<CacheDirectoryEntry>,
602 /// In-memory index over `cache_dir` for O(1) `(namespace, key_hash)`
603 /// lookups. Rebuilt on open + kept in sync on `cache_put`/`delete`.
604 cache_idx: DirectoryIndex,
605 /// Set whenever `cache_dir` changes; drives whether `flush` rewrites
606 /// the cache directory run.
607 dirty_cache: bool,
608 /// Per-namespace eviction budgets. Missing entries fall back to
609 /// [`CacheBudget::default`]. Not persisted (per-handle policy).
610 cache_budgets: std::collections::HashMap<String, CacheBudget>,
611 /// Flush policy for cache mutations. Memory writes ignore this and
612 /// always behave as `Strict`.
613 cache_flush_policy: CacheFlushPolicy,
614 /// Bookkeeping for `CacheFlushPolicy::Batched` — pending mutation
615 /// count and first-dirty timestamp.
616 cache_batch: BatchState,
617 /// In-memory hit/miss/eviction counters (reset on reopen).
618 cache_counters: CounterState,
619 /// Optional handle-scoped embedder (Phase 3, PR 10a). Registered
620 /// via [`Mnemo::set_embedder`]; consulted by [`Mnemo::remember_text`]
621 /// and [`RecallRequest::from_text`]. `None` means text-side APIs
622 /// return [`MnemoError::Invalid`] with a "no embedder" message.
623 /// Not persisted — the handle is re-registered after every open.
624 embedder: Option<std::sync::Arc<dyn Embedder>>,
625 /// Fsyncs issued by the most recent `flush()` call. Snapshotted
626 /// from the thread-local fsync counter in `lib.rs::fsync_counter`:
627 /// `flush()` reads it on entry, does its work (each `sync_all` the
628 /// pager / wal / recovery path makes bumps the counter), and
629 /// stores the delta here. `0` on a fresh handle before any flush,
630 /// or on a read-only handle where `flush()` short-circuits.
631 /// Exposed through [`Stats::fsyncs_last_flush`] for diagnostics —
632 /// the Phase 6 planner wants this before deciding whether cutting
633 /// fsync count is a viable write-latency win.
634 fsyncs_last_flush: u32,
635 /// FIX-1 R-001.8 part 2: lazy cache of the distinct agent IDs
636 /// across every live memory, built once on first `stats()` call
637 /// per handle. `remember()` adds to it in O(1); `delete()` can't
638 /// cheaply tell whether the removed memory was the last of its
639 /// agent (the agent lives in the encrypted body, not the catalog
640 /// — pre-v9 format), so it just invalidates the set and the next
641 /// `stats()` call rebuilds. Pre-FIX-1 every `stats()` call
642 /// decrypted every live memory to collect agents, turning an
643 /// otherwise-cheap diagnostic into an O(N) decrypt.
644 agent_set: Option<std::collections::BTreeSet<String>>,
645 /// Phase 12.1 lazy caches, built together on the first keyed op this
646 /// handle sees (`remember_keyed`, `update` that touches `_mnemo.key`,
647 /// or any `history(...)` call). Both are maintained incrementally
648 /// after that. `None` → not built yet; `Some(…)` → authoritative.
649 ///
650 /// * `key_index`: `(scope_owner, key) → ULID bytes` of the **live**
651 /// record for each keyed slot. `scope_owner` is `Some(agent_id)`
652 /// for a `Private` scope slot and `None` for a `Shared` one, so a
653 /// private write never collides with a shared one.
654 /// * `superseded`: the ULID bytes of every record that has been
655 /// superseded (`_mnemo.superseded_by` set on it). `recall` and
656 /// `about` skip these before any decrypt.
657 ///
658 /// Building both costs one scan over live records (~1s at 100K with
659 /// hardware AES) and happens at most once per handle. A format bump
660 /// to v9 moves the key into the catalog row so the scan goes away.
661 key_index: Option<std::collections::HashMap<KeySlot, u128>>,
662 superseded: Option<std::collections::HashSet<u128>>,
663}
664
665/// Composite key for the lazy `key_index` on `Mnemo`. A slot is identified
666/// by `(scope_owner, key)`:
667/// * `Scope::Private` writes carry the owning agent → `Some(agent_id)`.
668/// * `Scope::Shared` writes are per-file → `None`.
669///
670/// A private write never supersedes a shared one, and the reverse is also
671/// true. Scope lives on `Memory.scope`, agent on `Memory.agent_id`.
672#[derive(Debug, Clone, PartialEq, Eq, Hash)]
673pub(crate) struct KeySlot {
674 /// `Some(agent_id)` for `Scope::Private`, `None` for `Scope::Shared`.
675 pub(crate) scope_owner: Option<String>,
676 /// The caller-supplied key string, exactly as given (no case-fold).
677 pub(crate) key: String,
678}
679
680/// Open a file and take an OS advisory lock, returning `Locked { path }`
681/// if the requested lock cannot be acquired. On drop of the returned
682/// [`File`], the OS releases the lock. `flock`/`LockFileEx` semantics
683/// (per file-descriptor on Unix, per-open-file-handle on Windows) mean
684/// the lock is scoped to this handle: cloning the file (or the returned
685/// `File`) shares the lock, but a fresh `open()` from any process gets a
686/// distinct handle and will collide.
687fn open_with_lock(path: &Path, write: bool, create: bool) -> Result<File> {
688 let mut opts = OpenOptions::new();
689 opts.read(true);
690 if write {
691 opts.write(true);
692 }
693 if create {
694 opts.create(true).truncate(true);
695 }
696 let file = opts.open(path)?;
697 // On success the returned `Result<()>` is `Ok(())`. On conflict:
698 // * Unix `flock(2)` → `ErrorKind::WouldBlock` (EWOULDBLOCK / EAGAIN)
699 // * Windows `LockFileEx` → raw OS code 33 (ERROR_LOCK_VIOLATION)
700 // or 32 (ERROR_SHARING_VIOLATION), both surfaced by Rust as
701 // `ErrorKind::Uncategorized` because `std::io::ErrorKind`
702 // doesn't carry a portable "locked" variant.
703 // Anything else is a real I/O failure (permissions, invalid handle,
704 // ...) and gets bubbled up as `MnemoError::Io`.
705 let result = if write {
706 FileExt::try_lock_exclusive(&file)
707 } else {
708 FileExt::try_lock_shared(&file)
709 };
710 match result {
711 Ok(()) => Ok(file),
712 Err(e) if is_lock_conflict(&e) => Err(MnemoError::Locked {
713 path: path.to_path_buf(),
714 }),
715 Err(e) => Err(MnemoError::Io(e)),
716 }
717}
718
719/// Do two paths resolve to the same on-disk file? Used by
720/// [`Mnemo::export_encrypted`] to refuse overwriting the live
721/// database. On Unix we compare `(dev, ino)` via `MetadataExt`, which
722/// catches hard links and symlinks. On Windows we compare canonicalized
723/// paths, since there is no stable device+inode equivalent in `std`.
724/// Returns `None` if either side can't be stat'd / canonicalized —
725/// the caller treats that as "not the same file" so a missing `dest`
726/// (the normal export case) proceeds.
727fn paths_refer_to_same_file(a: &Path, b: &Path) -> Option<bool> {
728 #[cfg(unix)]
729 {
730 use std::os::unix::fs::MetadataExt;
731 let ma = std::fs::metadata(a).ok()?;
732 let mb = std::fs::metadata(b).ok()?;
733 Some(ma.dev() == mb.dev() && ma.ino() == mb.ino())
734 }
735 #[cfg(not(unix))]
736 {
737 let ca = std::fs::canonicalize(a).ok()?;
738 let cb = std::fs::canonicalize(b).ok()?;
739 Some(ca == cb)
740 }
741}
742
743/// A sibling temp-file path next to `dest`, suffixed with
744/// `.export-tmp`. Lives in `dest`'s own parent directory so the final
745/// [`std::fs::rename`] is same-filesystem (and therefore atomic on
746/// every platform mnemo supports).
747fn temp_sibling(dest: &Path) -> PathBuf {
748 let mut buf = dest.as_os_str().to_os_string();
749 buf.push(".export-tmp");
750 PathBuf::from(buf)
751}
752
753/// Was this I/O error the OS reporting a conflicting file lock, or a
754/// genuine I/O failure? Platform-conditional because
755/// [`std::io::ErrorKind`] carries no portable "resource-locked" variant
756/// as of Rust 1.75 — Windows surfaces `ERROR_LOCK_VIOLATION` /
757/// `ERROR_SHARING_VIOLATION` as `Uncategorized`.
758fn is_lock_conflict(e: &std::io::Error) -> bool {
759 if e.kind() == std::io::ErrorKind::WouldBlock {
760 return true;
761 }
762 #[cfg(windows)]
763 if let Some(code) = e.raw_os_error() {
764 // 33 = ERROR_LOCK_VIOLATION, 32 = ERROR_SHARING_VIOLATION —
765 // both mean "someone else holds an incompatible lock on this
766 // region of the file". See
767 // https://learn.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-
768 if code == 33 || code == 32 {
769 return true;
770 }
771 }
772 false
773}
774
775/// Read a run of consecutive encrypted pages and concatenate their plaintext.
776fn read_run_bytes(pager: &mut Pager, start: u64, pages: u64, len: u64) -> Result<Vec<u8>> {
777 let mut buf = Vec::with_capacity(len as usize);
778 for i in 0..pages {
779 buf.extend_from_slice(&pager.read_page(start + i)?);
780 }
781 buf.truncate(len as usize);
782 Ok(buf)
783}
784
785/// Load a v5+ catalog run (an empty `pages` yields an empty catalog).
786fn load_catalog(pager: &mut Pager, start: u64, pages: u64, len: u64) -> Result<Vec<CatalogEntry>> {
787 if pages == 0 {
788 return Ok(Vec::new());
789 }
790 let buf = read_run_bytes(pager, start, pages, len)?;
791 rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
792}
793
794/// Load a **v4** catalog run using the frozen [`CatalogEntryV4`] shape.
795/// Used only by the v4→v5 migration path in [`Mnemo::open`].
796fn load_catalog_v4(
797 pager: &mut Pager,
798 start: u64,
799 pages: u64,
800 len: u64,
801) -> Result<Vec<CatalogEntryV4>> {
802 if pages == 0 {
803 return Ok(Vec::new());
804 }
805 let buf = read_run_bytes(pager, start, pages, len)?;
806 rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
807}
808
809/// Walk a v4 catalog and synthesize the v5 shape. For each live entry,
810/// reads the memory's serialized body and copies its `accessed_at` and
811/// `access_count` into the catalog row (the v4 record body still carries
812/// them — they're moving *from* the body *to* the catalog). Deleted
813/// entries skip the read and zero the fields.
814fn migrate_v4_catalog(pager: &mut Pager, v4: Vec<CatalogEntryV4>) -> Result<Vec<CatalogEntry>> {
815 let mut out = Vec::with_capacity(v4.len());
816 for e in &v4 {
817 let (accessed_at, access_count) = if e.deleted {
818 (0i64, 0u32)
819 } else {
820 let mut buf = Vec::with_capacity(e.len as usize);
821 for i in 0..e.page_count as u64 {
822 buf.extend_from_slice(&pager.read_page(e.start_page + i)?);
823 }
824 buf.truncate(e.len as usize);
825 let mem: Memory = rmp_serde::from_slice(&buf)
826 .map_err(|err| MnemoError::Serialize(err.to_string()))?;
827 (mem.accessed_at, mem.access_count)
828 };
829 out.push(CatalogEntry {
830 id: e.id,
831 start_page: e.start_page,
832 page_count: e.page_count,
833 len: e.len,
834 deleted: e.deleted,
835 accessed_at,
836 access_count,
837 });
838 }
839 Ok(out)
840}
841
842/// Load the snapshot manifest (an empty `pages` yields an empty manifest).
843fn load_manifest(pager: &mut Pager, start: u64, pages: u64, len: u64) -> Result<Vec<Snapshot>> {
844 if pages == 0 {
845 return Ok(Vec::new());
846 }
847 let buf = read_run_bytes(pager, start, pages, len)?;
848 rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
849}
850
851/// Load the result-cache directory (v8+). An empty `pages` yields an
852/// empty directory, which is the natural state for v7 files migrated
853/// to v8 (no cache entries yet) and for freshly created v8 files.
854fn load_cache_directory(
855 pager: &mut Pager,
856 start: u64,
857 pages: u64,
858 len: u64,
859) -> Result<Vec<CacheDirectoryEntry>> {
860 if pages == 0 {
861 return Ok(Vec::new());
862 }
863 let buf = read_run_bytes(pager, start, pages, len)?;
864 rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))
865}
866
867/// Load an ANN index run, validating its dimensionality.
868fn load_index(
869 pager: &mut Pager,
870 start: u64,
871 pages: u64,
872 len: u64,
873 dims: usize,
874) -> Result<Option<IvfPqIndex>> {
875 if pages == 0 {
876 return Ok(None);
877 }
878 let buf = read_run_bytes(pager, start, pages, len)?;
879 let mut idx: IvfPqIndex =
880 rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
881 if idx.dims() != dims {
882 return Err(MnemoError::Invalid(
883 "ANN index dimensionality does not match the database".into(),
884 ));
885 }
886 idx.rebuild_assignment();
887 Ok(Some(idx))
888}
889
890/// True if a memory's metadata marks it as the canonical onboarding manifest
891/// (`metadata.topic == "manifest"`). Used by [`Mnemo::about`] to hoist the
892/// manifest to the top of the briefing regardless of importance ordering.
893fn is_manifest(m: &Memory) -> bool {
894 m.metadata
895 .get("topic")
896 .and_then(|v| v.as_str())
897 .map(|s| s.eq_ignore_ascii_case("manifest"))
898 .unwrap_or(false)
899}
900
901/// Build the ULID → catalog-slot lookup map.
902fn build_id_index(catalog: &[CatalogEntry]) -> HashMap<u128, usize> {
903 let mut m = HashMap::with_capacity(catalog.len());
904 for (i, e) in catalog.iter().enumerate() {
905 m.insert(e.id, i);
906 }
907 m
908}
909
910// ---------------------------------------------------------------------------
911// Phase 12.7a helpers used by `ingest_thread`.
912// ---------------------------------------------------------------------------
913
914/// Hex-encode a byte slice. Low-dependency alternative to pulling in a
915/// hex crate for the lone call site in `ingest_thread`'s SHA-256
916/// recording.
917fn hex_encode(bytes: &[u8]) -> String {
918 const HEX: &[u8; 16] = b"0123456789abcdef";
919 let mut out = String::with_capacity(bytes.len() * 2);
920 for b in bytes {
921 out.push(HEX[(b >> 4) as usize] as char);
922 out.push(HEX[(b & 0x0f) as usize] as char);
923 }
924 out
925}
926
927/// Return the longest UTF-8 prefix of `s` whose byte length is `<=
928/// max_bytes`. Always lands on a `char` boundary.
929fn take_utf8_prefix(s: &str, max_bytes: usize) -> String {
930 if s.len() <= max_bytes {
931 return s.to_string();
932 }
933 // Walk forward through char indices; the last index whose byte
934 // offset fits inside `max_bytes` tells us where to cut.
935 let mut cut = 0usize;
936 for (idx, _) in s.char_indices() {
937 if idx > max_bytes {
938 break;
939 }
940 cut = idx;
941 }
942 s[..cut].to_string()
943}
944
945/// Split `s` into chunks, each with `<= max_bytes` of UTF-8 content,
946/// landing on `char` boundaries. Preserves original ordering; a trailing
947/// chunk may be shorter than `max_bytes`.
948/// Phase 12.7b helper: pull the `_mnemo.status` string from an
949/// essence record. Returns `None` for timeline entries (they have no
950/// status) and for malformed records.
951fn status_of(m: &Memory) -> Option<&str> {
952 m.metadata.get(MNEMO_META_STATUS).and_then(|v| v.as_str())
953}
954
955/// Phase 12.7b helper: render one essence item as a markdown bullet.
956/// Timeline entries get a date tag; section items get `[open]` /
957/// `[resolved]` so the reader can scan active work quickly.
958fn render_essence_item(section: EssenceSection, m: &Memory) -> String {
959 if section == EssenceSection::Timeline {
960 let date = format_unix_date(m.created_at);
961 format!("- [{date}] {}\n", m.content)
962 } else {
963 let status = status_of(m).unwrap_or(essence_status::OPEN);
964 format!("- [{status}] {}\n", m.content)
965 }
966}
967
968/// Minimal unix-seconds → `YYYY-MM-DD` formatter (no chrono/time
969/// dependency; mirrors the ISO 8601 parser in `transcript.rs`).
970fn format_unix_date(secs: i64) -> String {
971 let days_total = secs.div_euclid(86_400);
972 let (y, m, d) = civil_from_days(days_total);
973 format!("{y:04}-{m:02}-{d:02}")
974}
975
976/// Howard Hinnant's `civil_from_days` — inverse of `days_from_civil`,
977/// adequate for Gregorian dates since 1970. Pure arithmetic, no
978/// dependencies.
979fn civil_from_days(z: i64) -> (i64, u32, u32) {
980 let z = z + 719_468;
981 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
982 let doe = (z - era * 146_097) as u64;
983 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
984 let y = yoe as i64 + era * 400;
985 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
986 let mp = (5 * doy + 2) / 153;
987 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
988 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
989 let y = if m <= 2 { y + 1 } else { y };
990 (y, m, d)
991}
992
993fn chunk_utf8(s: &str, max_bytes: usize) -> Vec<String> {
994 if s.len() <= max_bytes {
995 return vec![s.to_string()];
996 }
997 let mut out = Vec::new();
998 let mut rest = s;
999 while rest.len() > max_bytes {
1000 let head = take_utf8_prefix(rest, max_bytes);
1001 let head_bytes = head.len();
1002 out.push(head);
1003 rest = &rest[head_bytes..];
1004 }
1005 if !rest.is_empty() {
1006 out.push(rest.to_string());
1007 }
1008 out
1009}
1010
1011impl Mnemo {
1012 /// Create a brand-new encrypted database at `path`.
1013 pub fn create(
1014 path: impl Into<PathBuf>,
1015 passphrase: &str,
1016 config: MnemoConfig,
1017 ) -> Result<Mnemo> {
1018 let path: PathBuf = path.into();
1019 if config.dimensions == 0 {
1020 return Err(MnemoError::Invalid("dimensions must be > 0".into()));
1021 }
1022 // Exclusive lock covers the write path; second writer sees `Locked`.
1023 let file = open_with_lock(&path, /*write=*/ true, /*create=*/ true)?;
1024
1025 let salt = crypto::random_salt();
1026 let dek = crypto::random_dek();
1027 let kek = crypto::derive_kek(passphrase.as_bytes(), &salt, config.kdf)?;
1028 let dek_nonce = crypto::random_nonce();
1029 let wrapped = crypto::wrap_dek(&kek, &dek_nonce, &dek)?;
1030 if wrapped.len() != WRAPPED_DEK_LEN {
1031 return Err(MnemoError::Crypto("unexpected wrapped DEK length".into()));
1032 }
1033 let mut wrapped_dek = [0u8; WRAPPED_DEK_LEN];
1034 wrapped_dek.copy_from_slice(&wrapped);
1035
1036 let mut header = Header {
1037 version: VERSION,
1038 page_size: PAGE_SIZE as u32,
1039 flags: FLAG_ENCRYPTED,
1040 dimensions: config.dimensions as u32,
1041 created_at: memory::now_secs(),
1042 write_counter: 0,
1043 // Page 0 is the header; pages 1..=wal_pages are the WAL;
1044 // record/catalog/index pages start after it. Initial WAL size is
1045 // chosen per-config (default 8 pages = 64 KiB) and clamped to a
1046 // sane floor so the first transaction can always commit.
1047 next_page: 1 + config.wal_pages_initial.max(MIN_WAL_PAGES),
1048 catalog_start: 0,
1049 catalog_pages: 0,
1050 catalog_len: 0,
1051 vector_count: 0,
1052 m_cost: config.kdf.m_cost,
1053 t_cost: config.kdf.t_cost,
1054 p_cost: config.kdf.p_cost,
1055 salt,
1056 dek_nonce,
1057 wrapped_dek,
1058 index_start: 0,
1059 index_pages: 0,
1060 index_len: 0,
1061 wal_start: 1,
1062 wal_pages: config.wal_pages_initial.max(MIN_WAL_PAGES),
1063 wal_seq: 0,
1064 manifest_start: 0,
1065 manifest_pages: 0,
1066 manifest_len: 0,
1067 // Regenerated per write by `apply_seal`; `None` until we seal.
1068 seal_nonce: None,
1069 seal_tag: None,
1070 // v8+ result-cache pointer — zero-initialized on create,
1071 // populated only when `cache_put` runs at least once and
1072 // the next `flush` seals a directory run.
1073 cache_start: 0,
1074 cache_pages: 0,
1075 cache_len: 0,
1076 };
1077
1078 let mut pager = Pager::new(file, dek, 0, VERSION);
1079 let mut page = header.to_page();
1080 header.apply_seal(&mut page, pager.dek())?;
1081 pager.write_raw(0, &page)?;
1082 pager.sync()?;
1083
1084 Ok(Mnemo {
1085 pager,
1086 header,
1087 catalog: Vec::new(),
1088 index: HashMap::new(),
1089 path,
1090 dimensions: config.dimensions,
1091 kdf: config.kdf,
1092 dirty_catalog: false,
1093 ann: None,
1094 dirty_index: false,
1095 manifest: Vec::new(),
1096 max_snapshots: config.max_snapshots,
1097 read_only: false,
1098 cache_dir: Vec::new(),
1099 cache_idx: DirectoryIndex::new(),
1100 dirty_cache: false,
1101 cache_budgets: std::collections::HashMap::new(),
1102 cache_flush_policy: CacheFlushPolicy::default(),
1103 cache_batch: BatchState::default(),
1104 cache_counters: CounterState::default(),
1105 embedder: None,
1106 fsyncs_last_flush: 0,
1107 agent_set: None,
1108 key_index: None,
1109 superseded: None,
1110 })
1111 }
1112
1113 /// Open an existing database. A wrong passphrase fails cleanly with
1114 /// [`MnemoError::WrongPassphrase`]. Takes an OS exclusive lock for
1115 /// the lifetime of the returned handle; a second concurrent open
1116 /// (from any process) returns [`MnemoError::Locked`].
1117 ///
1118 /// For a caller that only needs to read (and can tolerate the
1119 /// pending-recovery / pending-migration cases documented on
1120 /// [`Mnemo::open_read_only`]), prefer that entry point — it takes a
1121 /// shared lock and coexists with other readers.
1122 pub fn open(path: impl Into<PathBuf>, passphrase: &str) -> Result<Mnemo> {
1123 let path: PathBuf = path.into();
1124 let mut file = open_with_lock(&path, /*write=*/ true, /*create=*/ false)?;
1125
1126 let mut hbuf = [0u8; PAGE_SIZE];
1127 file.seek(SeekFrom::Start(0))?;
1128 file.read_exact(&mut hbuf)?;
1129 let mut header = match Header::from_page(&hbuf) {
1130 Ok(h) => h,
1131 Err(e) => {
1132 // DUR-1 (R-002.1 part 2+3): page 0 did not parse — any
1133 // error is treated as a torn-header candidate, not just
1134 // `HeaderChecksum`. The whole 294-byte header lives in
1135 // the first 512-byte sector, so the realistic failure is
1136 // the sector zeroed or garbled, which returns `BadMagic`
1137 // or `UnsupportedVersion` *before* the CRC gate.
1138 //
1139 // Scan the file for valid committed header-frame WAL
1140 // regions, pick the highest `txn_id`, and replay it. If
1141 // nothing valid is found, return the *original* error so
1142 // non-mnemo files still report `BadMagic`.
1143 let scan = scan_header_recovery_candidates(&mut file, passphrase)?;
1144 let frames = scan.ok_or(e)?;
1145 for (page_no, bytes) in &frames {
1146 if bytes.len() != PAGE_SIZE {
1147 return Err(MnemoError::Invalid("WAL frame is not page-sized".into()));
1148 }
1149 file.seek(SeekFrom::Start(page_no * PAGE_SIZE as u64))?;
1150 file.write_all(bytes)?;
1151 }
1152 file.sync_all()?;
1153 crate::bump_fsync();
1154 file.seek(SeekFrom::Start(0))?;
1155 file.read_exact(&mut hbuf)?;
1156 Header::from_page(&hbuf)?
1157 }
1158 };
1159
1160 // Crash recovery: replay a committed-but-uncheckpointed transaction.
1161 // Each frame is a finished page image; the header frame, replayed to
1162 // page 0, supersedes the header just read.
1163 if let Some(frames) = wal::recover(
1164 &mut file,
1165 header.wal_start,
1166 header.wal_pages,
1167 header.wal_seq,
1168 )? {
1169 for (page_no, bytes) in &frames {
1170 if bytes.len() != PAGE_SIZE {
1171 return Err(MnemoError::Invalid("WAL frame is not page-sized".into()));
1172 }
1173 file.seek(SeekFrom::Start(page_no * PAGE_SIZE as u64))?;
1174 file.write_all(bytes)?;
1175 }
1176 file.sync_all()?;
1177 crate::bump_fsync();
1178 // Re-read the now-current header from the replayed page 0.
1179 file.seek(SeekFrom::Start(0))?;
1180 file.read_exact(&mut hbuf)?;
1181 header = Header::from_page(&hbuf)?;
1182 }
1183
1184 let kdf = KdfParams {
1185 m_cost: header.m_cost,
1186 t_cost: header.t_cost,
1187 p_cost: header.p_cost,
1188 };
1189 let kek = crypto::derive_kek(passphrase.as_bytes(), &header.salt, kdf)?;
1190 let dek = crypto::unwrap_dek(&kek, &header.dek_nonce, &header.wrapped_dek)?;
1191
1192 // Validate the v7 header seal now that we have the DEK. Returns
1193 // `MnemoError::HeaderTampered` if any of the sealed mutable fields
1194 // were rewritten after the last legitimate flush. No-op for v6 and
1195 // older — pre-v7 files don't carry a seal.
1196 //
1197 // DUR-1 (Planner decision 2026-11-01, Option B): a seal failure is
1198 // **always** reported as tampering, never auto-healed. R-002.1
1199 // part 3's seal-mismatch WAL fallback is withdrawn: the seal
1200 // exists for tamper-evidence; silently loading a stale committed
1201 // header from the WAL would erase that evidence. A real crash
1202 // cannot produce "CRC valid + seal invalid" either, because the
1203 // whole 294-byte header lies inside sector 0 which storage
1204 // writes atomically — a torn sector breaks magic, version or
1205 // CRC first, and *those* are healed by the scan above.
1206 header.validate_seal(&dek)?;
1207
1208 // Build the pager at the file's *current* version so initial reads
1209 // use the matching AAD scheme — `Pager::page_aad` returns empty AAD
1210 // for v4/v5 (no page binding) and `page_no.to_le_bytes()` for v6+.
1211 let on_disk_version = header.version;
1212 let mut pager = Pager::new(file, dek, header.write_counter, on_disk_version);
1213 let dimensions = header.dimensions as usize;
1214
1215 // === v4→v5 catalog migration =====================================
1216 // v6 reads directly; v5 reads directly; v4 replays the catalog into
1217 // the v5 shape by reading each memory's body to populate the new
1218 // `accessed_at` / `access_count` fields. All page reads here still
1219 // use the v4/v5 AAD scheme (none).
1220 let (catalog, migrated_from_v4) = if on_disk_version >= 5 {
1221 (
1222 load_catalog(
1223 &mut pager,
1224 header.catalog_start,
1225 header.catalog_pages,
1226 header.catalog_len,
1227 )?,
1228 false,
1229 )
1230 } else if on_disk_version == 4 {
1231 let v4 = load_catalog_v4(
1232 &mut pager,
1233 header.catalog_start,
1234 header.catalog_pages,
1235 header.catalog_len,
1236 )?;
1237 (migrate_v4_catalog(&mut pager, v4)?, true)
1238 } else {
1239 return Err(MnemoError::UnsupportedVersion(on_disk_version));
1240 };
1241 let index = build_id_index(&catalog);
1242 let ann = load_index(
1243 &mut pager,
1244 header.index_start,
1245 header.index_pages,
1246 header.index_len,
1247 dimensions,
1248 )?;
1249 let mut manifest = load_manifest(
1250 &mut pager,
1251 header.manifest_start,
1252 header.manifest_pages,
1253 header.manifest_len,
1254 )?;
1255
1256 // === v5→v6 page-encryption migration =============================
1257 // Pre-v6 data pages were encrypted under no AAD; v6 binds the home
1258 // `page_no` as AAD so a page-transplant attack fails authentication
1259 // on read. Re-stage every live record page through `write_page` so
1260 // the next flush re-encrypts it under v6 AAD at the same home slot.
1261 // Tombstoned entries skip — they're reclaimed by `compact_file`.
1262 // Old catalog / ANN / manifest pages stay as orphan ciphertext;
1263 // the next flush writes the new runs at fresh slots.
1264 let migrated_pages_to_v6 = on_disk_version < 6;
1265 if migrated_pages_to_v6 {
1266 for e in &catalog {
1267 if e.deleted {
1268 continue;
1269 }
1270 for i in 0..e.page_count as u64 {
1271 let payload = pager.read_page(e.start_page + i)?;
1272 pager.write_page(e.start_page + i, &payload)?;
1273 }
1274 }
1275 pager.set_version(6);
1276 }
1277
1278 // === v6→v7 header-seal migration =================================
1279 // v7 adds an AES-GCM seal at the tail of the header page that
1280 // authenticates every mutable header field. Pre-v7 files have no
1281 // seal — we just bump the version in memory and mark `dirty_catalog`
1282 // so the next flush writes a sealed v7 header. Pages are untouched
1283 // (page crypto is unchanged from v6).
1284 let migrated_header_to_v7 = on_disk_version < 7;
1285 if migrated_header_to_v7 {
1286 pager.set_version(7);
1287 }
1288
1289 // === v7→v8 cache-directory migration =============================
1290 // v8 adds three u64 header fields (cache_start/pages/len) for the
1291 // result-cache directory (Phase 10.1). Pre-v8 files have those
1292 // three bytes as zero (the on-disk region beyond the v7 seal is
1293 // zero-initialized), which is the legal "empty cache directory"
1294 // state — no data migration required. We bump the version in
1295 // memory and mark dirty_catalog so the next flush writes a
1296 // v8-sealed header with the new AAD shape covering the cache
1297 // pointer. Pages are untouched.
1298 let migrated_header_to_v8 = on_disk_version < 8;
1299 if migrated_header_to_v8 {
1300 pager.set_version(VERSION);
1301 }
1302
1303 let migrated = migrated_from_v4
1304 || migrated_pages_to_v6
1305 || migrated_header_to_v7
1306 || migrated_header_to_v8;
1307 if migrated {
1308 // Stamp the version forward in memory; the next flush persists
1309 // it via the WAL-committed header frame (and the v7 seal).
1310 header.version = VERSION;
1311 // Snapshots written under an older format reference page runs
1312 // encrypted under the old AAD scheme; this build can't decrypt
1313 // them under the new pager. Drop the manifest so the next
1314 // flush records a fresh snapshot of the migrated state.
1315 // Point-in-time recovery into the pre-migration past is
1316 // sacrificed for migration simplicity. Old pages remain on
1317 // disk until `compact_file`. (For v6→v7 alone — which doesn't
1318 // change page crypto — the old snapshots would technically
1319 // still be readable, but dropping them uniformly keeps the
1320 // migration policy simple.)
1321 manifest.clear();
1322 }
1323
1324 // The ANN index pages on disk were sealed under the *old* AAD scheme
1325 // if we migrated pages to v6; force a rewrite at fresh v6-encrypted
1326 // slots on the next flush so a future reopen doesn't try to read
1327 // them under the new AAD and fail. (Catalog and manifest already
1328 // get rewritten via dirty_catalog and the manifest.clear() above.)
1329 let dirty_index = migrated_pages_to_v6 && ann.is_some();
1330
1331 // Load the cache directory (v8+ only). Pre-v8 files have
1332 // cache_pages == 0 by header layout, so this trivially yields
1333 // an empty directory without touching the disk.
1334 let cache_dir = load_cache_directory(
1335 &mut pager,
1336 header.cache_start,
1337 header.cache_pages,
1338 header.cache_len,
1339 )?;
1340 let cache_idx = DirectoryIndex::build(&cache_dir);
1341
1342 Ok(Mnemo {
1343 pager,
1344 header,
1345 catalog,
1346 index,
1347 path,
1348 dimensions,
1349 kdf,
1350 dirty_catalog: migrated,
1351 ann,
1352 dirty_index,
1353 manifest,
1354 // `Mnemo::open` has no [`MnemoConfig`] to read; default the cap
1355 // to [`DEFAULT_MAX_SNAPSHOTS`]. Override with
1356 // [`Mnemo::set_max_snapshots`] right after open if you need
1357 // unlimited retention or a different cap for this handle.
1358 max_snapshots: DEFAULT_MAX_SNAPSHOTS,
1359 read_only: false,
1360 cache_dir,
1361 cache_idx,
1362 dirty_cache: false,
1363 cache_budgets: std::collections::HashMap::new(),
1364 cache_flush_policy: CacheFlushPolicy::default(),
1365 cache_batch: BatchState::default(),
1366 cache_counters: CounterState::default(),
1367 embedder: None,
1368 fsyncs_last_flush: 0,
1369 agent_set: None,
1370 key_index: None,
1371 superseded: None,
1372 })
1373 }
1374
1375 /// Open an existing database in **read-only** mode. Takes an OS shared
1376 /// lock; multiple read-only handles can coexist, but any attempt to
1377 /// open the same file read-write elsewhere returns
1378 /// [`MnemoError::Locked`]. All mutating methods on the returned handle
1379 /// return [`MnemoError::ReadOnly`], including `recall` with
1380 /// `track_access(true)`.
1381 ///
1382 /// Two conditions block a read-only open and return
1383 /// [`MnemoError::NeedsWriteOpen`]:
1384 ///
1385 /// 1. The file has a committed-but-uncheckpointed WAL transaction that
1386 /// would ordinarily be replayed on open. A read-only handle
1387 /// cannot write the recovered pages back.
1388 /// 2. The on-disk format version is older than the current
1389 /// `format::VERSION`, requiring an on-disk migration.
1390 ///
1391 /// In either case, open the file read-write once (which recovers /
1392 /// migrates in place), close the handle cleanly, and reopen
1393 /// read-only. This is documented in the README's Durability section.
1394 pub fn open_read_only(path: impl Into<PathBuf>, passphrase: &str) -> Result<Mnemo> {
1395 let path: PathBuf = path.into();
1396 let mut file = open_with_lock(&path, /*write=*/ false, /*create=*/ false)?;
1397
1398 let mut hbuf = [0u8; PAGE_SIZE];
1399 file.seek(SeekFrom::Start(0))?;
1400 file.read_exact(&mut hbuf)?;
1401 // A torn header on a read-only open cannot be healed (that requires
1402 // writing recovered frames back). Surface as `NeedsWriteOpen` so the
1403 // caller knows to reopen read-write once. DUR-1 (R-002.1 part 4):
1404 // any `Header::from_page` failure (`BadMagic`,
1405 // `UnsupportedVersion`, `HeaderChecksum`) qualifies — but only
1406 // when the file-wide scan actually finds a valid committed
1407 // header frame to recover from, so a non-mnemo file still
1408 // reports `BadMagic` cleanly. A `validate_seal` failure is NOT
1409 // routed here (Option B: tampering is never auto-healed).
1410 let header = match Header::from_page(&hbuf) {
1411 Ok(h) => h,
1412 Err(parse_err) => {
1413 let recoverable = scan_header_recovery_candidates(&mut file, passphrase)?;
1414 return Err(match (parse_err, recoverable) {
1415 (_, Some(_)) => MnemoError::NeedsWriteOpen {
1416 reason: "torn header page needs recovery from WAL",
1417 },
1418 (e, None) => e,
1419 });
1420 }
1421 };
1422
1423 // Refuse to migrate on read-only opens. v6→v7 is header-only
1424 // (no page writes required), but even that bumps `version` on
1425 // disk and re-seals the header — which needs write access. Keep
1426 // the policy uniform across all sub-version bumps.
1427 if header.version < VERSION {
1428 return Err(MnemoError::NeedsWriteOpen {
1429 reason: "pending format-version migration",
1430 });
1431 }
1432
1433 // Refuse if there is a committed-but-uncheckpointed transaction
1434 // waiting for `wal::recover` to fold it into home pages.
1435 if wal::recover(
1436 &mut file,
1437 header.wal_start,
1438 header.wal_pages,
1439 header.wal_seq,
1440 )?
1441 .is_some()
1442 {
1443 return Err(MnemoError::NeedsWriteOpen {
1444 reason: "pending WAL recovery",
1445 });
1446 }
1447
1448 let kdf = KdfParams {
1449 m_cost: header.m_cost,
1450 t_cost: header.t_cost,
1451 p_cost: header.p_cost,
1452 };
1453 let kek = crypto::derive_kek(passphrase.as_bytes(), &header.salt, kdf)?;
1454 let dek = crypto::unwrap_dek(&kek, &header.dek_nonce, &header.wrapped_dek)?;
1455 header.validate_seal(&dek)?;
1456
1457 let mut pager = Pager::new(file, dek, header.write_counter, header.version);
1458 let dimensions = header.dimensions as usize;
1459
1460 let catalog = load_catalog(
1461 &mut pager,
1462 header.catalog_start,
1463 header.catalog_pages,
1464 header.catalog_len,
1465 )?;
1466 let index = build_id_index(&catalog);
1467 let ann = load_index(
1468 &mut pager,
1469 header.index_start,
1470 header.index_pages,
1471 header.index_len,
1472 dimensions,
1473 )?;
1474 let manifest = load_manifest(
1475 &mut pager,
1476 header.manifest_start,
1477 header.manifest_pages,
1478 header.manifest_len,
1479 )?;
1480 let cache_dir = load_cache_directory(
1481 &mut pager,
1482 header.cache_start,
1483 header.cache_pages,
1484 header.cache_len,
1485 )?;
1486 let cache_idx = DirectoryIndex::build(&cache_dir);
1487
1488 Ok(Mnemo {
1489 pager,
1490 header,
1491 catalog,
1492 index,
1493 path,
1494 dimensions,
1495 kdf,
1496 // No mutation is possible; leave both dirty flags false so
1497 // `close()` is a genuine no-op even if we later add an
1498 // auto-flush on drop.
1499 dirty_catalog: false,
1500 ann,
1501 dirty_index: false,
1502 manifest,
1503 max_snapshots: DEFAULT_MAX_SNAPSHOTS,
1504 read_only: true,
1505 cache_dir,
1506 cache_idx,
1507 dirty_cache: false,
1508 cache_budgets: std::collections::HashMap::new(),
1509 cache_flush_policy: CacheFlushPolicy::default(),
1510 cache_batch: BatchState::default(),
1511 cache_counters: CounterState::default(),
1512 embedder: None,
1513 fsyncs_last_flush: 0,
1514 agent_set: None,
1515 key_index: None,
1516 superseded: None,
1517 })
1518 }
1519
1520 /// Override the manifest snapshot cap on this open handle. `0` disables
1521 /// the cap; any positive value keeps the most-recent `max` snapshots and
1522 /// drops the rest on the next flush. See [`MnemoConfig::max_snapshots`].
1523 pub fn set_max_snapshots(&mut self, max: usize) {
1524 self.max_snapshots = max;
1525 }
1526
1527 /// Embedding dimensionality this database expects.
1528 pub fn dimensions(&self) -> usize {
1529 self.dimensions
1530 }
1531
1532 /// Number of live (non-deleted) memories.
1533 pub fn len(&self) -> usize {
1534 self.catalog.iter().filter(|e| !e.deleted).count()
1535 }
1536
1537 /// True if the database holds no live memories.
1538 pub fn is_empty(&self) -> bool {
1539 self.len() == 0
1540 }
1541
1542 // --- internal page/record helpers -------------------------------------
1543
1544 fn write_record(&mut self, bytes: &[u8]) -> Result<(u64, u32)> {
1545 let pc = bytes.len().div_ceil(PAYLOAD).max(1);
1546 let start = self.header.next_page;
1547 self.header.next_page += pc as u64;
1548 for i in 0..pc {
1549 let lo = i * PAYLOAD;
1550 let hi = ((i + 1) * PAYLOAD).min(bytes.len());
1551 self.pager.write_page(start + i as u64, &bytes[lo..hi])?;
1552 }
1553 Ok((start, pc as u32))
1554 }
1555
1556 fn read_record(&mut self, e: &CatalogEntry) -> Result<Vec<u8>> {
1557 let mut buf = Vec::with_capacity(e.len as usize);
1558 for i in 0..e.page_count as u64 {
1559 buf.extend_from_slice(&self.pager.read_page(e.start_page + i)?);
1560 }
1561 buf.truncate(e.len as usize);
1562 Ok(buf)
1563 }
1564
1565 fn read_memory(&mut self, e: &CatalogEntry) -> Result<Memory> {
1566 let bytes = self.read_record(e)?;
1567 let mut m: Memory =
1568 rmp_serde::from_slice(&bytes).map_err(|err| MnemoError::Serialize(err.to_string()))?;
1569 // The catalog is the source of truth for access stats — the values
1570 // in the record body are a stale snapshot from when the record was
1571 // last written. Overwrite them here so consumers always see live
1572 // values regardless of when the record was last serialized.
1573 m.accessed_at = e.accessed_at;
1574 m.access_count = e.access_count;
1575 Ok(m)
1576 }
1577
1578 /// Serialize and store a memory (insert or overwrite by ID).
1579 fn put(&mut self, mut m: Memory) -> Result<Ulid> {
1580 if m.vector.len() != self.dimensions {
1581 return Err(MnemoError::DimensionMismatch {
1582 expected: self.dimensions,
1583 got: m.vector.len(),
1584 });
1585 }
1586 // Reject all-zero vectors (Phase 3 PR 10a; breaking vs 0.4.x).
1587 // A zero vector scores exactly 0.0 on every cosine query — it
1588 // can't contribute signal, but recency / importance can still
1589 // pull it into top-k as noise. We'd rather fail loudly at
1590 // `remember` than let a mis-embedded entry pollute recall
1591 // quality for the life of the database. Scaffold manifests now
1592 // use a sentinel unit vector so this check doesn't trip them.
1593 if embed::is_zero_vector(&m.vector) {
1594 return Err(MnemoError::Invalid(
1595 "all-zero vector refused: it scores 0.0 on every cosine query and \
1596 pollutes recall; check your embedder returned a real embedding"
1597 .into(),
1598 ));
1599 }
1600 if m.id == Ulid::nil() {
1601 m.id = Ulid::new();
1602 }
1603 let id_u: u128 = m.id.0;
1604 let bytes = rmp_serde::to_vec(&m).map_err(|e| MnemoError::Serialize(e.to_string()))?;
1605 let (start, pc) = self.write_record(&bytes)?;
1606 // For overwrites preserve the existing access stats; for inserts
1607 // seed them from the memory (which carries them in its body).
1608 let (prev_accessed, prev_count) = match self.index.get(&id_u).copied() {
1609 Some(idx) => (
1610 self.catalog[idx].accessed_at,
1611 self.catalog[idx].access_count,
1612 ),
1613 None => (m.accessed_at, m.access_count),
1614 };
1615 let entry = CatalogEntry {
1616 id: id_u,
1617 start_page: start,
1618 page_count: pc,
1619 len: bytes.len() as u32,
1620 deleted: false,
1621 accessed_at: prev_accessed,
1622 access_count: prev_count,
1623 };
1624 match self.index.get(&id_u).copied() {
1625 Some(idx) => self.catalog[idx] = entry,
1626 None => {
1627 self.index.insert(id_u, self.catalog.len());
1628 self.catalog.push(entry);
1629 }
1630 }
1631 self.dirty_catalog = true;
1632
1633 // Keep the ANN index complete: assign the (new or changed) vector to
1634 // its nearest partition. Centroids/codebook stay fixed until rebuild.
1635 if let Some(ann) = &mut self.ann {
1636 ann.add(id_u, &m.vector);
1637 self.dirty_index = true;
1638 }
1639
1640 // FIX-1 R-001.8 part 2: keep the lazy agent set in sync. If
1641 // it hasn't been built yet (no `stats()` call this session),
1642 // skip — the first `stats()` will build it from scratch. If
1643 // it has, add this memory's agent_id. An overwrite with the
1644 // same id and same agent is a no-op (BTreeSet::insert returns
1645 // false); an overwrite with a *changed* agent_id adds the new
1646 // agent but doesn't drop the old (we can't tell without a
1647 // decrypt whether the old agent still has live memories).
1648 // That's conservative in the right direction — `stats()` may
1649 // over-report by one agent until the next delete invalidates.
1650 if let Some(set) = &mut self.agent_set {
1651 set.insert(m.agent_id.clone());
1652 }
1653 Ok(m.id)
1654 }
1655
1656 // --- public CRUD ------------------------------------------------------
1657
1658 /// Register a text embedder for this handle. Enables
1659 /// [`Mnemo::remember_text`] and the text-side `RecallRequest`
1660 /// convenience path. Does two checks before storing the embedder:
1661 ///
1662 /// 1. **Dimension check.** The embedder's `identity().dimensions`
1663 /// must equal [`Mnemo::dimensions`] (the width this database
1664 /// was created with). Mismatch returns
1665 /// [`MnemoError::DimensionMismatch`].
1666 /// 2. **Manifest identity check.** If the scaffold / onboarding
1667 /// manifest already records an embedder — the canonical shape
1668 /// is `metadata.embedder = { name, dimensions, normalize }`,
1669 /// with the legacy flat `embedder_name` / `embedder_dimensions`
1670 /// / `embedder_normalize` keys accepted as an alias — the new
1671 /// embedder's identity must match exactly. A different name —
1672 /// even at the same dimensions — returns
1673 /// [`MnemoError::Invalid`] with
1674 /// [`EmbedderIdentity::MISMATCH_MSG`] in the message. If the
1675 /// manifest has no embedder metadata yet, this call upserts
1676 /// the nested object into the first onboarding/manifest memory
1677 /// (write + flush so a subsequent reopen sees the identity).
1678 ///
1679 /// **Read-only handles.** `set_embedder` on an `open_read_only`
1680 /// handle performs both checks and adopts the embedder in memory;
1681 /// it never writes. Readers (CLI `--text`, agent-side `recall_text`)
1682 /// can therefore use the text path next to a writer that holds
1683 /// the exclusive lock.
1684 ///
1685 /// On both failure paths the handle's embedder slot is left
1686 /// unchanged. On success the embedder is wrapped in an `Arc<dyn
1687 /// Embedder>` and consulted by every text-side call made through
1688 /// this handle. The slot is per-handle and not persisted — a
1689 /// fresh open must re-register (same identity, so the manifest
1690 /// check round-trips cleanly).
1691 pub fn set_embedder<E: Embedder + 'static>(&mut self, embedder: E) -> Result<()> {
1692 let identity = embedder.identity();
1693 if identity.dimensions != self.dimensions {
1694 return Err(MnemoError::DimensionMismatch {
1695 expected: self.dimensions,
1696 got: identity.dimensions,
1697 });
1698 }
1699
1700 // Find a scaffold/onboarding manifest to inspect (or upsert
1701 // into). `about()` already filters + sorts the onboarding set.
1702 let onboarding = self.about()?;
1703 let mut upsert_target: Option<usize> = None;
1704 for (i, m) in onboarding.iter().enumerate() {
1705 if let Some(saw) = embed::read_identity_from_metadata(&m.metadata) {
1706 if saw != identity {
1707 return Err(embed::identity_mismatch_err(&saw, &identity));
1708 }
1709 // Match — nothing to persist; adopt + done. Works the
1710 // same on read-only and read-write handles.
1711 self.embedder = Some(std::sync::Arc::new(embedder));
1712 return Ok(());
1713 }
1714 if upsert_target.is_none() {
1715 upsert_target = Some(i);
1716 }
1717 }
1718
1719 // No existing identity on the manifest. On a read-write handle
1720 // upsert into the first onboarding memory so later opens see
1721 // it; on a read-only handle skip the write (readers must not
1722 // mutate the file), but still adopt the embedder in memory.
1723 if !self.read_only {
1724 if let Some(idx) = upsert_target {
1725 let mut m = onboarding[idx].clone();
1726 embed::write_identity_into_metadata(&mut m.metadata, &identity);
1727 // Reuse the memory's existing id so this is an in-place
1728 // update of the scaffold manifest, not a new entry.
1729 self.put(m)?;
1730 self.flush()?;
1731 }
1732 }
1733
1734 self.embedder = Some(std::sync::Arc::new(embedder));
1735 Ok(())
1736 }
1737
1738 /// Borrow the currently-registered embedder, if any. Returns
1739 /// `None` when no embedder has been set via
1740 /// [`Mnemo::set_embedder`]. Bindings use this to drive their own
1741 /// text-side convenience methods without duplicating the identity
1742 /// check.
1743 pub fn embedder(&self) -> Option<&dyn Embedder> {
1744 self.embedder.as_deref()
1745 }
1746
1747 /// Shared handle to the registered embedder, cloned so the caller
1748 /// can drop the borrow on this `Mnemo` before invoking `embed()`.
1749 /// FIX-1 / PY-1 (R-001.11): the Python binding needs this so an
1750 /// embedder callback that re-enters `Mnemo` (e.g. a Python
1751 /// embedder that calls `db.dimensions()` or `db.cache_get(...)`)
1752 /// doesn't hit PyO3's "Already borrowed" — we clone the Arc under
1753 /// a short `&self` borrow, release that borrow, then invoke
1754 /// `embed()` with no `&mut Mnemo` held.
1755 pub fn embedder_arc(&self) -> Option<std::sync::Arc<dyn Embedder>> {
1756 self.embedder.clone()
1757 }
1758
1759 /// Embed `content` with the registered embedder and `remember` the
1760 /// result. Returns the new memory's ULID. Convenience path for
1761 /// callers who have a string, want it stored, and don't need any
1762 /// of the per-memory options [`Memory`] exposes (agent, importance,
1763 /// metadata, ttl, scope) — build a [`Memory`] by hand and call
1764 /// [`Mnemo::remember`] for those.
1765 ///
1766 /// Fails with [`MnemoError::Invalid`] if no embedder has been
1767 /// registered on this handle (call [`Mnemo::set_embedder`] first);
1768 /// with [`MnemoError::DimensionMismatch`] if the embedder returned
1769 /// the wrong width for this database.
1770 pub fn remember_text(&mut self, content: &str, memory_type: MemoryType) -> Result<Ulid> {
1771 if self.read_only {
1772 return Err(MnemoError::ReadOnly);
1773 }
1774 let embedder = self.embedder.as_ref().ok_or_else(|| {
1775 MnemoError::Invalid(
1776 "no embedder registered on this handle; call set_embedder first".into(),
1777 )
1778 })?;
1779 let mut vectors = embedder.embed(&[content])?;
1780 let vector = vectors.pop().ok_or_else(|| {
1781 MnemoError::Invalid("embedder returned no vector for the input text".into())
1782 })?;
1783 if vector.len() != self.dimensions {
1784 return Err(MnemoError::DimensionMismatch {
1785 expected: self.dimensions,
1786 got: vector.len(),
1787 });
1788 }
1789 let memory = Memory::new(content.to_string(), memory_type, vector);
1790 self.remember(memory)
1791 }
1792
1793 /// Store a memory. Returns its ULID. If the memory's ID is nil a fresh
1794 /// one is assigned; an existing ID overwrites in place.
1795 pub fn remember(&mut self, memory: Memory) -> Result<Ulid> {
1796 if self.read_only {
1797 return Err(MnemoError::ReadOnly);
1798 }
1799 self.put(memory)
1800 }
1801
1802 /// Fetch a memory by ID.
1803 pub fn get(&mut self, id: &Ulid) -> Result<Memory> {
1804 let idx = *self
1805 .index
1806 .get(&id.0)
1807 .ok_or_else(|| MnemoError::NotFound(id.to_string()))?;
1808 let entry = self.catalog[idx].clone();
1809 if entry.deleted {
1810 return Err(MnemoError::NotFound(id.to_string()));
1811 }
1812 self.read_memory(&entry)
1813 }
1814
1815 /// Soft-delete a memory (tombstoned; space reclaimed by `compact`).
1816 pub fn delete(&mut self, id: &Ulid) -> Result<()> {
1817 if self.read_only {
1818 return Err(MnemoError::ReadOnly);
1819 }
1820 let idx = *self
1821 .index
1822 .get(&id.0)
1823 .ok_or_else(|| MnemoError::NotFound(id.to_string()))?;
1824 if !self.catalog[idx].deleted {
1825 self.catalog[idx].deleted = true;
1826 self.dirty_catalog = true;
1827 }
1828 if let Some(ann) = &mut self.ann {
1829 ann.remove(id.0);
1830 self.dirty_index = true;
1831 }
1832 // FIX-1 R-001.8 part 2: we can't tell whether this delete
1833 // removed the last memory of its agent without a decrypt
1834 // (the catalog entry doesn't carry `agent_id` in format v8),
1835 // so just invalidate the lazy set. The next `stats()` call
1836 // rebuilds from live memories.
1837 self.agent_set = None;
1838 // Phase 12.1: delete of a keyed record removes it from the slot,
1839 // and delete of a superseded record frees a `history` entry. In
1840 // both cases the exact (key, scope_owner) needs a decrypt we're
1841 // not willing to pay here, so drop the lazy caches and rebuild
1842 // on next access. Format v9 moves the key into the catalog row
1843 // and this becomes O(1).
1844 self.key_index = None;
1845 self.superseded = None;
1846 Ok(())
1847 }
1848
1849 // --- Phase 12.1: edit in place + keyed supersession -----------------
1850
1851 /// Scope-aware key-slot identifier for `memory` and a caller key.
1852 ///
1853 /// * [`Scope::Private`] → owned by the memory's `agent_id`.
1854 /// * [`Scope::Shared`] → one slot across the whole file.
1855 fn key_slot_for(memory: &Memory, key: &str) -> crate::store::KeySlot {
1856 let scope_owner = match memory.scope {
1857 Scope::Private => Some(memory.agent_id.clone()),
1858 Scope::Shared => None,
1859 };
1860 crate::store::KeySlot {
1861 scope_owner,
1862 key: key.to_string(),
1863 }
1864 }
1865
1866 /// Build the `key_index` + `superseded` lazy caches from live
1867 /// records. Called by every 12.1 entrypoint on first use. Decrypts
1868 /// every live record once, so a fresh open pays one scan the first
1869 /// time a keyed op touches it; subsequent ops are O(1).
1870 fn ensure_key_caches(&mut self) -> Result<()> {
1871 if self.key_index.is_some() && self.superseded.is_some() {
1872 return Ok(());
1873 }
1874 let entries: Vec<CatalogEntry> = self
1875 .catalog
1876 .iter()
1877 .filter(|e| !e.deleted)
1878 .cloned()
1879 .collect();
1880 let mut keys: std::collections::HashMap<crate::store::KeySlot, u128> =
1881 std::collections::HashMap::new();
1882 let mut superseded: std::collections::HashSet<u128> = std::collections::HashSet::new();
1883 for e in &entries {
1884 let m = self.read_memory(e)?;
1885 if m.metadata.get(MNEMO_META_SUPERSEDED_BY).is_some() {
1886 superseded.insert(e.id);
1887 continue;
1888 }
1889 if let Some(Value::String(k)) = m.metadata.get(MNEMO_META_KEY) {
1890 let slot = Self::key_slot_for(&m, k);
1891 // Catalog order is insertion order; a later live record
1892 // with the same slot would only exist if a prior build
1893 // of this code allowed it. Keep the later id (newer
1894 // ULID), in case of a tie.
1895 keys.entry(slot)
1896 .and_modify(|cur| {
1897 if *cur < e.id {
1898 *cur = e.id;
1899 }
1900 })
1901 .or_insert(e.id);
1902 }
1903 }
1904 self.key_index = Some(keys);
1905 self.superseded = Some(superseded);
1906 Ok(())
1907 }
1908
1909 /// Stage-identical check for a 12.1 keyed no-op. The incoming write
1910 /// is byte-equal to the live record iff every user-visible field
1911 /// (content, type, importance, vector, user metadata) matches. The
1912 /// `_mnemo.*` reserved keys on the live record are **ignored** for
1913 /// this comparison — a repeat of an identical write re-issued by an
1914 /// extractor must be a NOOP even if the live record already carries
1915 /// supersession bookkeeping from a prior op.
1916 fn is_identical_keyed_write(incoming: &Memory, live: &Memory) -> bool {
1917 if incoming.content != live.content {
1918 return false;
1919 }
1920 if incoming.memory_type != live.memory_type {
1921 return false;
1922 }
1923 if (incoming.importance - live.importance).abs() > f32::EPSILON {
1924 return false;
1925 }
1926 if incoming.vector != live.vector {
1927 return false;
1928 }
1929 // Strip `_mnemo.*` from both sides before comparing user metadata.
1930 let strip = |m: &Map<String, Value>| -> Map<String, Value> {
1931 m.iter()
1932 .filter(|(k, _)| !k.starts_with(crate::memory::MNEMO_META_NS))
1933 .map(|(k, v)| (k.clone(), v.clone()))
1934 .collect()
1935 };
1936 strip(&incoming.metadata) == strip(&live.metadata)
1937 }
1938
1939 /// Store a memory under a keyed upsert slot. Returns the live record's
1940 /// id and a [`WriteOutcome`] saying whether this write created a new
1941 /// slot (`Added`), replaced a prior record (`Superseded`), or matched
1942 /// the existing live record byte-for-byte (`Unchanged`, no file write).
1943 ///
1944 /// The slot is identified by `(scope_owner, key)`:
1945 /// * `Scope::Private` memories share a slot per `(agent_id, key)`.
1946 /// * `Scope::Shared` memories share one slot per `key` across the file.
1947 ///
1948 /// On supersession the prior record is **not** deleted — it remains
1949 /// retrievable through [`Mnemo::history`] — but it is excluded from
1950 /// default [`Mnemo::recall`] and [`Mnemo::about`]. A caller that wants
1951 /// the full lineage of a key walks `history(key)`.
1952 ///
1953 /// This is the atomic 12.1 "supersede-or-noop" write path. Plain
1954 /// [`Mnemo::remember`] without a key is unchanged.
1955 pub fn remember_keyed(
1956 &mut self,
1957 mut memory: Memory,
1958 key: &str,
1959 ) -> Result<(Ulid, WriteOutcome)> {
1960 if self.read_only {
1961 return Err(MnemoError::ReadOnly);
1962 }
1963 if key.is_empty() {
1964 return Err(MnemoError::Invalid(
1965 "remember_keyed: 'key' must not be empty".into(),
1966 ));
1967 }
1968 self.ensure_key_caches()?;
1969 let slot = Self::key_slot_for(&memory, key);
1970 let existing = self.key_index.as_ref().and_then(|k| k.get(&slot).copied());
1971
1972 // Caller may already have set `_mnemo.key` on the memory; rewrite
1973 // it to our canonical value regardless so the record is
1974 // self-identifying.
1975 memory
1976 .metadata
1977 .insert(MNEMO_META_KEY.into(), Value::String(key.to_string()));
1978
1979 // NOOP check: if a live record exists for this slot and its
1980 // user-visible fields match, return the existing id without a
1981 // write.
1982 if let Some(prev_id_u) = existing {
1983 let prev_idx = *self
1984 .index
1985 .get(&prev_id_u)
1986 .ok_or_else(|| MnemoError::Invalid("key_index drift".into()))?;
1987 let prev_entry = self.catalog[prev_idx].clone();
1988 let prev_mem = self.read_memory(&prev_entry)?;
1989 if Self::is_identical_keyed_write(&memory, &prev_mem) {
1990 return Ok((Ulid(prev_id_u), WriteOutcome::Unchanged));
1991 }
1992
1993 // SUPERSESSION: insert the new record, then rewrite the old
1994 // with `_mnemo.superseded_by` + `_mnemo.invalid_at`.
1995 let now = memory::now_secs();
1996 memory.id = Ulid::nil(); // force a fresh ULID in `put`
1997 memory.metadata.insert(
1998 MNEMO_META_SUPERSEDES.into(),
1999 Value::String(Ulid(prev_id_u).to_string()),
2000 );
2001 let new_id = self.put(memory)?;
2002 // Mark the prior record.
2003 let mut prev_mut = prev_mem;
2004 prev_mut.metadata.insert(
2005 MNEMO_META_SUPERSEDED_BY.into(),
2006 Value::String(new_id.to_string()),
2007 );
2008 prev_mut
2009 .metadata
2010 .insert(MNEMO_META_INVALID_AT.into(), Value::Number(now.into()));
2011 // keep prev_mut.id so `put` overwrites in place.
2012 self.put(prev_mut)?;
2013 // Update caches incrementally.
2014 if let Some(k) = self.key_index.as_mut() {
2015 k.insert(slot, new_id.0);
2016 }
2017 if let Some(s) = self.superseded.as_mut() {
2018 s.insert(prev_id_u);
2019 }
2020 return Ok((new_id, WriteOutcome::Superseded));
2021 }
2022
2023 // ADDED: no prior record for this slot.
2024 memory.id = Ulid::nil();
2025 let new_id = self.put(memory)?;
2026 if let Some(k) = self.key_index.as_mut() {
2027 k.insert(slot, new_id.0);
2028 }
2029 Ok((new_id, WriteOutcome::Added))
2030 }
2031
2032 /// Text-side keyed write: embed `content` through the registered
2033 /// embedder, then [`Mnemo::remember_keyed`]. Same returns.
2034 pub fn remember_keyed_text(
2035 &mut self,
2036 content: &str,
2037 memory_type: MemoryType,
2038 key: &str,
2039 ) -> Result<(Ulid, WriteOutcome)> {
2040 if self.read_only {
2041 return Err(MnemoError::ReadOnly);
2042 }
2043 let embedder = self.embedder.as_ref().ok_or_else(|| {
2044 MnemoError::Invalid(
2045 "no embedder registered on this handle; call set_embedder first".into(),
2046 )
2047 })?;
2048 let mut vectors = embedder.embed(&[content])?;
2049 let vector = vectors.pop().ok_or_else(|| {
2050 MnemoError::Invalid("embedder returned no vector for the input text".into())
2051 })?;
2052 if vector.len() != self.dimensions {
2053 return Err(MnemoError::DimensionMismatch {
2054 expected: self.dimensions,
2055 got: vector.len(),
2056 });
2057 }
2058 let memory = Memory::new(content.to_string(), memory_type, vector);
2059 self.remember_keyed(memory, key)
2060 }
2061
2062 /// Update an existing memory in place, preserving its ULID,
2063 /// `created_at` and access stats. Returns [`WriteOutcome::Updated`]
2064 /// on a real change, [`WriteOutcome::Unchanged`] when every field in
2065 /// the patch already matches the live record.
2066 ///
2067 /// Reserved `_mnemo.*` metadata keys on the existing record are
2068 /// preserved across a `metadata` replacement; a `metadata` of
2069 /// `Some(map)` replaces every **user-visible** key with `map`'s
2070 /// entries. A `content` change requires a matching `vector`
2071 /// (use [`Mnemo::update_text`] to re-embed) — a bare `content` patch
2072 /// is rejected to stop text and embedding drifting apart silently.
2073 pub fn update(&mut self, id: &Ulid, patch: MemoryPatch) -> Result<WriteOutcome> {
2074 if self.read_only {
2075 return Err(MnemoError::ReadOnly);
2076 }
2077 if patch.content.is_some() && patch.vector.is_none() {
2078 return Err(MnemoError::Invalid(
2079 "update: a content change needs a matching vector (or call update_text \
2080 to re-embed through the registered embedder)"
2081 .into(),
2082 ));
2083 }
2084 let idx = *self
2085 .index
2086 .get(&id.0)
2087 .ok_or_else(|| MnemoError::NotFound(id.to_string()))?;
2088 if self.catalog[idx].deleted {
2089 return Err(MnemoError::NotFound(id.to_string()));
2090 }
2091 let entry = self.catalog[idx].clone();
2092 let existing = self.read_memory(&entry)?;
2093
2094 // Apply patch into a candidate, detect no-op after the fact.
2095 let mut cand = existing.clone();
2096 if let Some(c) = &patch.content {
2097 cand.content = c.clone();
2098 }
2099 if let Some(v) = &patch.vector {
2100 if v.len() != self.dimensions {
2101 return Err(MnemoError::DimensionMismatch {
2102 expected: self.dimensions,
2103 got: v.len(),
2104 });
2105 }
2106 cand.vector = v.clone();
2107 }
2108 if let Some(i) = patch.importance {
2109 cand.importance = i.clamp(0.0, 1.0);
2110 }
2111 if let Some(mt) = patch.memory_type {
2112 cand.memory_type = mt;
2113 }
2114 if let Some(new_meta) = patch.metadata {
2115 // Keep reserved `_mnemo.*` keys from the existing record.
2116 let reserved: Vec<(String, Value)> = existing
2117 .metadata
2118 .iter()
2119 .filter(|(k, _)| k.starts_with(crate::memory::MNEMO_META_NS))
2120 .map(|(k, v)| (k.clone(), v.clone()))
2121 .collect();
2122 cand.metadata = new_meta;
2123 for (k, v) in reserved {
2124 cand.metadata.insert(k, v);
2125 }
2126 }
2127 if let Some(ttl) = patch.ttl_secs {
2128 cand.ttl_secs = ttl;
2129 }
2130
2131 // No-op detection: compare all user-visible fields.
2132 if cand.content == existing.content
2133 && cand.vector == existing.vector
2134 && (cand.importance - existing.importance).abs() <= f32::EPSILON
2135 && cand.memory_type == existing.memory_type
2136 && cand.metadata == existing.metadata
2137 && cand.ttl_secs == existing.ttl_secs
2138 {
2139 return Ok(WriteOutcome::Unchanged);
2140 }
2141
2142 // Stamp `_mnemo.updated_at` and preserve id + created_at.
2143 let now = memory::now_secs();
2144 cand.metadata
2145 .insert(MNEMO_META_UPDATED_AT.into(), Value::Number(now.into()));
2146 cand.id = existing.id;
2147 cand.created_at = existing.created_at;
2148 // put() preserves accessed_at/access_count via the index lookup.
2149 self.put(cand)?;
2150 // key_index may need maintenance if this update added or removed
2151 // `_mnemo.key`; cheapest safe path is to invalidate.
2152 self.key_index = None;
2153 Ok(WriteOutcome::Updated)
2154 }
2155
2156 /// In-place update that re-embeds `new_text` through the registered
2157 /// embedder. Shorthand for `update(id, patch.with_content_and_vector)`.
2158 pub fn update_text(
2159 &mut self,
2160 id: &Ulid,
2161 new_text: &str,
2162 mut patch: MemoryPatch,
2163 ) -> Result<WriteOutcome> {
2164 if self.read_only {
2165 return Err(MnemoError::ReadOnly);
2166 }
2167 let embedder = self.embedder.as_ref().ok_or_else(|| {
2168 MnemoError::Invalid(
2169 "no embedder registered on this handle; call set_embedder first".into(),
2170 )
2171 })?;
2172 let mut vectors = embedder.embed(&[new_text])?;
2173 let vector = vectors.pop().ok_or_else(|| {
2174 MnemoError::Invalid("embedder returned no vector for the input text".into())
2175 })?;
2176 if vector.len() != self.dimensions {
2177 return Err(MnemoError::DimensionMismatch {
2178 expected: self.dimensions,
2179 got: vector.len(),
2180 });
2181 }
2182 patch.content = Some(new_text.to_string());
2183 patch.vector = Some(vector);
2184 self.update(id, patch)
2185 }
2186
2187 /// Walk the full chain for a keyed slot: every record this key has
2188 /// ever held, newest first. Entry 0 is the **live** record (unless
2189 /// the slot is empty, in which case the vector is empty); subsequent
2190 /// entries are the superseded history in reverse-supersession order.
2191 ///
2192 /// **Slot resolution (v0.8.1 bugfix, lane A).** The lookup slot
2193 /// matches a keyed `remember` with the same arguments, so the
2194 /// obvious `history(key, None)` walks the private slot of the
2195 /// default agent — the same slot
2196 /// `remember_keyed(Memory::new(...), key)` writes to when the
2197 /// caller omits an explicit `agent_id` / `scope`. Pre-v0.8.1
2198 /// `agent_id = None` incorrectly looked up the shared slot and
2199 /// returned an empty chain for the common case; the old behavior
2200 /// is still reachable via `history_in(key, KeyScope::Shared)`.
2201 ///
2202 /// * `agent_id = Some(a)` → private slot for that agent.
2203 /// * `agent_id = None` → private slot for the default agent (same
2204 /// as [`KeyScope::private_default`]).
2205 ///
2206 /// Use [`Mnemo::history_in`] with [`KeyScope::Shared`] for the
2207 /// shared slot.
2208 pub fn history(&mut self, key: &str, agent_id: Option<&str>) -> Result<Vec<Memory>> {
2209 let scope = match agent_id {
2210 Some(a) => KeyScope::Private(a.to_string()),
2211 None => KeyScope::private_default(),
2212 };
2213 self.history_in(key, scope)
2214 }
2215
2216 /// Walk the full chain for a keyed slot identified by an explicit
2217 /// [`KeyScope`]. Returns the same shape as [`Mnemo::history`] —
2218 /// live record first, then the superseded chain newest-first.
2219 ///
2220 /// Use this when you need the **shared** slot (`KeyScope::Shared`),
2221 /// which `history(key, None)` no longer reaches as of v0.8.1.
2222 pub fn history_in(&mut self, key: &str, scope: KeyScope) -> Result<Vec<Memory>> {
2223 self.ensure_key_caches()?;
2224 let slot = crate::store::KeySlot {
2225 scope_owner: match scope {
2226 KeyScope::Private(a) => Some(a),
2227 KeyScope::Shared => None,
2228 },
2229 key: key.to_string(),
2230 };
2231 let live_id = self.key_index.as_ref().and_then(|k| k.get(&slot).copied());
2232 let mut out = Vec::new();
2233 let mut cursor = live_id;
2234 while let Some(id_u) = cursor {
2235 let idx = *self.index.get(&id_u).ok_or_else(|| {
2236 MnemoError::Invalid("history: superseded chain references missing id".into())
2237 })?;
2238 let entry = self.catalog[idx].clone();
2239 if entry.deleted {
2240 break;
2241 }
2242 let m = self.read_memory(&entry)?;
2243 let next = m
2244 .metadata
2245 .get(MNEMO_META_SUPERSEDES)
2246 .and_then(|v| v.as_str())
2247 .and_then(|s| Ulid::from_string(s).ok())
2248 .map(|u| u.0);
2249 out.push(m);
2250 cursor = next;
2251 }
2252 Ok(out)
2253 }
2254
2255 // --- Phase 12.7a: thread archive --------------------------------
2256
2257 /// Ingest a batch of turns into the archive layer of `thread_id`.
2258 ///
2259 /// Each [`ThreadTurn`] becomes one or more `Episodic` records
2260 /// tagged with the usual thread-archive reserved-metadata set
2261 /// (`_mnemo.kind = "turn"`, `_mnemo.thread`, `_mnemo.turn_id`,
2262 /// `_mnemo.seq`, `_mnemo.role`). The ingest path is
2263 /// **idempotent** per `turn_id`: a turn whose `turn_id` already
2264 /// appears on a record in this thread is skipped, so re-running
2265 /// `ingest_thread` with an extended transcript appends only
2266 /// what's new.
2267 ///
2268 /// **Chunking.** A non-tool turn whose UTF-8 content exceeds
2269 /// [`THREAD_TURN_CHUNK_BYTES`] (8 KB) is split into chunks at
2270 /// char boundaries, each stored as its own record with
2271 /// `_mnemo.part = 0, 1, …` under the same `turn_id`.
2272 ///
2273 /// **Truncation.** A `role = "tool"` turn whose content exceeds
2274 /// [`THREAD_TOOL_RESULT_TRUNCATE_BYTES`] (16 KB) is stored
2275 /// truncated — the first 16 KB of UTF-8 goes into the record,
2276 /// and `_mnemo.sha256` + `_mnemo.length` capture the original
2277 /// hash and byte length so the full content can be recovered
2278 /// (or verified) out-of-band.
2279 ///
2280 /// **Embedding.** An embedder must be registered
2281 /// ([`Mnemo::set_embedder`]); the content strings (one per
2282 /// resulting chunk / truncated record) are batched into a single
2283 /// `embed()` call.
2284 ///
2285 /// Call `self.flush()` after a successful ingest to persist.
2286 pub fn ingest_thread(&mut self, thread_id: &str, turns: &[ThreadTurn]) -> Result<IngestReport> {
2287 if self.read_only {
2288 return Err(MnemoError::ReadOnly);
2289 }
2290 if thread_id.is_empty() {
2291 return Err(MnemoError::Invalid(
2292 "ingest_thread: 'thread_id' must not be empty".into(),
2293 ));
2294 }
2295 let mut report = IngestReport::default();
2296 if turns.is_empty() {
2297 return Ok(report);
2298 }
2299
2300 // Build the set of turn_ids already in this thread so the
2301 // idempotence check is O(1) per incoming turn. First-pass
2302 // cost: one decrypt per existing thread record. A future v9
2303 // format bump can move `_mnemo.turn_id` into the catalog row
2304 // and drop this scan.
2305 let existing_turn_ids = self.collect_turn_ids_in_thread(thread_id)?;
2306 // Also figure out the next `_mnemo.seq` value so a repeat
2307 // ingest continues the sequence instead of restarting at 0.
2308 let mut next_seq = self.max_seq_in_thread(thread_id)?.map_or(0, |n| n + 1);
2309
2310 // Determine each incoming turn's final text pieces (post-
2311 // chunking / truncation) before any writes. We embed the
2312 // whole batch in a single `embed()` call so the engine side
2313 // is one round trip to the embedder regardless of turn count.
2314 struct Pending {
2315 turn_id: String,
2316 role: String,
2317 created_at: i64,
2318 parts: Vec<String>,
2319 /// Hex sha256 + original length, set only for a
2320 /// truncated tool-result turn. Applied to the single
2321 /// chunk the truncation yielded.
2322 truncation: Option<(String, u64)>,
2323 }
2324 let mut pending: Vec<Pending> = Vec::new();
2325 let mut all_parts: Vec<String> = Vec::new();
2326 let now = memory::now_secs();
2327 for t in turns {
2328 if t.turn_id.is_empty() {
2329 return Err(MnemoError::Invalid(
2330 "ingest_thread: turn_id must not be empty".into(),
2331 ));
2332 }
2333 if existing_turn_ids.contains(&t.turn_id) {
2334 report.skipped_existing_turns += 1;
2335 continue;
2336 }
2337 let content_bytes = t.content.len();
2338 let is_tool = t.role == "tool";
2339 let (parts, truncation) =
2340 if is_tool && content_bytes > THREAD_TOOL_RESULT_TRUNCATE_BYTES {
2341 report.truncated_turns += 1;
2342 let hash = {
2343 use sha2::{Digest, Sha256};
2344 let h = Sha256::digest(t.content.as_bytes());
2345 hex_encode(&h)
2346 };
2347 let truncated = take_utf8_prefix(&t.content, THREAD_TOOL_RESULT_TRUNCATE_BYTES);
2348 (vec![truncated], Some((hash, content_bytes as u64)))
2349 } else if content_bytes > THREAD_TURN_CHUNK_BYTES {
2350 report.chunked_turns += 1;
2351 (chunk_utf8(&t.content, THREAD_TURN_CHUNK_BYTES), None)
2352 } else {
2353 (vec![t.content.clone()], None)
2354 };
2355 for p in &parts {
2356 all_parts.push(p.clone());
2357 }
2358 pending.push(Pending {
2359 turn_id: t.turn_id.clone(),
2360 role: t.role.clone(),
2361 created_at: t.timestamp.unwrap_or(now),
2362 parts,
2363 truncation,
2364 });
2365 }
2366
2367 if pending.is_empty() {
2368 return Ok(report);
2369 }
2370
2371 // Batched embed — one round trip to the embedder for the
2372 // whole ingest.
2373 let embedder = self.embedder.as_ref().ok_or_else(|| {
2374 MnemoError::Invalid(
2375 "ingest_thread: no embedder registered on this handle; call set_embedder first"
2376 .into(),
2377 )
2378 })?;
2379 let as_refs: Vec<&str> = all_parts.iter().map(|s| s.as_str()).collect();
2380 let vectors = embedder.embed(&as_refs)?;
2381 if vectors.len() != all_parts.len() {
2382 return Err(MnemoError::Invalid(format!(
2383 "ingest_thread: embedder returned {} vectors for {} parts",
2384 vectors.len(),
2385 all_parts.len()
2386 )));
2387 }
2388 for v in &vectors {
2389 if v.len() != self.dimensions {
2390 return Err(MnemoError::DimensionMismatch {
2391 expected: self.dimensions,
2392 got: v.len(),
2393 });
2394 }
2395 }
2396
2397 // Write records. Order: each pending turn's chunks in part
2398 // order; each turn gets a single `_mnemo.seq` slot, so
2399 // `_mnemo.part` disambiguates chunks of the same turn.
2400 let mut vec_cursor = 0usize;
2401 for p in &pending {
2402 let this_seq = next_seq;
2403 next_seq += 1;
2404 for (part_idx, part_text) in p.parts.iter().enumerate() {
2405 let vec = vectors[vec_cursor].clone();
2406 vec_cursor += 1;
2407 let mut m =
2408 Memory::new(part_text.clone(), MemoryType::Episodic, vec).with_importance(0.5);
2409 m.created_at = p.created_at;
2410 m.accessed_at = p.created_at;
2411 m.metadata.insert(
2412 MNEMO_META_KIND.into(),
2413 Value::String(MNEMO_META_KIND_TURN.into()),
2414 );
2415 m.metadata.insert(
2416 MNEMO_META_THREAD.into(),
2417 Value::String(thread_id.to_string()),
2418 );
2419 m.metadata
2420 .insert(MNEMO_META_TURN_ID.into(), Value::String(p.turn_id.clone()));
2421 m.metadata
2422 .insert(MNEMO_META_SEQ.into(), Value::Number(this_seq.into()));
2423 m.metadata
2424 .insert(MNEMO_META_ROLE.into(), Value::String(p.role.clone()));
2425 if p.parts.len() > 1 {
2426 m.metadata.insert(
2427 MNEMO_META_PART.into(),
2428 Value::Number((part_idx as u64).into()),
2429 );
2430 }
2431 if let Some((sha, len)) = &p.truncation {
2432 m.metadata
2433 .insert(MNEMO_META_SHA256.into(), Value::String(sha.clone()));
2434 m.metadata
2435 .insert(MNEMO_META_LENGTH.into(), Value::Number((*len).into()));
2436 }
2437 self.put(m)?;
2438 report.added_records += 1;
2439 }
2440 }
2441 Ok(report)
2442 }
2443
2444 /// Phase 12.7a helper: collect every `_mnemo.turn_id` already
2445 /// stored in `thread_id`. One decrypt per record in the thread.
2446 fn collect_turn_ids_in_thread(
2447 &mut self,
2448 thread_id: &str,
2449 ) -> Result<std::collections::HashSet<String>> {
2450 let entries: Vec<CatalogEntry> = self
2451 .catalog
2452 .iter()
2453 .filter(|e| !e.deleted)
2454 .cloned()
2455 .collect();
2456 let mut out = std::collections::HashSet::new();
2457 for e in &entries {
2458 let m = self.read_memory(e)?;
2459 if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(thread_id) {
2460 continue;
2461 }
2462 if let Some(tid) = m.metadata.get(MNEMO_META_TURN_ID).and_then(|v| v.as_str()) {
2463 out.insert(tid.to_string());
2464 }
2465 }
2466 Ok(out)
2467 }
2468
2469 /// Phase 12.7a helper: find the largest `_mnemo.seq` in a thread,
2470 /// so a resumed ingest continues the sequence.
2471 fn max_seq_in_thread(&mut self, thread_id: &str) -> Result<Option<u64>> {
2472 let entries: Vec<CatalogEntry> = self
2473 .catalog
2474 .iter()
2475 .filter(|e| !e.deleted)
2476 .cloned()
2477 .collect();
2478 let mut best: Option<u64> = None;
2479 for e in &entries {
2480 let m = self.read_memory(e)?;
2481 if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(thread_id) {
2482 continue;
2483 }
2484 if let Some(n) = m.metadata.get(MNEMO_META_SEQ).and_then(|v| v.as_u64()) {
2485 best = Some(best.map_or(n, |cur| cur.max(n)));
2486 }
2487 }
2488 Ok(best)
2489 }
2490
2491 /// Export the archive layer for `thread_id` in `_mnemo.seq`
2492 /// order. Each entry is the Memory as stored; chunks of a
2493 /// multi-part turn come out in `_mnemo.part` order within their
2494 /// `_mnemo.seq`. The record `.content` is the text that was
2495 /// stored (post-chunking / post-truncation); callers that need
2496 /// the pre-truncation original should round-trip via
2497 /// `_mnemo.sha256` + `_mnemo.length`.
2498 pub fn thread_export(&mut self, thread_id: &str) -> Result<Vec<Memory>> {
2499 let entries: Vec<CatalogEntry> = self
2500 .catalog
2501 .iter()
2502 .filter(|e| !e.deleted)
2503 .cloned()
2504 .collect();
2505 let mut out: Vec<Memory> = Vec::new();
2506 for e in &entries {
2507 let m = self.read_memory(e)?;
2508 if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(thread_id) {
2509 continue;
2510 }
2511 if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
2512 != Some(MNEMO_META_KIND_TURN)
2513 {
2514 continue;
2515 }
2516 out.push(m);
2517 }
2518 out.sort_by(|a, b| {
2519 let seq_a = a
2520 .metadata
2521 .get(MNEMO_META_SEQ)
2522 .and_then(|v| v.as_u64())
2523 .unwrap_or(0);
2524 let seq_b = b
2525 .metadata
2526 .get(MNEMO_META_SEQ)
2527 .and_then(|v| v.as_u64())
2528 .unwrap_or(0);
2529 let part_a = a
2530 .metadata
2531 .get(MNEMO_META_PART)
2532 .and_then(|v| v.as_u64())
2533 .unwrap_or(0);
2534 let part_b = b
2535 .metadata
2536 .get(MNEMO_META_PART)
2537 .and_then(|v| v.as_u64())
2538 .unwrap_or(0);
2539 seq_a.cmp(&seq_b).then_with(|| part_a.cmp(&part_b))
2540 });
2541 Ok(out)
2542 }
2543
2544 /// Soft-delete every record in the archive layer of `thread_id`.
2545 /// Returns the number of records marked deleted. Permanent
2546 /// removal still requires a subsequent `compact_file`, same as
2547 /// any `Mnemo::delete`.
2548 ///
2549 /// Does **not** touch `_mnemo.kind = "essence"` records or other
2550 /// non-turn metadata the thread may carry — only `_mnemo.kind =
2551 /// "turn"` records matching the thread id.
2552 pub fn thread_forget(&mut self, thread_id: &str) -> Result<usize> {
2553 if self.read_only {
2554 return Err(MnemoError::ReadOnly);
2555 }
2556 // Collect first, then delete — can't hold catalog borrows
2557 // across `self.delete`.
2558 let entries: Vec<CatalogEntry> = self
2559 .catalog
2560 .iter()
2561 .filter(|e| !e.deleted)
2562 .cloned()
2563 .collect();
2564 let mut victims: Vec<Ulid> = Vec::new();
2565 for e in &entries {
2566 let m = self.read_memory(e)?;
2567 if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(thread_id) {
2568 continue;
2569 }
2570 if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
2571 != Some(MNEMO_META_KIND_TURN)
2572 {
2573 continue;
2574 }
2575 victims.push(m.id);
2576 }
2577 let n = victims.len();
2578 for id in &victims {
2579 self.delete(id)?;
2580 }
2581 Ok(n)
2582 }
2583
2584 // --- Phase 12.7b: essence (timeline + sections) -----------------
2585
2586 /// Phase 12.7b: add a new essence item to a section (any section
2587 /// except [`EssenceSection::Timeline`]). Content ≤
2588 /// [`memory::ESSENCE_SECTION_MAX_BYTES`] (280 bytes); items over
2589 /// that are rejected. Starting status is `"open"`.
2590 ///
2591 /// Use [`Mnemo::essence_append_timeline`] for timeline entries —
2592 /// they have a different cap (600 bytes), are append-only, and
2593 /// carry no status lifecycle.
2594 pub fn essence_add(
2595 &mut self,
2596 scope: EssenceScope,
2597 section: EssenceSection,
2598 text: &str,
2599 refs: Option<Vec<String>>,
2600 ) -> Result<Ulid> {
2601 if self.read_only {
2602 return Err(MnemoError::ReadOnly);
2603 }
2604 if section == EssenceSection::Timeline {
2605 return Err(MnemoError::Invalid(
2606 "essence_add: use essence_append_timeline for the timeline section".into(),
2607 ));
2608 }
2609 if text.is_empty() {
2610 return Err(MnemoError::Invalid(
2611 "essence_add: text must not be empty".into(),
2612 ));
2613 }
2614 if text.len() > section.item_max_bytes() {
2615 return Err(MnemoError::Invalid(format!(
2616 "essence_add: '{}' items must be <= {} bytes; got {}",
2617 section.as_str(),
2618 section.item_max_bytes(),
2619 text.len()
2620 )));
2621 }
2622 let now = memory::now_secs();
2623 let mem = self.build_essence_record(scope, section, text, refs, Some(now))?;
2624 let id = self.put(mem)?;
2625 Ok(id)
2626 }
2627
2628 /// Phase 12.7b: append a new timeline entry (append-only
2629 /// milestone). Content ≤ [`memory::ESSENCE_TIMELINE_MAX_BYTES`]
2630 /// (600 bytes); entries over that are rejected (not truncated —
2631 /// truncating a milestone silently loses the narrative).
2632 pub fn essence_append_timeline(
2633 &mut self,
2634 scope: EssenceScope,
2635 text: &str,
2636 refs: Option<Vec<String>>,
2637 ) -> Result<Ulid> {
2638 if self.read_only {
2639 return Err(MnemoError::ReadOnly);
2640 }
2641 if text.is_empty() {
2642 return Err(MnemoError::Invalid(
2643 "essence_append_timeline: text must not be empty".into(),
2644 ));
2645 }
2646 if text.len() > EssenceSection::Timeline.item_max_bytes() {
2647 return Err(MnemoError::Invalid(format!(
2648 "essence_append_timeline: entries must be <= {} bytes; got {}",
2649 EssenceSection::Timeline.item_max_bytes(),
2650 text.len()
2651 )));
2652 }
2653 let mem = self.build_essence_record(scope, EssenceSection::Timeline, text, refs, None)?;
2654 let id = self.put(mem)?;
2655 Ok(id)
2656 }
2657
2658 /// Phase 12.7b: update an existing essence section item in place.
2659 /// Returns [`WriteOutcome::Updated`] / [`WriteOutcome::Unchanged`].
2660 /// Timeline entries are append-only — calling this on a timeline
2661 /// record is an error.
2662 pub fn essence_update(
2663 &mut self,
2664 id: &Ulid,
2665 text: &str,
2666 refs: Option<Vec<String>>,
2667 ) -> Result<WriteOutcome> {
2668 if self.read_only {
2669 return Err(MnemoError::ReadOnly);
2670 }
2671 let existing = self.load_essence_record(id)?;
2672 let section = self.section_of(&existing)?;
2673 if section == EssenceSection::Timeline {
2674 return Err(MnemoError::Invalid(
2675 "essence_update: timeline entries are append-only; append a new one instead".into(),
2676 ));
2677 }
2678 if text.is_empty() {
2679 return Err(MnemoError::Invalid(
2680 "essence_update: text must not be empty".into(),
2681 ));
2682 }
2683 if text.len() > section.item_max_bytes() {
2684 return Err(MnemoError::Invalid(format!(
2685 "essence_update: '{}' items must be <= {} bytes; got {}",
2686 section.as_str(),
2687 section.item_max_bytes(),
2688 text.len()
2689 )));
2690 }
2691 let mut patch = MemoryPatch {
2692 content: Some(text.to_string()),
2693 ..Default::default()
2694 };
2695 // We don't have a usable embedder-free path for an in-place
2696 // content update, so provide the same vector as the existing
2697 // record (the text-vector drift guard in `update` will accept
2698 // this).
2699 patch.vector = Some(existing.vector.clone());
2700 if let Some(r) = refs {
2701 let mut meta = Map::new();
2702 // Preserve the user-visible metadata (none for essence —
2703 // everything lives under `_mnemo.*`) and let `update`'s
2704 // metadata-replacement path keep the reserved keys.
2705 meta.insert(
2706 MNEMO_META_REFS.into(),
2707 Value::Array(r.into_iter().map(Value::String).collect()),
2708 );
2709 patch.metadata = Some(meta);
2710 }
2711 self.update(id, patch)
2712 }
2713
2714 /// Phase 12.7b: mark a section item as resolved (open → resolved
2715 /// transition). A new history entry lands on
2716 /// `_mnemo.status_history`. Idempotent — resolving a record that
2717 /// is already resolved is a no-op.
2718 pub fn essence_resolve(&mut self, id: &Ulid) -> Result<WriteOutcome> {
2719 self.essence_transition_status(id, essence_status::RESOLVED)
2720 }
2721
2722 /// Phase 12.7b: soft-delete a section item. Same semantics as
2723 /// [`Mnemo::delete`] on any record — space reclaimed by a later
2724 /// compact.
2725 pub fn essence_remove(&mut self, id: &Ulid) -> Result<()> {
2726 if self.read_only {
2727 return Err(MnemoError::ReadOnly);
2728 }
2729 self.delete(id)
2730 }
2731
2732 /// Phase 12.7b: deterministic, budgeted markdown render of the
2733 /// essence layer for a thread (or the project layer when
2734 /// `scope = EssenceScope::Project`). `max_bytes` caps the output;
2735 /// sections are emitted in the fixed [`EssenceSection::render_order`],
2736 /// and each section's own budget is proportional to its position
2737 /// in the order. Oldest timeline entries fold into a
2738 /// `+N earlier milestones` tail when the budget runs out.
2739 ///
2740 /// The render always ends with an archive pointer so the reader
2741 /// knows how to drill in deeper (`mnemo thread export …` or
2742 /// `recall --thread …`).
2743 pub fn essence(&mut self, scope: EssenceScope, max_bytes: usize) -> Result<String> {
2744 let max_bytes = if max_bytes == 0 {
2745 ESSENCE_DEFAULT_RENDER_BYTES
2746 } else {
2747 max_bytes
2748 };
2749 // Collect every live essence record for this scope.
2750 let entries: Vec<CatalogEntry> = self
2751 .catalog
2752 .iter()
2753 .filter(|e| !e.deleted)
2754 .cloned()
2755 .collect();
2756 let mut by_section: std::collections::BTreeMap<&'static str, Vec<Memory>> =
2757 std::collections::BTreeMap::new();
2758 for section in EssenceSection::render_order() {
2759 by_section.insert(section.as_str(), Vec::new());
2760 }
2761 for e in &entries {
2762 let m = self.read_memory(e)?;
2763 if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
2764 != Some(MNEMO_META_KIND_ESSENCE)
2765 {
2766 continue;
2767 }
2768 // Scope filter. Project-scope records are shown in every
2769 // thread's render (and in the project render); thread-scope
2770 // records only match when scope == same thread.
2771 let rec_thread = m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str());
2772 let matches_scope = match (&scope, rec_thread) {
2773 (EssenceScope::Project, None) => true,
2774 (EssenceScope::Thread(_), None) => true, // project-scope always shows
2775 (EssenceScope::Thread(t), Some(rt)) => rt == t,
2776 (EssenceScope::Project, Some(_)) => false,
2777 };
2778 if !matches_scope {
2779 continue;
2780 }
2781 let section_str = m
2782 .metadata
2783 .get(MNEMO_META_SECTION)
2784 .and_then(|v| v.as_str())
2785 .unwrap_or("timeline");
2786 by_section
2787 .entry(
2788 EssenceSection::parse(section_str)
2789 .map(|s| s.as_str())
2790 .unwrap_or("timeline"),
2791 )
2792 .or_default()
2793 .push(m);
2794 }
2795
2796 // Sort each section's records:
2797 // - Decisions + Timeline: newest-first (by created_at desc).
2798 // - Everything else: sort open items before resolved, then
2799 // newest-first. Cheap, deterministic.
2800 for (name, items) in by_section.iter_mut() {
2801 let newest_first = matches!(*name, "decisions" | "timeline");
2802 items.sort_by(|a, b| {
2803 let a_resolved = status_of(a) == Some(essence_status::RESOLVED);
2804 let b_resolved = status_of(b) == Some(essence_status::RESOLVED);
2805 match (a_resolved, b_resolved) {
2806 (false, true) => std::cmp::Ordering::Less,
2807 (true, false) => std::cmp::Ordering::Greater,
2808 _ => {
2809 if newest_first {
2810 b.created_at.cmp(&a.created_at)
2811 } else {
2812 a.created_at.cmp(&b.created_at)
2813 }
2814 }
2815 }
2816 });
2817 }
2818
2819 // Per-section budget: give intent / conventions / decisions a
2820 // larger share (they frame the thread), timeline a smaller
2821 // share (it can fold). This is deterministic because it
2822 // depends only on `max_bytes`.
2823 let header_reserve = 256usize; // header + archive pointer
2824 let body_budget = max_bytes.saturating_sub(header_reserve);
2825 let per_section_budget = |section: EssenceSection| -> usize {
2826 match section {
2827 EssenceSection::Intent => body_budget / 8,
2828 EssenceSection::Conventions => body_budget / 6,
2829 EssenceSection::Decisions => body_budget / 4,
2830 EssenceSection::State => body_budget / 8,
2831 EssenceSection::Open => body_budget / 8,
2832 EssenceSection::Next => body_budget / 12,
2833 EssenceSection::Lessons => body_budget / 12,
2834 EssenceSection::Timeline => body_budget / 4,
2835 }
2836 };
2837
2838 let mut out = String::with_capacity(max_bytes);
2839 let scope_label = match &scope {
2840 EssenceScope::Thread(t) => format!("thread `{t}`"),
2841 EssenceScope::Project => "project".to_string(),
2842 };
2843 out.push_str(&format!("# essence — {scope_label}\n\n"));
2844
2845 // Phase 12.7c: continuation. If this is a thread render AND
2846 // the thread has a `_mnemo.continues` link, pull the prior
2847 // thread's open section items, its `next`, and its last-N
2848 // timeline entries in ahead of the new thread's own essence.
2849 if let EssenceScope::Thread(new_id) = &scope {
2850 if let Some(prev_id) = self.predecessor_thread(new_id)? {
2851 let continuation = self
2852 .render_continuation_prelude(&prev_id, max_bytes.saturating_sub(out.len()))?;
2853 if !continuation.is_empty() && out.len() + continuation.len() <= max_bytes {
2854 out.push_str(&continuation);
2855 }
2856 }
2857 }
2858
2859 for section in EssenceSection::render_order() {
2860 let items = match by_section.get(section.as_str()) {
2861 Some(v) if !v.is_empty() => v,
2862 _ => continue,
2863 };
2864 let budget = per_section_budget(*section);
2865 let section_header = format!("## {}\n", section.as_str());
2866 if out.len() + section_header.len() > max_bytes {
2867 break;
2868 }
2869 out.push_str(§ion_header);
2870 let mut used = 0usize;
2871 let mut rendered = 0usize;
2872 for item in items {
2873 let line = render_essence_item(*section, item);
2874 if used + line.len() > budget {
2875 break;
2876 }
2877 if out.len() + line.len() > max_bytes {
2878 break;
2879 }
2880 out.push_str(&line);
2881 used += line.len();
2882 rendered += 1;
2883 }
2884 // Fold the remainder for timeline.
2885 if *section == EssenceSection::Timeline && rendered < items.len() {
2886 let fold = format!("- +{} earlier milestones\n", items.len() - rendered);
2887 if out.len() + fold.len() <= max_bytes {
2888 out.push_str(&fold);
2889 }
2890 }
2891 if out.len() < max_bytes {
2892 out.push('\n');
2893 }
2894 }
2895
2896 // Archive pointer.
2897 let pointer = match &scope {
2898 EssenceScope::Thread(t) => format!(
2899 "> full history: `mnemo thread export <file> --thread {t}` or `recall --thread {t}`\n"
2900 ),
2901 EssenceScope::Project => String::from(
2902 "> full history: `mnemo thread export <file> --thread <id>` or `recall --thread <id>`\n",
2903 ),
2904 };
2905 if out.len() + pointer.len() <= max_bytes {
2906 out.push_str(&pointer);
2907 }
2908 Ok(out)
2909 }
2910
2911 // --- essence internal helpers ----------------------------------
2912
2913 fn build_essence_record(
2914 &self,
2915 scope: EssenceScope,
2916 section: EssenceSection,
2917 text: &str,
2918 refs: Option<Vec<String>>,
2919 status_at: Option<i64>,
2920 ) -> Result<Memory> {
2921 if let Some(e) = self.embedder.as_ref() {
2922 let mut vs = e.embed(&[text])?;
2923 let v = vs.pop().ok_or_else(|| {
2924 MnemoError::Invalid("essence: embedder returned no vector".into())
2925 })?;
2926 let mut m = Memory::new(text.to_string(), MemoryType::Semantic, v).with_importance(0.5);
2927 m.metadata.insert(
2928 MNEMO_META_KIND.into(),
2929 Value::String(MNEMO_META_KIND_ESSENCE.into()),
2930 );
2931 m.metadata.insert(
2932 MNEMO_META_SECTION.into(),
2933 Value::String(section.as_str().into()),
2934 );
2935 if let EssenceScope::Thread(t) = &scope {
2936 m.metadata
2937 .insert(MNEMO_META_THREAD.into(), Value::String(t.clone()));
2938 }
2939 if let Some(r) = refs {
2940 m.metadata.insert(
2941 MNEMO_META_REFS.into(),
2942 Value::Array(r.into_iter().map(Value::String).collect()),
2943 );
2944 }
2945 if let Some(at) = status_at {
2946 m.metadata.insert(
2947 MNEMO_META_STATUS.into(),
2948 Value::String(essence_status::OPEN.into()),
2949 );
2950 m.metadata.insert(
2951 MNEMO_META_STATUS_HISTORY.into(),
2952 Value::Array(vec![serde_json::json!({
2953 "status": essence_status::OPEN,
2954 "at": at,
2955 })]),
2956 );
2957 }
2958 Ok(m)
2959 } else {
2960 // No embedder: use an axis-neutral all-equal vector
2961 // scaled to unit magnitude. Essence records are retrieved
2962 // by `_mnemo.kind` filter, not by similarity, so the
2963 // concrete vector doesn't matter for retrieval; but we
2964 // need something non-zero to pass `put`'s guard.
2965 let dims = self.dimensions.max(1);
2966 let scale = 1.0 / (dims as f32).sqrt();
2967 let v = vec![scale; dims];
2968 let mut m = Memory::new(text.to_string(), MemoryType::Semantic, v).with_importance(0.5);
2969 m.metadata.insert(
2970 MNEMO_META_KIND.into(),
2971 Value::String(MNEMO_META_KIND_ESSENCE.into()),
2972 );
2973 m.metadata.insert(
2974 MNEMO_META_SECTION.into(),
2975 Value::String(section.as_str().into()),
2976 );
2977 if let EssenceScope::Thread(t) = &scope {
2978 m.metadata
2979 .insert(MNEMO_META_THREAD.into(), Value::String(t.clone()));
2980 }
2981 if let Some(r) = refs {
2982 m.metadata.insert(
2983 MNEMO_META_REFS.into(),
2984 Value::Array(r.into_iter().map(Value::String).collect()),
2985 );
2986 }
2987 if let Some(at) = status_at {
2988 m.metadata.insert(
2989 MNEMO_META_STATUS.into(),
2990 Value::String(essence_status::OPEN.into()),
2991 );
2992 m.metadata.insert(
2993 MNEMO_META_STATUS_HISTORY.into(),
2994 Value::Array(vec![serde_json::json!({
2995 "status": essence_status::OPEN,
2996 "at": at,
2997 })]),
2998 );
2999 }
3000 Ok(m)
3001 }
3002 }
3003
3004 fn load_essence_record(&mut self, id: &Ulid) -> Result<Memory> {
3005 let m = self.get(id)?;
3006 if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str()) != Some(MNEMO_META_KIND_ESSENCE)
3007 {
3008 return Err(MnemoError::Invalid(format!(
3009 "essence: record {id} is not an essence record (missing `_mnemo.kind = essence`)"
3010 )));
3011 }
3012 Ok(m)
3013 }
3014
3015 fn section_of(&self, m: &Memory) -> Result<EssenceSection> {
3016 let s = m
3017 .metadata
3018 .get(MNEMO_META_SECTION)
3019 .and_then(|v| v.as_str())
3020 .ok_or_else(|| {
3021 MnemoError::Invalid("essence: record missing `_mnemo.section`".into())
3022 })?;
3023 EssenceSection::parse(s)
3024 .ok_or_else(|| MnemoError::Invalid(format!("essence: unknown section '{s}' on record")))
3025 }
3026
3027 fn essence_transition_status(&mut self, id: &Ulid, to: &str) -> Result<WriteOutcome> {
3028 if self.read_only {
3029 return Err(MnemoError::ReadOnly);
3030 }
3031 let existing = self.load_essence_record(id)?;
3032 let section = self.section_of(&existing)?;
3033 if !section.has_status_lifecycle() {
3034 return Err(MnemoError::Invalid(format!(
3035 "essence_transition: '{}' section has no status lifecycle",
3036 section.as_str()
3037 )));
3038 }
3039 let from = existing
3040 .metadata
3041 .get(MNEMO_META_STATUS)
3042 .and_then(|v| v.as_str())
3043 .unwrap_or(essence_status::OPEN);
3044 if from == to {
3045 return Ok(WriteOutcome::Unchanged);
3046 }
3047 let allowed = ESSENCE_TRANSITIONS
3048 .iter()
3049 .any(|(f, t)| *f == from && *t == to);
3050 if !allowed {
3051 return Err(MnemoError::Invalid(format!(
3052 "essence_transition: '{from}' → '{to}' is not a valid transition for essence"
3053 )));
3054 }
3055 // Mutate metadata in place and `put` back under the same id
3056 // so `_mnemo.updated_at` gets stamped and access stats are
3057 // preserved.
3058 let mut new = existing.clone();
3059 new.metadata
3060 .insert(MNEMO_META_STATUS.into(), Value::String(to.to_string()));
3061 let now = memory::now_secs();
3062 let history_entry = serde_json::json!({ "status": to, "at": now });
3063 let history = new
3064 .metadata
3065 .entry(MNEMO_META_STATUS_HISTORY.to_string())
3066 .or_insert_with(|| Value::Array(Vec::new()));
3067 if let Value::Array(arr) = history {
3068 arr.push(history_entry);
3069 }
3070 new.metadata
3071 .insert(MNEMO_META_UPDATED_AT.into(), Value::Number(now.into()));
3072 new.id = existing.id;
3073 new.created_at = existing.created_at;
3074 self.put(new)?;
3075 Ok(WriteOutcome::Updated)
3076 }
3077
3078 // --- Phase 12.7c: continuation + commit_deltas ------------------
3079
3080 /// Start a new thread that continues from a previous one
3081 /// (Phase 12.7c). Writes a thread-link marker record with
3082 /// `_mnemo.kind = "thread_link"` + `_mnemo.thread = <new_id>` +
3083 /// `_mnemo.continues = <from_id>`. The marker holds no user
3084 /// content; its purpose is to let `essence(Thread(new_id))`
3085 /// discover the continuation chain.
3086 ///
3087 /// The render for the new thread pulls the previous thread's
3088 /// open section items, its `next`, and its last
3089 /// [`CONTINUATION_TIMELINE_ENTRIES`] timeline entries ahead of
3090 /// the new thread's own essence — the "new thread, same
3091 /// experience" path.
3092 pub fn thread_start(&mut self, new_thread_id: &str, from_thread_id: &str) -> Result<Ulid> {
3093 if self.read_only {
3094 return Err(MnemoError::ReadOnly);
3095 }
3096 if new_thread_id.is_empty() || from_thread_id.is_empty() {
3097 return Err(MnemoError::Invalid(
3098 "thread_start: 'new_thread_id' and 'from_thread_id' must not be empty".into(),
3099 ));
3100 }
3101 if new_thread_id == from_thread_id {
3102 return Err(MnemoError::Invalid(
3103 "thread_start: 'new_thread_id' must differ from 'from_thread_id'".into(),
3104 ));
3105 }
3106 // Store a tiny marker record so the continuation chain is
3107 // queryable from a fresh handle. Vector is the sentinel
3108 // all-equal-axis unit spike (same as the scaffold manifest
3109 // path) so `put`'s zero-vector guard accepts it.
3110 let dims = self.dimensions.max(1);
3111 let scale = 1.0 / (dims as f32).sqrt();
3112 let v = vec![scale; dims];
3113 let content = format!("thread link: {new_thread_id} continues from {from_thread_id}");
3114 let mut m = Memory::new(content, MemoryType::Semantic, v).with_importance(0.1);
3115 m.metadata.insert(
3116 MNEMO_META_KIND.into(),
3117 Value::String(MNEMO_META_KIND_THREAD_LINK.into()),
3118 );
3119 m.metadata.insert(
3120 MNEMO_META_THREAD.into(),
3121 Value::String(new_thread_id.to_string()),
3122 );
3123 m.metadata.insert(
3124 MNEMO_META_CONTINUES.into(),
3125 Value::String(from_thread_id.to_string()),
3126 );
3127 let id = self.put(m)?;
3128 Ok(id)
3129 }
3130
3131 /// Phase 12.7c helper: render the predecessor thread's open
3132 /// items + `next` + last-N timeline entries as a "continued from"
3133 /// prelude. Used by [`Mnemo::essence`] to carry forward the
3134 /// prior thread's live state into a fresh thread.
3135 fn render_continuation_prelude(
3136 &mut self,
3137 prev_thread: &str,
3138 max_bytes: usize,
3139 ) -> Result<String> {
3140 if max_bytes == 0 {
3141 return Ok(String::new());
3142 }
3143 let entries: Vec<CatalogEntry> = self
3144 .catalog
3145 .iter()
3146 .filter(|e| !e.deleted)
3147 .cloned()
3148 .collect();
3149 let mut open_items: Vec<Memory> = Vec::new();
3150 let mut next_items: Vec<Memory> = Vec::new();
3151 let mut timeline_items: Vec<Memory> = Vec::new();
3152 for e in &entries {
3153 let m = self.read_memory(e)?;
3154 if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
3155 != Some(MNEMO_META_KIND_ESSENCE)
3156 {
3157 continue;
3158 }
3159 if m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str()) != Some(prev_thread) {
3160 continue;
3161 }
3162 let section = match m
3163 .metadata
3164 .get(MNEMO_META_SECTION)
3165 .and_then(|v| v.as_str())
3166 .and_then(EssenceSection::parse)
3167 {
3168 Some(s) => s,
3169 None => continue,
3170 };
3171 let status = m
3172 .metadata
3173 .get(MNEMO_META_STATUS)
3174 .and_then(|v| v.as_str())
3175 .unwrap_or(essence_status::OPEN);
3176 match section {
3177 EssenceSection::Open if status == essence_status::OPEN => {
3178 open_items.push(m);
3179 }
3180 EssenceSection::Next if status == essence_status::OPEN => {
3181 next_items.push(m);
3182 }
3183 EssenceSection::Timeline => timeline_items.push(m),
3184 _ => {}
3185 }
3186 }
3187 // Newest-first timeline, cap to last N.
3188 timeline_items.sort_by_key(|m| std::cmp::Reverse(m.created_at));
3189 timeline_items.truncate(CONTINUATION_TIMELINE_ENTRIES);
3190 // Oldest-first for stable chronological read in the render.
3191 timeline_items.reverse();
3192
3193 let mut out = String::new();
3194 let header = format!("## continued from `{prev_thread}`\n");
3195 if header.len() > max_bytes {
3196 return Ok(String::new());
3197 }
3198 out.push_str(&header);
3199 if !open_items.is_empty() {
3200 let sub = "### open\n";
3201 if out.len() + sub.len() <= max_bytes {
3202 out.push_str(sub);
3203 }
3204 for m in &open_items {
3205 let line = format!("- {}\n", m.content);
3206 if out.len() + line.len() > max_bytes {
3207 break;
3208 }
3209 out.push_str(&line);
3210 }
3211 }
3212 if !next_items.is_empty() {
3213 let sub = "### next\n";
3214 if out.len() + sub.len() <= max_bytes {
3215 out.push_str(sub);
3216 }
3217 for m in &next_items {
3218 let line = format!("- {}\n", m.content);
3219 if out.len() + line.len() > max_bytes {
3220 break;
3221 }
3222 out.push_str(&line);
3223 }
3224 }
3225 if !timeline_items.is_empty() {
3226 let sub = "### recent milestones\n";
3227 if out.len() + sub.len() <= max_bytes {
3228 out.push_str(sub);
3229 }
3230 for m in &timeline_items {
3231 let line = format!("- {}\n", m.content);
3232 if out.len() + line.len() > max_bytes {
3233 break;
3234 }
3235 out.push_str(&line);
3236 }
3237 }
3238 if out.len() < max_bytes {
3239 out.push('\n');
3240 }
3241 Ok(out)
3242 }
3243
3244 /// Phase 12.7c helper: find the predecessor thread id for
3245 /// `thread_id` by reading its `_mnemo.kind = "thread_link"`
3246 /// marker. Returns `None` if the thread has no predecessor
3247 /// (fresh thread, no `thread_start` call).
3248 fn predecessor_thread(&mut self, thread_id: &str) -> Result<Option<String>> {
3249 let entries: Vec<CatalogEntry> = self
3250 .catalog
3251 .iter()
3252 .filter(|e| !e.deleted)
3253 .cloned()
3254 .collect();
3255 for e in &entries {
3256 let m = self.read_memory(e)?;
3257 if m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str())
3258 != Some(MNEMO_META_KIND_THREAD_LINK)
3259 {
3260 continue;
3261 }
3262 let this = m.metadata.get(MNEMO_META_THREAD).and_then(|v| v.as_str());
3263 if this != Some(thread_id) {
3264 continue;
3265 }
3266 return Ok(m
3267 .metadata
3268 .get(MNEMO_META_CONTINUES)
3269 .and_then(|v| v.as_str())
3270 .map(String::from));
3271 }
3272 Ok(None)
3273 }
3274
3275 /// Atomic batch write combining 12.1 facts, 12.7b essence ops,
3276 /// and 12.7a archive turns in one flush (Phase 12.7c).
3277 ///
3278 /// **Validation up front.** Every op in the batch is validated
3279 /// before any write runs: missing required fields in an
3280 /// [`EssenceOp`], an invalid `thread_id` for turns, a fact
3281 /// whose vector has the wrong dimensionality, a timeline entry
3282 /// over the 600-byte cap, etc. Any validation error returns
3283 /// `Err(MnemoError::Invalid)` and the file is untouched.
3284 ///
3285 /// **One flush.** On success, every sub-write runs in order
3286 /// (facts → essence ops → turns per `turn_policy`) and the
3287 /// method calls `self.flush()` exactly once on exit. On a
3288 /// failure that lands past validation (e.g. an I/O error mid-
3289 /// batch), the file reflects whatever had already been written
3290 /// — the batch is atomic at the "validation gate" but not
3291 /// crash-atomic. Callers that need crash atomicity should use
3292 /// snapshots.
3293 pub fn commit_deltas(&mut self, mut batch: CommitBatch) -> Result<CommitReport> {
3294 if self.read_only {
3295 return Err(MnemoError::ReadOnly);
3296 }
3297 // --- Validation gate (no writes yet) ----------------------
3298 // Facts: vectors must match the handle's dimensionality.
3299 for (i, f) in batch.facts.iter().enumerate() {
3300 if f.memory.vector.len() != self.dimensions {
3301 return Err(MnemoError::Invalid(format!(
3302 "commit_deltas: facts[{i}] vector has {} dims; this file is {}",
3303 f.memory.vector.len(),
3304 self.dimensions
3305 )));
3306 }
3307 if let Some(k) = &f.key {
3308 if k.is_empty() {
3309 return Err(MnemoError::Invalid(format!(
3310 "commit_deltas: facts[{i}] key is empty"
3311 )));
3312 }
3313 }
3314 }
3315 // Essence ops: per-kind field presence + text size caps.
3316 for (i, op) in batch.essence_ops.iter().enumerate() {
3317 match op.kind {
3318 EssenceOpKind::Add => {
3319 if op.scope.is_none() {
3320 return Err(MnemoError::Invalid(format!(
3321 "commit_deltas: essence_ops[{i}] Add missing 'scope'"
3322 )));
3323 }
3324 let section = op.section.ok_or_else(|| {
3325 MnemoError::Invalid(format!(
3326 "commit_deltas: essence_ops[{i}] Add missing 'section'"
3327 ))
3328 })?;
3329 if section == EssenceSection::Timeline {
3330 return Err(MnemoError::Invalid(format!(
3331 "commit_deltas: essence_ops[{i}] Add on Timeline; use AppendTimeline"
3332 )));
3333 }
3334 let text = op.text.as_deref().ok_or_else(|| {
3335 MnemoError::Invalid(format!(
3336 "commit_deltas: essence_ops[{i}] Add missing 'text'"
3337 ))
3338 })?;
3339 if text.is_empty() || text.len() > section.item_max_bytes() {
3340 return Err(MnemoError::Invalid(format!(
3341 "commit_deltas: essence_ops[{i}] Add text length invalid (0 or > {})",
3342 section.item_max_bytes()
3343 )));
3344 }
3345 }
3346 EssenceOpKind::AppendTimeline => {
3347 if op.scope.is_none() {
3348 return Err(MnemoError::Invalid(format!(
3349 "commit_deltas: essence_ops[{i}] AppendTimeline missing 'scope'"
3350 )));
3351 }
3352 let text = op.text.as_deref().ok_or_else(|| {
3353 MnemoError::Invalid(format!(
3354 "commit_deltas: essence_ops[{i}] AppendTimeline missing 'text'"
3355 ))
3356 })?;
3357 if text.is_empty() || text.len() > EssenceSection::Timeline.item_max_bytes() {
3358 return Err(MnemoError::Invalid(format!(
3359 "commit_deltas: essence_ops[{i}] AppendTimeline text length invalid (0 or > {})",
3360 EssenceSection::Timeline.item_max_bytes()
3361 )));
3362 }
3363 }
3364 EssenceOpKind::Update => {
3365 if op.id.is_none() || op.text.is_none() {
3366 return Err(MnemoError::Invalid(format!(
3367 "commit_deltas: essence_ops[{i}] Update needs 'id' and 'text'"
3368 )));
3369 }
3370 }
3371 EssenceOpKind::Resolve | EssenceOpKind::Remove => {
3372 if op.id.is_none() {
3373 return Err(MnemoError::Invalid(format!(
3374 "commit_deltas: essence_ops[{i}] needs 'id'"
3375 )));
3376 }
3377 }
3378 }
3379 }
3380 // Turns: thread_id required when non-empty.
3381 if !batch.turns.is_empty() && batch.thread_id.is_none() {
3382 return Err(MnemoError::Invalid(
3383 "commit_deltas: 'turns' non-empty but 'thread_id' missing".into(),
3384 ));
3385 }
3386
3387 // --- Apply, in planner-specified order --------------------
3388 let mut report = CommitReport::default();
3389
3390 // 1) facts
3391 for f in batch.facts.drain(..) {
3392 match f.key {
3393 Some(k) => {
3394 let (_id, outcome) = self.remember_keyed(f.memory, &k)?;
3395 match outcome {
3396 WriteOutcome::Added => report.facts_added += 1,
3397 WriteOutcome::Superseded => report.facts_superseded += 1,
3398 WriteOutcome::Unchanged => report.facts_unchanged += 1,
3399 WriteOutcome::Updated => report.facts_superseded += 1,
3400 }
3401 }
3402 None => {
3403 self.remember(f.memory)?;
3404 report.facts_added += 1;
3405 }
3406 }
3407 }
3408
3409 // 2) essence ops
3410 for op in batch.essence_ops.drain(..) {
3411 match op.kind {
3412 EssenceOpKind::Add => {
3413 self.essence_add(
3414 op.scope.expect("validated"),
3415 op.section.expect("validated"),
3416 op.text.as_deref().expect("validated"),
3417 op.refs,
3418 )?;
3419 report.essence_ops_applied += 1;
3420 }
3421 EssenceOpKind::AppendTimeline => {
3422 self.essence_append_timeline(
3423 op.scope.expect("validated"),
3424 op.text.as_deref().expect("validated"),
3425 op.refs,
3426 )?;
3427 report.essence_ops_applied += 1;
3428 }
3429 EssenceOpKind::Update => {
3430 let outcome = self.essence_update(
3431 op.id.as_ref().expect("validated"),
3432 op.text.as_deref().expect("validated"),
3433 op.refs,
3434 )?;
3435 match outcome {
3436 WriteOutcome::Updated | WriteOutcome::Added | WriteOutcome::Superseded => {
3437 report.essence_ops_applied += 1
3438 }
3439 WriteOutcome::Unchanged => report.essence_ops_unchanged += 1,
3440 }
3441 }
3442 EssenceOpKind::Resolve => {
3443 let outcome = self.essence_resolve(op.id.as_ref().expect("validated"))?;
3444 match outcome {
3445 WriteOutcome::Updated | WriteOutcome::Added | WriteOutcome::Superseded => {
3446 report.essence_ops_applied += 1
3447 }
3448 WriteOutcome::Unchanged => report.essence_ops_unchanged += 1,
3449 }
3450 }
3451 EssenceOpKind::Remove => {
3452 self.essence_remove(op.id.as_ref().expect("validated"))?;
3453 report.essence_ops_applied += 1;
3454 }
3455 }
3456 }
3457
3458 // 3) turns per turn_policy
3459 match batch.turn_policy {
3460 TurnPolicy::Archive => {
3461 if !batch.turns.is_empty() {
3462 let thread_id = batch.thread_id.as_deref().expect("validated above");
3463 let r = self.ingest_thread(thread_id, &batch.turns)?;
3464 report.turns_added += r.added_records;
3465 report.turns_skipped += r.skipped_existing_turns;
3466 report.turns_truncated += r.truncated_turns;
3467 report.turns_chunked += r.chunked_turns;
3468 }
3469 }
3470 TurnPolicy::ExpireAfter(ttl) => {
3471 if !batch.turns.is_empty() {
3472 let thread_id = batch.thread_id.as_deref().expect("validated above");
3473 // Reuse ingest, then apply TTL via `update` on
3474 // the newly-written records. Simpler than a
3475 // dedicated path — the TTL is advisory anyway
3476 // (compact + is_expired do the work).
3477 let r = self.ingest_thread(thread_id, &batch.turns)?;
3478 report.turns_added += r.added_records;
3479 report.turns_skipped += r.skipped_existing_turns;
3480 report.turns_truncated += r.truncated_turns;
3481 report.turns_chunked += r.chunked_turns;
3482 // Walk the just-ingested records and stamp TTL.
3483 // We can identify them by `(thread, seq >=
3484 // next_seq_before)`; simplest: pull the whole
3485 // thread, and for turns whose turn_id is in the
3486 // input set but wasn't already there (handled
3487 // by ingest_thread's idempotence check), set TTL.
3488 let want_ids: std::collections::HashSet<String> =
3489 batch.turns.iter().map(|t| t.turn_id.clone()).collect();
3490 let records = self.thread_export(thread_id)?;
3491 for rec in records {
3492 if let Some(tid) = rec
3493 .metadata
3494 .get(MNEMO_META_TURN_ID)
3495 .and_then(|v| v.as_str())
3496 {
3497 if want_ids.contains(tid) && rec.ttl_secs.is_none() {
3498 let _ = self.update(
3499 &rec.id,
3500 MemoryPatch {
3501 ttl_secs: Some(Some(ttl)),
3502 ..Default::default()
3503 },
3504 )?;
3505 }
3506 }
3507 }
3508 }
3509 }
3510 TurnPolicy::Drop => {
3511 // No-op: nothing goes to the archive.
3512 }
3513 }
3514
3515 self.flush()?;
3516 Ok(report)
3517 }
3518
3519 /// Return every live memory (used by tooling and compaction).
3520 pub fn memories(&mut self) -> Result<Vec<Memory>> {
3521 let entries: Vec<CatalogEntry> = self
3522 .catalog
3523 .iter()
3524 .filter(|e| !e.deleted)
3525 .cloned()
3526 .collect();
3527 let mut out = Vec::with_capacity(entries.len());
3528 for e in &entries {
3529 out.push(self.read_memory(e)?);
3530 }
3531 Ok(out)
3532 }
3533
3534 /// Return the database's **self-describing onboarding memories** — the
3535 /// ones tagged `metadata.area = "onboarding"`. This is the engine-level
3536 /// surface for the *single-file philosophy*: an agent who receives a
3537 /// `.mnemo` file (and its passphrase) can call this to learn what the
3538 /// file is, which embedder it expects, the recommended agent id, and any
3539 /// other conventions the file's author chose to record — all without
3540 /// needing any external documentation.
3541 ///
3542 /// Ordering: the canonical manifest (tag `metadata.topic = "manifest"`)
3543 /// always comes first; everything else follows in `importance` descending,
3544 /// then `created_at` ascending for deterministic results.
3545 pub fn about(&mut self) -> Result<Vec<Memory>> {
3546 // Phase 12.1: drop records that have been superseded by a newer
3547 // keyed write. They still live in snapshot history and
3548 // `history(key)` — just not here.
3549 let mut out: Vec<Memory> = self
3550 .memories()?
3551 .into_iter()
3552 .filter(|m| !m.metadata.contains_key(MNEMO_META_SUPERSEDED_BY))
3553 .filter(|m| {
3554 m.metadata
3555 .get("area")
3556 .and_then(|v| v.as_str())
3557 .map(|s| s.eq_ignore_ascii_case("onboarding"))
3558 .unwrap_or(false)
3559 })
3560 .collect();
3561 out.sort_by(|a, b| {
3562 let a_manifest = is_manifest(a);
3563 let b_manifest = is_manifest(b);
3564 // Manifest topic wins first; then importance desc; then created_at asc.
3565 b_manifest
3566 .cmp(&a_manifest)
3567 .then_with(|| b.importance.total_cmp(&a.importance))
3568 .then_with(|| a.created_at.cmp(&b.created_at))
3569 });
3570 Ok(out)
3571 }
3572
3573 // --- retrieval --------------------------------------------------------
3574
3575 /// Phase-1 brute-force search: rank live memories by raw similarity only.
3576 /// Read-only — does not update access statistics.
3577 pub fn search(
3578 &mut self,
3579 query: &[f32],
3580 top_k: usize,
3581 metric: Metric,
3582 ) -> Result<Vec<(Memory, f32)>> {
3583 if query.len() != self.dimensions {
3584 return Err(MnemoError::DimensionMismatch {
3585 expected: self.dimensions,
3586 got: query.len(),
3587 });
3588 }
3589 let now = memory::now_secs();
3590 let entries: Vec<CatalogEntry> = self
3591 .catalog
3592 .iter()
3593 .filter(|e| !e.deleted)
3594 .cloned()
3595 .collect();
3596
3597 let mut scored: Vec<(Memory, f32)> = Vec::new();
3598 for e in &entries {
3599 let m = self.read_memory(e)?;
3600 if m.is_expired(now) {
3601 continue;
3602 }
3603 let sim = memory::similarity(metric, query, &m.vector);
3604 scored.push((m, sim));
3605 }
3606 scored.sort_by(|a, b| b.1.total_cmp(&a.1));
3607 scored.truncate(top_k);
3608 Ok(scored)
3609 }
3610
3611 /// Phase-5 multi-signal recall: rank by
3612 /// `α·similarity + β·recency + γ·importance + δ·ln(freq)`, with type and
3613 /// agent filtering. Updates `accessed_at` / `access_count` on the
3614 /// returned memories (persisted on the next `flush`).
3615 ///
3616 /// When an ANN index has been built ([`Mnemo::build_index`]), recall runs
3617 /// the tiered IVF→PQ→rerank pipeline: it scores only the similarity-nearest
3618 /// `n_rerank` candidates rather than every memory. Without an index it
3619 /// scores every live memory exactly.
3620 /// One pass of filter + score for [`Mnemo::recall`]. Factored out of
3621 /// `recall` so the outer path can retry with a wider shortlist when
3622 /// the type/agent/supersession/TTL filters starve the ANN-narrowed
3623 /// result below `top_k` (Phase 12.1 short-results-on-indexed-path
3624 /// fix).
3625 ///
3626 /// * `force_exact = false` → use the ANN index when one exists; fall
3627 /// back to a full scan only if none is built.
3628 /// * `force_exact = true` → skip the ANN index and scan every live
3629 /// non-superseded record. Used by the outer path as the last-resort
3630 /// attempt when widening the shortlist would already touch half or
3631 /// more of the live non-superseded set.
3632 ///
3633 /// Returns an unsorted, un-truncated `Vec<RecallResult>`; the outer
3634 /// `recall` sorts and truncates once.
3635 fn score_once(
3636 &mut self,
3637 req: &RecallRequest,
3638 now: i64,
3639 force_exact: bool,
3640 ) -> Result<Vec<RecallResult>> {
3641 // Candidate set: ANN-narrowed when an index exists and the
3642 // caller hasn't forced an exact scan, else everything live.
3643 let entries: Vec<CatalogEntry> = match (&self.ann, force_exact) {
3644 (Some(ann), false) => {
3645 let ids = ann.query(&req.query, req.n_probe, req.n_rerank);
3646 ids.iter()
3647 .filter_map(|id| self.index.get(id).copied())
3648 .map(|i| self.catalog[i].clone())
3649 .filter(|e| !e.deleted)
3650 .collect()
3651 }
3652 _ => self
3653 .catalog
3654 .iter()
3655 .filter(|e| !e.deleted)
3656 .cloned()
3657 .collect(),
3658 };
3659
3660 let mut scored: Vec<RecallResult> = Vec::with_capacity(entries.len());
3661 for e in &entries {
3662 // Phase 12.1: drop superseded records.
3663 //
3664 // Fast path: if the lazy `superseded` set has already been
3665 // built by a prior keyed op on this handle, check it before
3666 // decrypt — O(1), no AES work. On a fresh read-only handle
3667 // it is typically `None`, so we fall through to the slow
3668 // path (one `_mnemo.superseded_by` lookup per surviving
3669 // record, O(k) over user metadata), and never trigger the
3670 // full-file scan that `ensure_key_caches()` would do.
3671 if self.superseded.as_ref().is_some_and(|s| s.contains(&e.id)) {
3672 continue;
3673 }
3674 let m = self.read_memory(e)?;
3675 if m.metadata.contains_key(MNEMO_META_SUPERSEDED_BY) {
3676 continue;
3677 }
3678 if m.is_expired(now) {
3679 continue;
3680 }
3681 if let Some(types) = &req.memory_types {
3682 if !types.contains(&m.memory_type) {
3683 continue;
3684 }
3685 }
3686 if let Some(agent) = &req.agent_id {
3687 let visible = m.agent_id == *agent || m.scope == Scope::Shared;
3688 if !visible {
3689 continue;
3690 }
3691 }
3692 // Phase 12.6: pre-scoring filter (session_id + metadata
3693 // conditions). Cheap when `req.filter.is_empty()`; the
3694 // filter struct's `matches` is a short-circuit AND.
3695 if !req.filter.is_empty() && !req.filter.matches(&m) {
3696 continue;
3697 }
3698 // Phase 12.7: default-exclude the thread layers from fact
3699 // recall: archive turns (12.7a), essence items (12.7b; read
3700 // by scope through the render, not by similarity) and
3701 // thread-link markers (12.7c; they carry an all-equal
3702 // sentinel vector and would surface as junk hits). A caller
3703 // that wants them opts in by placing any `_mnemo.*` term in
3704 // the filter (equality, `$in`, `$exists`, or a range on
3705 // `_mnemo.thread` / `_mnemo.kind` / `_mnemo.seq`).
3706 if !req.filter.mentions_mnemo_meta()
3707 && matches!(
3708 m.metadata.get(MNEMO_META_KIND).and_then(|v| v.as_str()),
3709 Some(MNEMO_META_KIND_TURN)
3710 | Some(MNEMO_META_KIND_ESSENCE)
3711 | Some(MNEMO_META_KIND_THREAD_LINK)
3712 )
3713 {
3714 continue;
3715 }
3716 let sim = memory::similarity(req.metric, &req.query, &m.vector);
3717 let age_secs = (now - m.created_at) as f32;
3718 let score = req
3719 .weights
3720 .score(sim, age_secs, m.importance, m.access_count);
3721 scored.push(RecallResult {
3722 memory: m,
3723 score,
3724 similarity: sim,
3725 });
3726 }
3727 Ok(scored)
3728 }
3729
3730 /// Phase-5 multi-signal recall under the v0.7.0 scoring-v2 formula
3731 /// (see [`crate::ScoreWeights`]). Thin wrapper over
3732 /// [`Mnemo::recall_budgeted`] that drops the `truncated` flag;
3733 /// use the budgeted form if you need to know whether
3734 /// [`RecallRequest::max_bytes`] cut anything.
3735 pub fn recall(&mut self, req: &RecallRequest) -> Result<Vec<RecallResult>> {
3736 Ok(self.recall_budgeted(req)?.hits)
3737 }
3738
3739 /// Phase 12.8 budgeted recall: scoring v2 (see [`crate::ScoreWeights`])
3740 /// with an optional UTF-8-content byte cap. Ranks by
3741 /// `sim + max(sim, 0) · (β·recency + γ·(importance − 0.5) + δ·tanh(uses/5))`,
3742 /// with type / agent / supersession / TTL filters and the 12.6
3743 /// `Filter` applied before the scorer. Updates `accessed_at` /
3744 /// `access_count` on the returned memories (persisted on the next
3745 /// `flush`).
3746 ///
3747 /// When an ANN index has been built ([`Mnemo::build_index`]),
3748 /// recall runs the tiered IVF→PQ→rerank pipeline. If the filters
3749 /// (type / agent / supersession / TTL / 12.6) reduce the ANN
3750 /// shortlist below `top_k`, Phase 12.1's short-results-on-indexed-
3751 /// path fix kicks in: the query is retried with `n_probe` and
3752 /// `n_rerank` doubled, and if that would already touch half or
3753 /// more of the live non-superseded records, the ANN pass is
3754 /// skipped for an exact scan. Without an index, recall scores
3755 /// every live non-superseded memory.
3756 ///
3757 /// Phase 12.8: after `top_k` is applied, if
3758 /// [`RecallRequest::max_bytes`] is set, hits are dropped in rank
3759 /// order once the running sum of `content` UTF-8 bytes would
3760 /// exceed the budget. The top-1 hit is always kept even if it
3761 /// alone exceeds the budget. [`RecallResponse::truncated`] is
3762 /// `true` iff at least one hit was dropped by the budget.
3763 pub fn recall_budgeted(&mut self, req: &RecallRequest) -> Result<RecallResponse> {
3764 if req.query.len() != self.dimensions {
3765 return Err(MnemoError::DimensionMismatch {
3766 expected: self.dimensions,
3767 got: req.query.len(),
3768 });
3769 }
3770 // Access-tracking bumps `accessed_at` / `access_count` in the
3771 // catalog, which is a mutation. On a read-only handle the caller
3772 // must pass `track_access(false)` (via CLI `--no-track`).
3773 if self.read_only && req.track_access {
3774 return Err(MnemoError::ReadOnly);
3775 }
3776 // Phase 12.1 **fix** (lane A v0.8.0 REVIEW): do NOT call
3777 // `ensure_key_caches()` here. A fresh read-only handle that
3778 // only ever recalls would otherwise pay a full-file decrypt
3779 // on its first query to build the lazy superseded set. Instead
3780 // `score_once` checks `_mnemo.superseded_by` per-record after
3781 // decrypt (slow path) and consults the set only when it has
3782 // already been built by a prior keyed op on this handle (fast
3783 // path). `live_non_superseded` is not knowable without a
3784 // decrypt pass either, so we approximate by `live_count`; the
3785 // exact-scan gate uses this as a conservative upper bound,
3786 // which errs toward widening (cheaper) rather than skipping
3787 // straight to a full scan.
3788 let live_count = self.catalog.iter().filter(|e| !e.deleted).count();
3789 let live_non_superseded = live_count;
3790 let now = memory::now_secs();
3791
3792 // Scoring v2 (shipped v0.7.0). Single-pass filter + score:
3793 // * `age_secs = now - m.created_at`, so a recall doesn't bump
3794 // its own results' recency on later queries.
3795 // * `ScoreWeights::score` applies the similarity-gated,
3796 // importance-centered, saturating-usage formula documented
3797 // on `ScoreWeights`.
3798 //
3799 // Phase 12.1 + 12.6 short-results-on-indexed-path fix: when the
3800 // ANN shortlist is narrower than the live count, the filters
3801 // (type / agent / supersession / TTL / 12.6) can reduce the
3802 // result below `top_k`. If that happens, re-query the index
3803 // with `n_probe` / `n_rerank` doubled from their *effective*
3804 // base; if that would already cover ≥50% of the live set,
3805 // skip the index and do an exact scan.
3806 //
3807 // Lane A v0.8.0 REVIEW fix: widen from
3808 // `IvfPqIndex::effective_defaults()` when the caller didn't
3809 // pin the knobs. The pre-fix code only doubled when `req.n_*`
3810 // was already `Some(_)`, which skipped the widen step for the
3811 // default (unpinned) recall path and jumped straight to an
3812 // exact scan on moderately selective filters.
3813 let mut scored = self.score_once(req, now, false)?;
3814 let mut used_exact_scan = self.ann.is_none();
3815 if scored.len() < req.top_k && live_non_superseded > req.top_k {
3816 if let Some(ann) = &self.ann {
3817 let (default_probe, default_rerank) = ann.effective_defaults();
3818 let base_probe = req.n_probe.unwrap_or(default_probe);
3819 let base_rerank = req.n_rerank.unwrap_or(default_rerank);
3820 let widen_probe = base_probe.saturating_mul(2);
3821 let widen_rerank = base_rerank.saturating_mul(2);
3822 // Attempt 2: widen once, 2× from the effective base.
3823 // Only worth another ANN pass if either knob actually
3824 // grew (saturating_mul on usize::MAX would cap).
3825 if widen_probe > base_probe || widen_rerank > base_rerank {
3826 let widened = RecallRequest {
3827 n_probe: Some(widen_probe),
3828 n_rerank: Some(widen_rerank),
3829 ..req.clone()
3830 };
3831 scored = self.score_once(&widened, now, false)?;
3832 }
3833 // Attempt 3: if still short after widening, pay the
3834 // exact scan — it's the only path that guarantees
3835 // `top_k` correct hits when the filter is more
3836 // selective than `widen_rerank / live_count`. Before
3837 // the fix this was gated on `widened_rerank * 2 >=
3838 // live_non_superseded`, which silently returned fewer
3839 // than `top_k` hits on selective filters that fell in
3840 // the gap. Exact scan is bounded (one decrypt per
3841 // live record); the previous widen is an optimization.
3842 if scored.len() < req.top_k {
3843 scored = self.score_once(req, now, true)?;
3844 used_exact_scan = true;
3845 }
3846 }
3847 }
3848 scored.sort_by(|a, b| b.score.total_cmp(&a.score));
3849 scored.truncate(req.top_k);
3850
3851 // Update access statistics for everything we surfaced — but only
3852 // mutate the catalog entry, not the full record on disk. Pre-v5
3853 // this loop called `self.put(r.memory.clone())`, which serialized
3854 // and rewrote every result (vector included) to fresh pages,
3855 // turning a top-K recall into K full record rewrites. Now the
3856 // catalog is the source of truth for these two fields and the
3857 // record body stays untouched until the next real edit.
3858 if req.track_access {
3859 for r in &mut scored {
3860 if let Some(&idx) = self.index.get(&r.memory.id.0) {
3861 let entry = &mut self.catalog[idx];
3862 entry.accessed_at = now;
3863 entry.access_count = entry.access_count.saturating_add(1);
3864 // Propagate the just-updated values onto the returned
3865 // Memory so the caller sees the post-recall state.
3866 r.memory.accessed_at = entry.accessed_at;
3867 r.memory.access_count = entry.access_count;
3868 }
3869 }
3870 // Mark the catalog dirty so the next flush persists these
3871 // bumps. The records themselves are not dirty.
3872 if !scored.is_empty() {
3873 self.dirty_catalog = true;
3874 }
3875 }
3876
3877 // Phase 12.8: apply the `max_bytes` budget *after* tracking has
3878 // seen the full top-k list. Access stats record what the scorer
3879 // chose; the byte budget shouldn't suppress the signal.
3880 let truncated = if let Some(budget) = req.max_bytes {
3881 let orig_len = scored.len();
3882 let mut running = 0usize;
3883 let mut cut_at: Option<usize> = None;
3884 for (i, r) in scored.iter().enumerate() {
3885 let w = r.memory.content.len();
3886 if i == 0 {
3887 // Always keep the top hit, even if it's over budget
3888 // on its own. Avoid "I asked for top_k results and
3889 // got zero" when a single memory exceeds the cap.
3890 running += w;
3891 continue;
3892 }
3893 if running.saturating_add(w) > budget {
3894 cut_at = Some(i);
3895 break;
3896 }
3897 running += w;
3898 }
3899 if let Some(idx) = cut_at {
3900 scored.truncate(idx);
3901 scored.len() < orig_len
3902 } else {
3903 false
3904 }
3905 } else {
3906 false
3907 };
3908
3909 Ok(RecallResponse {
3910 hits: scored,
3911 truncated,
3912 used_exact_scan,
3913 })
3914 }
3915
3916 // --- approximate index ------------------------------------------------
3917
3918 /// Build an IVF+PQ approximate-nearest-neighbour index over every live
3919 /// memory, using default tuning. After this, [`Mnemo::recall`] runs the
3920 /// tiered pipeline instead of an exact scan. Persisted on the next
3921 /// `flush`. Returns a snapshot of the index shape.
3922 pub fn build_index(&mut self) -> Result<IndexInfo> {
3923 self.build_index_with(IndexConfig::default())
3924 }
3925
3926 /// Build the ANN index with explicit tuning.
3927 pub fn build_index_with(&mut self, cfg: IndexConfig) -> Result<IndexInfo> {
3928 if self.read_only {
3929 return Err(MnemoError::ReadOnly);
3930 }
3931 let mems = self.memories()?;
3932 if mems.is_empty() {
3933 return Err(MnemoError::Invalid(
3934 "cannot build an index over an empty database".into(),
3935 ));
3936 }
3937 let items: Vec<(u128, &[f32])> =
3938 mems.iter().map(|m| (m.id.0, m.vector.as_slice())).collect();
3939 let idx = IvfPqIndex::build(self.dimensions, &items, cfg)?;
3940 let info = idx.info();
3941 self.ann = Some(idx);
3942 self.dirty_index = true;
3943 Ok(info)
3944 }
3945
3946 /// Rebuild the ANN index from scratch — re-clusters centroids and retrains
3947 /// the PQ codebook, undoing the cluster drift that accumulates as memories
3948 /// are inserted against fixed centroids.
3949 pub fn rebuild_index(&mut self) -> Result<IndexInfo> {
3950 // ANN-1.1: re-derive every parameter (partitions, subspaces,
3951 // n_probe, n_rerank) for the current corpus size by passing
3952 // `IndexConfig::default()`. Previously this freezed the
3953 // stored `(n_probe, n_rerank)` from the current index,
3954 // defeating the point of rebuilding on a grown corpus. If
3955 // you really want to pin specific values, call
3956 // `build_index_with(IndexConfig { … })` directly.
3957 self.build_index_with(IndexConfig::default())
3958 }
3959
3960 /// Drop the ANN index; recall reverts to exact scans. Persisted on flush.
3961 /// No-op on a read-only handle (signature is infallible — callers of
3962 /// `build_index` / `flush` on a read-only handle already get a typed
3963 /// `ReadOnly` error, which is the enforceable boundary).
3964 pub fn drop_index(&mut self) {
3965 if self.read_only {
3966 return;
3967 }
3968 if self.ann.is_some() {
3969 self.ann = None;
3970 self.dirty_index = true;
3971 }
3972 }
3973
3974 /// Whether an ANN index is currently loaded.
3975 pub fn has_index(&self) -> bool {
3976 self.ann.is_some()
3977 }
3978
3979 // --- durability & maintenance ----------------------------------------
3980
3981 /// Seal a serialized buffer into a fresh run of encrypted home pages and
3982 /// append a WAL frame for each. Returns the run's `(start_page, pages)`.
3983 /// The pages are *not* written to their home locations here — they go to
3984 /// the WAL and are folded in later by [`Mnemo::checkpoint`].
3985 fn seal_run(&mut self, bytes: &[u8], frames: &mut Vec<wal::Frame>) -> Result<(u64, u32)> {
3986 let pc = bytes.len().div_ceil(PAYLOAD).max(1);
3987 let start = self.header.next_page;
3988 self.header.next_page += pc as u64;
3989 for i in 0..pc {
3990 let lo = i * PAYLOAD;
3991 let hi = ((i + 1) * PAYLOAD).min(bytes.len());
3992 let page_no = start + i as u64;
3993 let sealed = self.pager.seal_page(page_no, &bytes[lo..hi])?;
3994 frames.push((page_no, sealed.to_vec()));
3995 }
3996 Ok((start, pc as u32))
3997 }
3998
3999 /// Grow the WAL region if `frame_pages` worth of frames would not fit.
4000 /// Called at the top of `flush`, where the WAL is always spent (every
4001 /// flush ends with a checkpoint), so relocating it cannot strand a
4002 /// committed transaction.
4003 fn ensure_wal_capacity(&mut self, frame_pages: usize) -> Result<()> {
4004 let need = wal::txn_byte_len(frame_pages, PAGE_SIZE);
4005 if need <= self.header.wal_pages * PAGE_SIZE as u64 {
4006 return Ok(());
4007 }
4008 // Allocate a new, larger region at the file tail (~1.5x headroom).
4009 let req = need.div_ceil(PAGE_SIZE as u64);
4010 let new_pages = (req + req / 2 + 4).max(DEFAULT_WAL_PAGES);
4011 let old_wal_start = self.header.wal_start;
4012 let new_start = self.header.next_page;
4013 self.header.next_page += new_pages;
4014 self.header.wal_start = new_start;
4015 self.header.wal_pages = new_pages;
4016 self.header.wal_seq += 1;
4017 // DUR-1 (R-002.1): WAL-log the header change into the *new* region
4018 // first, checkpoint page 0, then invalidate the old region. A crash
4019 // anywhere here either keeps the old state (if page 0 wasn't yet
4020 // overwritten) or recovers to the new state via the torn-header
4021 // scan in `Mnemo::open`, which prefers the highest-txn-id valid
4022 // committed header frame.
4023 self.relocate_header_through_wal(old_wal_start)
4024 }
4025
4026 /// Shared helper for every post-`create` page-0 commit: WAL-log the
4027 /// new header into the current WAL region, checkpoint page 0, and
4028 /// invalidate `old_wal_start` (the previous region, or the current
4029 /// region when it hasn't moved). The caller is expected to have
4030 /// mutated `self.header` to its target values *including a bumped
4031 /// `wal_seq`* (one more than the last on-disk `wal_seq`); this
4032 /// routine applies the seal, writes the frame + commit, folds page 0,
4033 /// then zeroes the old region's first frame header.
4034 ///
4035 /// If `old_wal_start == self.header.wal_start`, the invalidation is
4036 /// skipped — the region didn't move, so overwriting its first frame
4037 /// would destroy the just-committed one.
4038 fn relocate_header_through_wal(&mut self, old_wal_start: u64) -> Result<()> {
4039 self.header.write_counter = self.pager.write_counter;
4040 let mut page = self.header.to_page();
4041 self.header.apply_seal(&mut page, self.pager.dek())?;
4042 let txn_id = self.header.wal_seq;
4043 let frames = vec![(0u64, page.to_vec())];
4044 wal::commit(
4045 self.pager.file_mut(),
4046 self.header.wal_start,
4047 self.header.wal_pages,
4048 txn_id,
4049 &frames,
4050 )?;
4051 self.pager.write_raw(0, &page)?;
4052 self.pager.sync()?;
4053 if old_wal_start != self.header.wal_start {
4054 wal::invalidate_region(self.pager.file_mut(), old_wal_start)?;
4055 }
4056 Ok(())
4057 }
4058
4059 /// Fold a committed transaction's WAL frames into their home pages.
4060 fn checkpoint(&mut self, frames: &[wal::Frame]) -> Result<()> {
4061 for (page_no, bytes) in frames {
4062 let mut img = [0u8; PAGE_SIZE];
4063 img.copy_from_slice(bytes);
4064 self.pager.write_sealed(*page_no, &img)?;
4065 }
4066 self.pager.sync()?;
4067 Ok(())
4068 }
4069
4070 /// Persist all pending changes as one **write-ahead-logged transaction**.
4071 ///
4072 /// Sequence:
4073 ///
4074 /// 1. **Prepare.** Serialize the control plane (catalog, ANN index),
4075 /// grow the WAL if needed, and **lease** counter and page slots —
4076 /// bump `header.write_counter` and `header.next_page` by upper
4077 /// bounds on this transaction's consumption and persist that
4078 /// *leased* header to disk before any encrypted page is written.
4079 /// 2. **Data pages.** [`Pager::flush`] writes dirty record pages
4080 /// copy-on-write under fresh nonces and fsyncs them. The orphan-page
4081 /// nonce-reuse window (see [Phase 1.1 of the improvement plan])
4082 /// is closed because the leased header on disk already records a
4083 /// `write_counter` past anything this step can produce.
4084 /// 3. **Control plane.** Seal the new catalog / ANN index / snapshot
4085 /// manifest into fresh home page runs.
4086 /// 4. **Commit.** Log all of step 3's frames plus the final header
4087 /// frame into the WAL and fsync — the durability point.
4088 /// 5. **Checkpoint.** Fold the WAL into the home pages.
4089 ///
4090 /// A crash before step 4 leaves the previous state intact. A crash
4091 /// after step 4 is repaired by [`Mnemo::open`] replaying the WAL.
4092 /// Safe to call repeatedly.
4093 pub fn flush(&mut self) -> Result<()> {
4094 if self.read_only {
4095 return Err(MnemoError::ReadOnly);
4096 }
4097 // Snapshot the thread-local fsync counter. Every `sync_all` the
4098 // pager / wal / checkpoint path makes between here and the end
4099 // of this function bumps the same counter; `delta` lands on
4100 // `self.fsyncs_last_flush` so `Stats::fsyncs_last_flush` can
4101 // report it. The planner wants this to decide whether cutting
4102 // fsync count is a viable write-latency win before we touch
4103 // the write path's durability model.
4104 let fsync_before = crate::fsync_count();
4105 if !self.dirty_catalog && !self.dirty_index && !self.dirty_cache {
4106 // No control-plane changes pending. Because every `put` that
4107 // dirties a data page also dirties the catalog, and every
4108 // `cache_put` that dirties a data page also dirties
4109 // `dirty_cache`, there can't be dirty data pages either —
4110 // nothing to do.
4111 self.fsyncs_last_flush = 0;
4112 return Ok(());
4113 }
4114
4115 // FIX-1 R-001.8 part 3: snapshot the memory/index-dirty flags
4116 // before step 3b decides whether to push a restore point.
4117 // Only flushes that change the memory catalog or ANN index
4118 // need a snapshot — a cache-only flush (`dirty_cache` set,
4119 // `dirty_catalog` + `dirty_index` both false) must not push,
4120 // because snapshots don't capture cache state and every push
4121 // counts against `max_snapshots` (default 256), so a cache-
4122 // heavy workload would otherwise evict every real memory
4123 // restore point within ~256 cache writes.
4124 let is_memory_or_index_flush = self.dirty_catalog || self.dirty_index;
4125
4126 // 1. Serialize control plane, ensure WAL capacity, lease counter +
4127 // page slots, and persist the leased header *before* any data
4128 // page hits the disk.
4129 let ctx = self.prepare_for_flush()?;
4130
4131 // 2. Record (vector) data pages: copy-on-write to fresh pages, fsynced.
4132 // Safe: the lease guarantees that even if we crash here, reopen
4133 // will see a `write_counter` past every nonce this step uses.
4134 self.pager.flush()?;
4135
4136 // 3. Seal the catalog / index control pages into fresh home runs.
4137 let mut frames: Vec<wal::Frame> = Vec::new();
4138 if let Some(bytes) = &ctx.cat_bytes {
4139 let (start, pages) = self.seal_run(bytes, &mut frames)?;
4140 self.header.catalog_start = start;
4141 self.header.catalog_pages = pages as u64;
4142 self.header.catalog_len = bytes.len() as u64;
4143 }
4144 if self.dirty_index {
4145 match &ctx.idx_bytes {
4146 Some(bytes) => {
4147 let (start, pages) = self.seal_run(bytes, &mut frames)?;
4148 self.header.index_start = start;
4149 self.header.index_pages = pages as u64;
4150 self.header.index_len = bytes.len() as u64;
4151 }
4152 None => {
4153 self.header.index_start = 0;
4154 self.header.index_pages = 0;
4155 self.header.index_len = 0;
4156 }
4157 }
4158 }
4159 // v8+ result-cache directory (Phase 10.1). Seal the serialized
4160 // directory into a fresh page run and update the pointer in the
4161 // header. The seal AAD already covers cache_start/pages/len for
4162 // v8+ (see `header_seal_aad`), so tampering with the pointer
4163 // trips the header seal on the next open.
4164 if self.dirty_cache {
4165 match &ctx.cache_bytes {
4166 Some(bytes) if !self.cache_dir.is_empty() => {
4167 let (start, pages) = self.seal_run(bytes, &mut frames)?;
4168 self.header.cache_start = start;
4169 self.header.cache_pages = pages as u64;
4170 self.header.cache_len = bytes.len() as u64;
4171 }
4172 _ => {
4173 // Either the directory serialized to nothing (all
4174 // entries deleted after tombstone-only migration) or
4175 // it's empty — collapse the pointer to zero.
4176 self.header.cache_start = 0;
4177 self.header.cache_pages = 0;
4178 self.header.cache_len = 0;
4179 }
4180 }
4181 }
4182
4183 // 3b. Record this transaction as a restorable snapshot. The manifest
4184 // update is staged in a local and adopted only once the commit
4185 // below succeeds, so a failed flush leaves no phantom entry.
4186 //
4187 // The cap (`self.max_snapshots`) is applied to the staged local
4188 // before the new entry lands, so after appending the manifest
4189 // holds at most `max_snapshots` entries — the most-recent N.
4190 // `max_snapshots == 0` disables the cap. Pages referenced only
4191 // by pruned snapshots stay on disk until `compact_file`.
4192 //
4193 // FIX-1 R-001.8 part 3: skip the snapshot push on a
4194 // cache-only flush. Snapshots don't capture the cache
4195 // anyway, and every push counts against `max_snapshots`
4196 // (default 256) + rewrites the manifest run — so a
4197 // cache-heavy workload would otherwise evict every real
4198 // memory restore point within ~256 cache writes. On a
4199 // cache-only flush we reuse the current manifest
4200 // verbatim; the manifest run already lives at
4201 // `self.header.manifest_{start,pages,len}` (set by the
4202 // last memory-touching flush, or at `create` time).
4203 let live = self.len() as u64;
4204 let txn_id = self.header.wal_seq + 1;
4205 let mut manifest = self.manifest.clone();
4206 if is_memory_or_index_flush {
4207 if self.max_snapshots > 0 {
4208 // Drop the oldest entries so there's room for the new one.
4209 while manifest.len() >= self.max_snapshots {
4210 manifest.remove(0);
4211 }
4212 }
4213 manifest.push(Snapshot {
4214 txn_id,
4215 created_at: memory::now_secs(),
4216 catalog_start: self.header.catalog_start,
4217 catalog_pages: self.header.catalog_pages,
4218 catalog_len: self.header.catalog_len,
4219 index_start: self.header.index_start,
4220 index_pages: self.header.index_pages,
4221 index_len: self.header.index_len,
4222 memory_count: live,
4223 });
4224 let man_bytes =
4225 rmp_serde::to_vec(&manifest).map_err(|e| MnemoError::Serialize(e.to_string()))?;
4226 let (m_start, m_pages) = self.seal_run(&man_bytes, &mut frames)?;
4227 self.header.manifest_start = m_start;
4228 self.header.manifest_pages = m_pages as u64;
4229 self.header.manifest_len = man_bytes.len() as u64;
4230 }
4231
4232 // 3c. The header is the transaction's final frame; stamp the new id
4233 // and the *actual* (post-flush) write counter, which is <= the
4234 // leased value persisted in `prepare_for_flush`. This is the
4235 // value that ultimately replaces the leased header on checkpoint.
4236 self.header.vector_count = live;
4237 self.header.write_counter = self.pager.write_counter;
4238 self.header.wal_seq = txn_id;
4239 let mut hpage = self.header.to_page();
4240 self.header.apply_seal(&mut hpage, self.pager.dek())?;
4241 frames.push((0, hpage.to_vec()));
4242
4243 // 4. COMMIT — log the transaction and fsync. Nothing at a home page
4244 // other than the leased header has changed yet; this single
4245 // fsync is what makes the rest durable.
4246 let (wal_start, wal_pages) = (self.header.wal_start, self.header.wal_pages);
4247 wal::commit(self.pager.file_mut(), wal_start, wal_pages, txn_id, &frames)?;
4248
4249 // 5. Checkpoint — fold the WAL into the home pages.
4250 self.checkpoint(&frames)?;
4251
4252 self.manifest = manifest;
4253 self.dirty_catalog = false;
4254 self.dirty_index = false;
4255 self.dirty_cache = false;
4256 // Batched policy: successful flush consumes the pending batch.
4257 self.cache_batch.reset();
4258 // Record how many fsyncs this flush issued, for Stats reporting.
4259 // Thread-local counter guarantees we see only this handle's own
4260 // sync_all calls between the snapshot above and this read.
4261 self.fsyncs_last_flush =
4262 (crate::fsync_count().wrapping_sub(fsync_before)).min(u32::MAX as u64) as u32;
4263 Ok(())
4264 }
4265
4266 /// Pre-flush prelude shared by [`Mnemo::flush`] and the
4267 /// `__crash_partial_flush_for_testing` hook.
4268 ///
4269 /// Serializes the control plane (catalog, ANN index, cache directory)
4270 /// and grows the WAL if needed. That's it: this used to persist a
4271 /// leased header too (SEC-1 obsoleted it; see DUR-1 R-002.1 note in
4272 /// the body for the full rationale), but after DUR-1 the only page-0
4273 /// writes outside `create` go through the WAL via
4274 /// [`Mnemo::relocate_header_through_wal`].
4275 ///
4276 /// The historical pre-stamping used to close the nonce-reuse window
4277 /// flagged in Phase 1.1 of the improvement plan: a crash anywhere
4278 /// between here and the WAL commit left a file whose on-disk header
4279 /// recorded a `write_counter` past every nonce the rest of this
4280 /// transaction could produce, so the next reopen started from a
4281 /// counter that guaranteed uniqueness against any orphan data pages
4282 /// still on disk. SEC-1's per-session random nonce prefix gives the
4283 /// same guarantee without needing a persisted counter, which is why
4284 /// this routine no longer writes page 0.
4285 fn prepare_for_flush(&mut self) -> Result<FlushPrelude> {
4286 // Serialize.
4287 let cat_bytes = if self.dirty_catalog {
4288 Some(
4289 rmp_serde::to_vec(&self.catalog)
4290 .map_err(|e| MnemoError::Serialize(e.to_string()))?,
4291 )
4292 } else {
4293 None
4294 };
4295 let idx_bytes: Option<Vec<u8>> = if self.dirty_index {
4296 match &self.ann {
4297 Some(ann) => {
4298 Some(rmp_serde::to_vec(ann).map_err(|e| MnemoError::Serialize(e.to_string()))?)
4299 }
4300 None => None,
4301 }
4302 } else {
4303 None
4304 };
4305 // Serialize the result-cache directory when it's dirty. An
4306 // empty directory serializes to a small header + 0-element
4307 // array; still cheap enough that we don't special-case it.
4308 let cache_bytes: Option<Vec<u8>> = if self.dirty_cache {
4309 Some(
4310 rmp_serde::to_vec(&self.cache_dir)
4311 .map_err(|e| MnemoError::Serialize(e.to_string()))?,
4312 )
4313 } else {
4314 None
4315 };
4316
4317 let cat_pc = cat_bytes
4318 .as_ref()
4319 .map_or(0, |b| b.len().div_ceil(PAYLOAD).max(1));
4320 let idx_pc = idx_bytes
4321 .as_ref()
4322 .map_or(0, |b| b.len().div_ceil(PAYLOAD).max(1));
4323 let cache_pc = cache_bytes
4324 .as_ref()
4325 .map_or(0, |b| b.len().div_ceil(PAYLOAD).max(1));
4326 // Upper bound on the manifest run: one extra entry, <=82 bytes each
4327 // (rmp_serde encodes a Snapshot as a 9-element fixarray of i64/u64,
4328 // worst case 81 bytes plus a 1-byte array header).
4329 let man_upper = 9 + (self.manifest.len() + 1) * 82;
4330 let man_pc_est = man_upper.div_ceil(PAYLOAD).max(1);
4331
4332 // Grow WAL if needed (DUR-1: relocation is WAL-logged and does
4333 // its own fsyncs). The cache directory rides in the same
4334 // transaction so its page count contributes to the reservation.
4335 self.ensure_wal_capacity(cat_pc + idx_pc + cache_pc + man_pc_est + 1)?;
4336
4337 // DUR-1 (R-002.1): the pre-flush "leased header" write that used
4338 // to live here is gone. It existed to close the Phase 1.1 nonce
4339 // reuse window (reopen must see a `write_counter` past any nonce
4340 // this flush could produce before data pages hit disk). SEC-1
4341 // superseded that: nonces now carry a per-session random prefix
4342 // and `write_counter` is no longer a nonce input, so the leased
4343 // write bought us nothing but an extra fsync per flush. Orphan
4344 // data pages past `header.next_page` on a crashed flush are
4345 // still safe — the next session's random prefix makes any later
4346 // write at the same page_no uniquely nonced regardless of
4347 // `write_counter`.
4348
4349 Ok(FlushPrelude {
4350 cat_bytes,
4351 idx_bytes,
4352 cache_bytes,
4353 })
4354 }
4355
4356 /// Flush and close. Equivalent to `flush()` on a read-write handle; on
4357 /// a read-only handle it's a no-op (no dirty state to persist). The
4358 /// file is released on drop, which also drops the OS advisory lock.
4359 pub fn close(&mut self) -> Result<()> {
4360 if self.read_only {
4361 return Ok(());
4362 }
4363 self.flush()
4364 }
4365
4366 /// **TEST ONLY — do not use.** Reproduces a crash inside
4367 /// [`Mnemo::flush`] *after* the prelude has leased counter+page slots
4368 /// and persisted the leased header, but *before* the WAL is committed.
4369 /// Runs `prepare_for_flush` + `pager.flush` and returns — leaving
4370 /// data pages and a leased header on disk, with no commit frame.
4371 ///
4372 /// Used by `tests/integration.rs::nonce_unique_after_crashed_data_flush`
4373 /// to verify that this window (Phase 1.1 of the improvement plan) does
4374 /// not enable AES-GCM nonce reuse. Calling this in production code
4375 /// strands data pages; never expose it from a binding.
4376 #[doc(hidden)]
4377 pub fn __crash_partial_flush_for_testing(&mut self) -> Result<()> {
4378 if self.read_only {
4379 return Err(MnemoError::ReadOnly);
4380 }
4381 if !self.dirty_catalog && !self.dirty_index {
4382 return Ok(());
4383 }
4384 let _prelude = self.prepare_for_flush()?;
4385 self.pager.flush()
4386 }
4387
4388 /// Bound the in-memory page cache to `pages` decrypted pages.
4389 ///
4390 /// The cache holds decrypted page payloads to speed repeated reads. By
4391 /// default it is capped at 8192 pages (~64 MiB); lower the cap to trade
4392 /// hit rate for a smaller footprint, or raise it for a hotter cache. The
4393 /// cap governs *clean* pages — pages with un-flushed writes are always
4394 /// retained until [`Mnemo::flush`], regardless of the cap.
4395 pub fn set_cache_capacity(&mut self, pages: usize) {
4396 self.pager.set_cache_capacity(pages);
4397 }
4398
4399 /// Page-cache occupancy: `(pages_cached, capacity)`. Renamed from
4400 /// `cache_stats` in v0.4.0: the result-cache API (Phase 10.1) took
4401 /// that name for its per-namespace [`CacheStats`] summary, and the
4402 /// page-cache pair is the older/less-user-facing of the two, so it
4403 /// gets the more-specific name.
4404 pub fn page_cache_stats(&self) -> (usize, usize) {
4405 self.pager.cache_stats()
4406 }
4407
4408 // === Result cache (Phase 10.1 + 10.3) ============================
4409
4410 /// Insert or overwrite a cache entry at `(namespace, key)`.
4411 ///
4412 /// The engine SHA-256-hashes `key` — callers pass the raw input
4413 /// string. Persists to disk on the next `flush()` under the current
4414 /// flush policy (see [`Mnemo::set_cache_flush_policy`]); in
4415 /// [`CacheFlushPolicy::Batched`] the engine may call `flush()`
4416 /// internally when a threshold trips.
4417 ///
4418 /// On overflow of the namespace's [`CacheBudget`] (default 10,000
4419 /// entries / 64 MiB) the LRU entry is tombstoned before the new
4420 /// insert. Tombstones are reclaimed by
4421 /// [`Mnemo::compact_file`], not the runtime flush.
4422 ///
4423 /// Returns [`MnemoError::ReadOnly`] on a read-only handle.
4424 pub fn cache_put(
4425 &mut self,
4426 namespace: &str,
4427 key: &str,
4428 value: &[u8],
4429 opts: CachePutOpts,
4430 ) -> Result<()> {
4431 if self.read_only {
4432 return Err(MnemoError::ReadOnly);
4433 }
4434 let key_hash = result_cache::hash_key(key);
4435 let key_preview = result_cache::key_preview(key);
4436 let now = memory::now_secs();
4437 let size_bytes = value.len() as u64;
4438
4439 let entry = CacheEntry {
4440 namespace: namespace.to_string(),
4441 key_hash,
4442 key_preview: key_preview.clone(),
4443 value: value.to_vec(),
4444 content_type: opts.content_type.clone(),
4445 created_at: now,
4446 accessed_at: now,
4447 access_count: 0,
4448 ttl_secs: opts.ttl_secs,
4449 cost_hint_ms: opts.cost_hint_ms,
4450 size_bytes,
4451 vector: None,
4452 model: None,
4453 };
4454 let bytes = rmp_serde::to_vec(&entry).map_err(|e| MnemoError::Serialize(e.to_string()))?;
4455 let (start_page, page_count) = self.write_record(&bytes)?;
4456
4457 // Tombstone any prior entry for this key. Its pages remain on
4458 // disk until `compact_file`, exactly like memory-record
4459 // overwrites — the tombstone just hides it from lookups.
4460 if let Some(prev_pos) = self.cache_idx.get(namespace, &key_hash) {
4461 self.cache_dir[prev_pos].deleted = true;
4462 self.cache_idx.remove(namespace, &key_hash);
4463 }
4464
4465 let dir_entry = CacheDirectoryEntry {
4466 namespace: namespace.to_string(),
4467 key_hash,
4468 start_page,
4469 page_count,
4470 len: bytes.len() as u32,
4471 deleted: false,
4472 accessed_at: now,
4473 access_count: 0,
4474 size_bytes,
4475 ttl_secs: opts.ttl_secs,
4476 created_at: now,
4477 vector: None,
4478 model: None,
4479 };
4480 let pos = self.cache_dir.len();
4481 self.cache_dir.push(dir_entry);
4482 self.cache_idx.insert(namespace, key_hash, pos);
4483
4484 // Enforce budget — evict LRU tombstones until the namespace
4485 // fits under both caps.
4486 self.evict_to_budget(namespace)?;
4487
4488 self.dirty_cache = true;
4489 self.cache_batch.record_dirty();
4490
4491 // Batched policy: auto-flush if a threshold tripped. Successful
4492 // `flush()` calls `cache_batch.reset()` for us.
4493 if self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
4494 self.flush()?;
4495 }
4496 Ok(())
4497 }
4498
4499 /// Look up `(namespace, key)`. Returns `None` on miss (either the
4500 /// key was never inserted or the entry is TTL-expired). On hit
4501 /// updates catalog-side access stats (mirrors the recall v5-trick);
4502 /// on a read-only handle the stats update is silently skipped.
4503 ///
4504 /// A miss is recorded as a miss in this handle's counters even if
4505 /// the key was TTL-expired — from the caller's perspective the two
4506 /// are indistinguishable.
4507 pub fn cache_get(&mut self, namespace: &str, key: &str) -> Result<Option<CachedValue>> {
4508 // FIX-1 R-001.8 part 4: under `Batched`, honor `max_age` on
4509 // reads too. Pre-FIX-1 the age check only fired on `cache_put`,
4510 // so a workload that wrote a few entries and then switched to
4511 // pure reads could sit with its pending batch past the
4512 // nominal max_age indefinitely. `max_dirty` is already
4513 // maintained per-write; this adds the symmetric time-bound
4514 // check on the read path. Only runs on read-write handles —
4515 // flush() returns ReadOnly for shared-lock readers.
4516 if !self.read_only && self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
4517 self.flush()?;
4518 }
4519 let key_hash = result_cache::hash_key(key);
4520 let pos = match self.cache_idx.get(namespace, &key_hash) {
4521 Some(p) => p,
4522 None => {
4523 self.cache_counters.misses += 1;
4524 return Ok(None);
4525 }
4526 };
4527 let now = memory::now_secs();
4528 // Snapshot the directory entry fields we need before any
4529 // mutation — the borrow checker gets grumpy otherwise.
4530 let (start_page, page_count, len, ttl_secs, expired, created_at) = {
4531 let e = &self.cache_dir[pos];
4532 (
4533 e.start_page,
4534 e.page_count,
4535 e.len,
4536 e.ttl_secs,
4537 e.is_expired(now),
4538 e.created_at,
4539 )
4540 };
4541 if expired {
4542 // Treat expired as a miss for the caller. Don't tombstone
4543 // here — cache_purge does that. Just skip.
4544 self.cache_counters.misses += 1;
4545 return Ok(None);
4546 }
4547 // Decrypt and decode the record body.
4548 let mut buf = Vec::with_capacity(len as usize);
4549 for i in 0..page_count as u64 {
4550 buf.extend_from_slice(&self.pager.read_page(start_page + i)?);
4551 }
4552 buf.truncate(len as usize);
4553 let entry: CacheEntry =
4554 rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
4555
4556 // Bump access stats catalog-only (v5 trick — no full record
4557 // rewrite on a hit). Skipped on read-only handles so a shared-lock
4558 // reader can't dirty the catalog.
4559 if !self.read_only {
4560 self.cache_dir[pos].accessed_at = now;
4561 self.cache_dir[pos].access_count = self.cache_dir[pos].access_count.saturating_add(1);
4562 self.dirty_cache = true;
4563 }
4564 self.cache_counters.hits += 1;
4565 // On a mutating handle: access_count reflects the just-bumped
4566 // value. On a read-only handle: the on-disk directory value is
4567 // unchanged, so returning it verbatim is honest (the caller's
4568 // reads did not, and cannot, alter the persisted counter).
4569 //
4570 // FIX-1 R-002.2 part 1: `accessed_at` must also come from the
4571 // directory on a read-only handle, not from `entry.accessed_at`
4572 // in the record body. The record body is written once at insert
4573 // time and never updated on a hit — every live `accessed_at`
4574 // update lands in the directory (see the `!self.read_only`
4575 // branch above). The pre-FIX-1 code returned
4576 // `entry.accessed_at`, which is really the entry's **creation
4577 // time** by then, so a read-only reader saw "last accessed =
4578 // when it was inserted" even after many successful hits by
4579 // concurrent writers.
4580 let access_count = self.cache_dir[pos].access_count;
4581 let accessed_at = if self.read_only {
4582 self.cache_dir[pos].accessed_at
4583 } else {
4584 now
4585 };
4586 Ok(Some(CachedValue {
4587 value: entry.value,
4588 content_type: entry.content_type,
4589 created_at,
4590 accessed_at,
4591 access_count,
4592 ttl_secs,
4593 }))
4594 }
4595
4596 /// Tombstone the entry at `(namespace, key)`. Silently succeeds if
4597 /// no such entry exists. Space is reclaimed by `compact_file`.
4598 pub fn cache_delete(&mut self, namespace: &str, key: &str) -> Result<()> {
4599 if self.read_only {
4600 return Err(MnemoError::ReadOnly);
4601 }
4602 let key_hash = result_cache::hash_key(key);
4603 if let Some(pos) = self.cache_idx.get(namespace, &key_hash) {
4604 self.cache_dir[pos].deleted = true;
4605 self.cache_idx.remove(namespace, &key_hash);
4606 self.dirty_cache = true;
4607 self.cache_batch.record_dirty();
4608 if self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
4609 self.flush()?;
4610 }
4611 }
4612 Ok(())
4613 }
4614
4615 /// Purge cache entries. If `namespace` is `Some`, restrict to that
4616 /// namespace; otherwise purge across the whole cache. If
4617 /// `expired_only` is `true`, only TTL-expired entries are removed;
4618 /// otherwise every live entry in the target scope is tombstoned.
4619 /// Returns the number of entries newly tombstoned.
4620 pub fn cache_purge(&mut self, namespace: Option<&str>, expired_only: bool) -> Result<usize> {
4621 if self.read_only {
4622 return Err(MnemoError::ReadOnly);
4623 }
4624 let now = memory::now_secs();
4625 let mut purged = 0usize;
4626 // Iterate the directory by index; mark matching entries as
4627 // deleted and pull them out of the in-memory index.
4628 for pos in 0..self.cache_dir.len() {
4629 if self.cache_dir[pos].deleted {
4630 continue;
4631 }
4632 if let Some(ns) = namespace {
4633 if self.cache_dir[pos].namespace != ns {
4634 continue;
4635 }
4636 }
4637 if expired_only && !self.cache_dir[pos].is_expired(now) {
4638 continue;
4639 }
4640 let key_hash = self.cache_dir[pos].key_hash;
4641 let ns = self.cache_dir[pos].namespace.clone();
4642 self.cache_dir[pos].deleted = true;
4643 self.cache_idx.remove(&ns, &key_hash);
4644 purged += 1;
4645 }
4646 if purged > 0 {
4647 self.dirty_cache = true;
4648 self.cache_batch.record_dirty();
4649 if self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
4650 self.flush()?;
4651 }
4652 }
4653 Ok(purged)
4654 }
4655
4656 /// Statistics for `namespace` (or the whole cache if `None`).
4657 /// `hits`/`misses`/`evictions` are per-handle in-memory counters
4658 /// that reset on reopen; `entries`/`bytes` reflect the durable
4659 /// on-disk state (live entries only, tombstones excluded).
4660 pub fn cache_stats(&self, namespace: Option<&str>) -> CacheStats {
4661 let mut entries = 0usize;
4662 let mut bytes = 0u64;
4663 for e in &self.cache_dir {
4664 if e.deleted {
4665 continue;
4666 }
4667 if let Some(ns) = namespace {
4668 if e.namespace != ns {
4669 continue;
4670 }
4671 }
4672 entries += 1;
4673 bytes += e.size_bytes;
4674 }
4675 let hits = self.cache_counters.hits;
4676 let misses = self.cache_counters.misses;
4677 let total = hits + misses;
4678 let hit_rate = if total > 0 {
4679 hits as f64 / total as f64
4680 } else {
4681 0.0
4682 };
4683 CacheStats {
4684 entries,
4685 bytes,
4686 hits,
4687 misses,
4688 hit_rate,
4689 evictions: self.cache_counters.evictions,
4690 }
4691 }
4692
4693 /// Set (or update) the eviction budget for `namespace`. Applies to
4694 /// subsequent `cache_put` calls; does not retroactively evict
4695 /// entries that already sit under a looser cap.
4696 pub fn set_cache_budget(&mut self, namespace: &str, budget: CacheBudget) {
4697 self.cache_budgets.insert(namespace.to_string(), budget);
4698 }
4699
4700 /// Set the per-handle cache flush policy. Defaults to
4701 /// [`CacheFlushPolicy::Strict`]. Memory writes are never batched;
4702 /// this affects only cache mutations.
4703 pub fn set_cache_flush_policy(&mut self, policy: CacheFlushPolicy) {
4704 self.cache_flush_policy = policy;
4705 // Switching mid-run: if we're moving to Strict and the batch is
4706 // non-empty, the pending count still counts toward dirty_cache
4707 // which the next flush() will consume.
4708 }
4709
4710 /// Evict LRU live entries in `namespace` until it fits under both
4711 /// budget caps. Called from `cache_put` after the insert; the new
4712 /// entry is included in the count so we always leave room.
4713 fn evict_to_budget(&mut self, namespace: &str) -> Result<()> {
4714 let budget = self
4715 .cache_budgets
4716 .get(namespace)
4717 .copied()
4718 .unwrap_or_default();
4719 loop {
4720 // Sum current live count + bytes in the namespace.
4721 let mut live_count = 0usize;
4722 let mut live_bytes = 0u64;
4723 for e in &self.cache_dir {
4724 if !e.deleted && e.namespace == namespace {
4725 live_count += 1;
4726 live_bytes += e.size_bytes;
4727 }
4728 }
4729 if live_count <= budget.max_entries && live_bytes <= budget.max_bytes {
4730 return Ok(());
4731 }
4732 // Find the LRU (oldest accessed_at) live entry.
4733 let victim_pos = self
4734 .cache_dir
4735 .iter()
4736 .enumerate()
4737 .filter(|(_, e)| !e.deleted && e.namespace == namespace)
4738 .min_by_key(|(_, e)| e.accessed_at)
4739 .map(|(i, _)| i);
4740 let Some(pos) = victim_pos else {
4741 // Nothing to evict but still over budget — the single
4742 // just-inserted entry itself is bigger than
4743 // max_bytes. Nothing we can safely do here without
4744 // rejecting the put; leave it and return.
4745 return Ok(());
4746 };
4747 let key_hash = self.cache_dir[pos].key_hash;
4748 let ns = self.cache_dir[pos].namespace.clone();
4749 self.cache_dir[pos].deleted = true;
4750 self.cache_idx.remove(&ns, &key_hash);
4751 self.cache_counters.evictions += 1;
4752 }
4753 }
4754
4755 // === Semantic cache (Phase 10.2) ==================================
4756
4757 /// Insert or overwrite a **semantic** cache entry — same as
4758 /// [`Mnemo::cache_put`] but with a required embedding vector and
4759 /// model identifier. The vector is stored on the directory entry
4760 /// so [`Mnemo::cache_get_semantic`] can score without decrypting
4761 /// record bodies.
4762 ///
4763 /// Vector dimensionality is validated against the database's
4764 /// configured `dimensions` — a mismatch returns
4765 /// [`MnemoError::DimensionMismatch`], the same signal `remember`
4766 /// gives for the memory catalog.
4767 ///
4768 /// The `model` string is matched **exactly** on lookup — a
4769 /// different model's cache is invisible to a given query. Pick
4770 /// canonical identifiers (`"text-embedding-3-small"`,
4771 /// `"bge-large-en-v1.5"`, ...) and stick to them.
4772 pub fn cache_put_semantic(
4773 &mut self,
4774 namespace: &str,
4775 key: &str,
4776 vector: Vec<f32>,
4777 value: &[u8],
4778 opts: SemanticCachePutOpts,
4779 ) -> Result<()> {
4780 if self.read_only {
4781 return Err(MnemoError::ReadOnly);
4782 }
4783 if vector.len() != self.dimensions {
4784 return Err(MnemoError::DimensionMismatch {
4785 expected: self.dimensions,
4786 got: vector.len(),
4787 });
4788 }
4789 let key_hash = result_cache::hash_key(key);
4790 let key_preview = result_cache::key_preview(key);
4791 let now = memory::now_secs();
4792 let size_bytes = value.len() as u64;
4793
4794 let entry = CacheEntry {
4795 namespace: namespace.to_string(),
4796 key_hash,
4797 key_preview: key_preview.clone(),
4798 value: value.to_vec(),
4799 content_type: opts.content_type.clone(),
4800 created_at: now,
4801 accessed_at: now,
4802 access_count: 0,
4803 ttl_secs: opts.ttl_secs,
4804 cost_hint_ms: opts.cost_hint_ms,
4805 size_bytes,
4806 vector: Some(vector.clone()),
4807 model: Some(opts.model.clone()),
4808 };
4809 let bytes = rmp_serde::to_vec(&entry).map_err(|e| MnemoError::Serialize(e.to_string()))?;
4810 let (start_page, page_count) = self.write_record(&bytes)?;
4811
4812 if let Some(prev_pos) = self.cache_idx.get(namespace, &key_hash) {
4813 self.cache_dir[prev_pos].deleted = true;
4814 self.cache_idx.remove(namespace, &key_hash);
4815 }
4816
4817 let dir_entry = CacheDirectoryEntry {
4818 namespace: namespace.to_string(),
4819 key_hash,
4820 start_page,
4821 page_count,
4822 len: bytes.len() as u32,
4823 deleted: false,
4824 accessed_at: now,
4825 access_count: 0,
4826 size_bytes,
4827 ttl_secs: opts.ttl_secs,
4828 created_at: now,
4829 vector: Some(vector),
4830 model: Some(opts.model),
4831 };
4832 let pos = self.cache_dir.len();
4833 self.cache_dir.push(dir_entry);
4834 self.cache_idx.insert(namespace, key_hash, pos);
4835
4836 self.evict_to_budget(namespace)?;
4837
4838 self.dirty_cache = true;
4839 self.cache_batch.record_dirty();
4840 if self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
4841 self.flush()?;
4842 }
4843 Ok(())
4844 }
4845
4846 /// Semantic recall over the cache: score every live entry in
4847 /// `namespace` whose `model` matches against `query_vector` using
4848 /// cosine similarity; return the top-1 hit and its similarity iff
4849 /// `sim >= threshold`. `None` otherwise.
4850 ///
4851 /// Uses [`crate::result_cache::DEFAULT_SEMANTIC_THRESHOLD`] if you
4852 /// want a sensible default — pass it explicitly (`0.97` today).
4853 ///
4854 /// Exact-key entries (those without a vector) are transparently
4855 /// skipped, as are entries whose model doesn't match — a
4856 /// different-model hit would be a semantic bug, not a win.
4857 /// TTL-expired entries are also skipped.
4858 ///
4859 /// Access-stat updates on hit follow the same policy as
4860 /// [`Mnemo::cache_get`]: bumped on read-write handles, silently
4861 /// skipped on read-only. Hits and misses are recorded in the
4862 /// per-handle counters just like exact-key lookups.
4863 pub fn cache_get_semantic(
4864 &mut self,
4865 namespace: &str,
4866 query_vector: &[f32],
4867 threshold: f32,
4868 model: &str,
4869 ) -> Result<Option<(CachedValue, f32)>> {
4870 if query_vector.len() != self.dimensions {
4871 return Err(MnemoError::DimensionMismatch {
4872 expected: self.dimensions,
4873 got: query_vector.len(),
4874 });
4875 }
4876 // FIX-1 R-001.8 part 4: same Batched max_age check as
4877 // `cache_get`. See the comment there.
4878 if !self.read_only && self.cache_batch.should_auto_flush(&self.cache_flush_policy) {
4879 self.flush()?;
4880 }
4881 let now = memory::now_secs();
4882
4883 // Scan namespace live entries, filter by model, compute
4884 // cosine, keep the best. Exact-key entries have `vector: None`
4885 // and are skipped by the filter.
4886 let mut best: Option<(usize, f32)> = None;
4887 for (i, e) in self.cache_dir.iter().enumerate() {
4888 if e.deleted || e.namespace != namespace || e.is_expired(now) {
4889 continue;
4890 }
4891 let Some(ref v) = e.vector else { continue };
4892 let Some(ref m) = e.model else { continue };
4893 if m != model {
4894 continue;
4895 }
4896 let sim = memory::cosine(query_vector, v);
4897 if best.map(|(_, s)| sim > s).unwrap_or(true) {
4898 best = Some((i, sim));
4899 }
4900 }
4901
4902 let Some((pos, sim)) = best else {
4903 self.cache_counters.misses += 1;
4904 return Ok(None);
4905 };
4906 if sim < threshold {
4907 self.cache_counters.misses += 1;
4908 return Ok(None);
4909 }
4910
4911 // Extract fields before any mutation to keep the borrow checker
4912 // happy.
4913 let (start_page, page_count, len, ttl_secs, created_at) = {
4914 let e = &self.cache_dir[pos];
4915 (e.start_page, e.page_count, e.len, e.ttl_secs, e.created_at)
4916 };
4917
4918 // Decrypt and decode the record body.
4919 let mut buf = Vec::with_capacity(len as usize);
4920 for i in 0..page_count as u64 {
4921 buf.extend_from_slice(&self.pager.read_page(start_page + i)?);
4922 }
4923 buf.truncate(len as usize);
4924 let entry: CacheEntry =
4925 rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
4926
4927 if !self.read_only {
4928 self.cache_dir[pos].accessed_at = now;
4929 self.cache_dir[pos].access_count = self.cache_dir[pos].access_count.saturating_add(1);
4930 self.dirty_cache = true;
4931 }
4932 self.cache_counters.hits += 1;
4933 let access_count = self.cache_dir[pos].access_count;
4934 // FIX-1 R-002.2 part 1: same read-only `accessed_at` fix as
4935 // `cache_get`. The directory carries the live value; the record
4936 // body only holds the insert-time snapshot.
4937 let accessed_at = if self.read_only {
4938 self.cache_dir[pos].accessed_at
4939 } else {
4940 now
4941 };
4942 Ok(Some((
4943 CachedValue {
4944 value: entry.value,
4945 content_type: entry.content_type,
4946 created_at,
4947 accessed_at,
4948 access_count,
4949 ttl_secs,
4950 },
4951 sim,
4952 )))
4953 }
4954
4955 /// Begin a conversation [`Session`](crate::Session) for `agent_id`.
4956 ///
4957 /// The session borrows the database for its lifetime, records turns as
4958 /// working memory, and consolidates them into episodic memory when closed.
4959 pub fn session(&mut self, agent_id: impl Into<String>) -> crate::session::Session<'_> {
4960 crate::session::Session::new(self, agent_id.into())
4961 }
4962
4963 // --- snapshots & point-in-time recovery ------------------------------
4964
4965 /// Every committed transaction, oldest first — the restore points
4966 /// available to [`Mnemo::restore_to`] and [`Mnemo::restore_to_time`].
4967 ///
4968 /// Each `flush` appends one snapshot. Because the storage engine is
4969 /// append-only, the pages a past flush wrote are still on disk, so any
4970 /// listed snapshot can be reinstated exactly. The history reaches back to
4971 /// the last [`Mnemo::compact_file`], which reclaims space by collapsing
4972 /// it.
4973 pub fn snapshots(&self) -> Vec<SnapshotInfo> {
4974 let mut v: Vec<SnapshotInfo> = self
4975 .manifest
4976 .iter()
4977 .map(|s| SnapshotInfo {
4978 txn_id: s.txn_id,
4979 created_at: s.created_at,
4980 memory_count: s.memory_count,
4981 })
4982 .collect();
4983 v.sort_by_key(|s| s.txn_id);
4984 v
4985 }
4986
4987 /// Load a past snapshot's state and commit it as a new transaction.
4988 fn apply_snapshot(&mut self, snap: &Snapshot) -> Result<()> {
4989 let catalog = load_catalog(
4990 &mut self.pager,
4991 snap.catalog_start,
4992 snap.catalog_pages,
4993 snap.catalog_len,
4994 )?;
4995 let ann = load_index(
4996 &mut self.pager,
4997 snap.index_start,
4998 snap.index_pages,
4999 snap.index_len,
5000 self.dimensions,
5001 )?;
5002 self.index = build_id_index(&catalog);
5003 self.catalog = catalog;
5004 self.ann = ann;
5005 // Re-commit as a fresh transaction: crash-safe, and itself recorded
5006 // as a new snapshot so a restore can always be undone.
5007 self.dirty_catalog = true;
5008 self.dirty_index = true;
5009 self.flush()
5010 }
5011
5012 /// Restore the database to the snapshot produced by transaction `txn_id`.
5013 ///
5014 /// The restore is itself a new committed transaction (and a new
5015 /// snapshot), so it is crash-safe and reversible — restoring forward to a
5016 /// later snapshot afterwards works just as well.
5017 pub fn restore_to(&mut self, txn_id: u64) -> Result<SnapshotInfo> {
5018 if self.read_only {
5019 return Err(MnemoError::ReadOnly);
5020 }
5021 let snap = self
5022 .manifest
5023 .iter()
5024 .find(|s| s.txn_id == txn_id)
5025 .cloned()
5026 .ok_or_else(|| MnemoError::NotFound(format!("snapshot for transaction {txn_id}")))?;
5027 self.apply_snapshot(&snap)?;
5028 Ok(SnapshotInfo {
5029 txn_id: snap.txn_id,
5030 created_at: snap.created_at,
5031 memory_count: snap.memory_count,
5032 })
5033 }
5034
5035 /// Restore the database to the latest snapshot committed at or before
5036 /// `unix_secs`. Returns [`MnemoError::NotFound`] if no snapshot is that
5037 /// old. Like [`Mnemo::restore_to`], the restore is a new transaction.
5038 pub fn restore_to_time(&mut self, unix_secs: i64) -> Result<SnapshotInfo> {
5039 if self.read_only {
5040 return Err(MnemoError::ReadOnly);
5041 }
5042 let snap = self
5043 .manifest
5044 .iter()
5045 .filter(|s| s.created_at <= unix_secs)
5046 .max_by_key(|s| s.txn_id)
5047 .cloned()
5048 .ok_or_else(|| {
5049 MnemoError::NotFound(format!("no snapshot at or before time {unix_secs}"))
5050 })?;
5051 self.apply_snapshot(&snap)?;
5052 Ok(SnapshotInfo {
5053 txn_id: snap.txn_id,
5054 created_at: snap.created_at,
5055 memory_count: snap.memory_count,
5056 })
5057 }
5058
5059 /// Return the KDF (Argon2id) parameters this file currently uses to
5060 /// wrap its DEK. Needed by callers that want to [`rekey`] while
5061 /// keeping the file's original protection level (e.g. the Python
5062 /// binding's `rekey(fast=False)`, whose docstring promises exactly
5063 /// that). FIX-1 R-001.5.
5064 pub fn kdf_params(&self) -> KdfParams {
5065 self.kdf
5066 }
5067
5068 /// Flush any pending writes, then stream the (encrypted) file to
5069 /// `dest` atomically: writes to a sibling temp file in `dest`'s
5070 /// parent directory, `sync_all`s it, renames onto `dest`, and
5071 /// fsyncs the parent directory on Unix so a crash never leaves a
5072 /// partial export.
5073 ///
5074 /// Refuses when `dest` resolves to the file this handle has open
5075 /// — that case would otherwise `File::create`-truncate the live
5076 /// database to 0 bytes (`fs::copy` on Unix reads *after* the
5077 /// `O_TRUNC`, so a naive export-to-self destroys the source).
5078 /// Unix: compares `(dev, ino)` of both paths via `MetadataExt`,
5079 /// catching symlinks and hard links. Windows: compares
5080 /// canonicalized paths; a rename onto an open file also fails on
5081 /// Windows because of the mandatory lock the handle holds, so the
5082 /// rename step is a second line of defense.
5083 ///
5084 /// Reads the bytes from the already-open file handle, not by
5085 /// `fs::copy`. On Windows that matters: `LockFileEx` locks are
5086 /// mandatory, so a second handle opened by `fs::copy` would fail
5087 /// while this writer still holds the lock.
5088 ///
5089 /// FIX-1 R-001.5 + R-002.2 part 3.
5090 pub fn export_encrypted(&mut self, dest: impl AsRef<Path>) -> Result<()> {
5091 let dest = dest.as_ref();
5092
5093 // Step 1 — refuse export to the open file itself. We compare
5094 // by identity, not string, so symlinks and hard links to
5095 // `self.path` are caught too. The check runs before any flush
5096 // so a self-targeted export is a pure no-op.
5097 if paths_refer_to_same_file(&self.path, dest).unwrap_or(false) {
5098 return Err(MnemoError::Invalid(format!(
5099 "export_encrypted refuses to overwrite the open database at {}",
5100 dest.display()
5101 )));
5102 }
5103
5104 // Step 2 — flush, so on-disk bytes match the live state.
5105 if !self.read_only {
5106 self.flush()?;
5107 }
5108
5109 // Step 3 — stream from the open handle into a sibling temp.
5110 // Using the open handle (instead of a second open on `self.path`)
5111 // means a Windows mandatory lock can't block the copy.
5112 let parent = dest.parent().ok_or_else(|| {
5113 MnemoError::Invalid(format!(
5114 "export_encrypted: dest {} has no parent directory",
5115 dest.display()
5116 ))
5117 })?;
5118 let tmp = temp_sibling(dest);
5119 // Scope the temp handle so it closes before the rename on
5120 // Windows (which refuses to rename over an open file).
5121 {
5122 let src = self.pager.file_mut();
5123 src.seek(SeekFrom::Start(0))?;
5124 let mut out = OpenOptions::new()
5125 .write(true)
5126 .create(true)
5127 .truncate(true)
5128 .open(&tmp)?;
5129 let mut buf = vec![0u8; 1 << 20]; // 1 MiB
5130 loop {
5131 let n = src.read(&mut buf)?;
5132 if n == 0 {
5133 break;
5134 }
5135 out.write_all(&buf[..n])?;
5136 }
5137 out.sync_all()?;
5138 crate::bump_fsync();
5139 }
5140
5141 // Step 4 — atomic rename onto `dest`.
5142 if let Err(e) = std::fs::rename(&tmp, dest) {
5143 // Best-effort cleanup; propagate the rename error.
5144 let _ = std::fs::remove_file(&tmp);
5145 return Err(MnemoError::Io(e));
5146 }
5147
5148 // Step 5 — fsync the parent directory on Unix so the rename
5149 // itself is durable. On Windows directory syncs aren't
5150 // supported; the rename above is atomic enough on NTFS.
5151 #[cfg(unix)]
5152 {
5153 if let Ok(dir) = File::open(parent) {
5154 let _ = dir.sync_all();
5155 crate::bump_fsync();
5156 }
5157 }
5158 #[cfg(not(unix))]
5159 {
5160 let _ = parent;
5161 }
5162
5163 Ok(())
5164 }
5165
5166 /// Change the passphrase. Cheap: re-derives the KEK and re-wraps the DEK;
5167 /// the encrypted pages are never rewritten.
5168 pub fn rekey(&mut self, new_passphrase: &str, kdf: KdfParams) -> Result<()> {
5169 if self.read_only {
5170 return Err(MnemoError::ReadOnly);
5171 }
5172 self.flush()?;
5173 let new_salt = crypto::random_salt();
5174 let new_kek = crypto::derive_kek(new_passphrase.as_bytes(), &new_salt, kdf)?;
5175 let new_nonce = crypto::random_nonce();
5176 let wrapped = crypto::wrap_dek(&new_kek, &new_nonce, self.pager.dek())?;
5177 if wrapped.len() != WRAPPED_DEK_LEN {
5178 return Err(MnemoError::Crypto("unexpected wrapped DEK length".into()));
5179 }
5180 let mut wrapped_dek = [0u8; WRAPPED_DEK_LEN];
5181 wrapped_dek.copy_from_slice(&wrapped);
5182
5183 self.header.salt = new_salt;
5184 self.header.dek_nonce = new_nonce;
5185 self.header.wrapped_dek = wrapped_dek;
5186 self.header.m_cost = kdf.m_cost;
5187 self.header.t_cost = kdf.t_cost;
5188 self.header.p_cost = kdf.p_cost;
5189 self.kdf = kdf;
5190
5191 // DUR-1 (R-002.1): WAL-log the new page 0 before touching it.
5192 // Allocate a fresh WAL region at the tail so that a crash during
5193 // the rekey leaves either the old region (old passphrase works)
5194 // or the new region (new passphrase works) fully committed —
5195 // overwriting the existing region would risk destroying both.
5196 let old_wal_start = self.header.wal_start;
5197 let new_pages = self.header.wal_pages;
5198 let new_start = self.header.next_page;
5199 self.header.next_page += new_pages;
5200 self.header.wal_start = new_start;
5201 self.header.wal_pages = new_pages;
5202 self.header.wal_seq += 1;
5203 self.relocate_header_through_wal(old_wal_start)
5204 }
5205
5206 /// Decrypt and validate every live record. Returns the count verified
5207 /// across memories **and** cache entries (FIX-1 R-001.1: now that the
5208 /// cache survives compaction, `verify()` has to walk it too — the
5209 /// pre-FIX-1 implementation only checked `self.catalog`, so a
5210 /// corrupted cache record went unnoticed).
5211 pub fn verify(&mut self) -> Result<usize> {
5212 let entries: Vec<CatalogEntry> = self
5213 .catalog
5214 .iter()
5215 .filter(|e| !e.deleted)
5216 .cloned()
5217 .collect();
5218 let mut n = entries.len();
5219 for e in &entries {
5220 let bytes = self.read_record(e)?;
5221 let _m: Memory = rmp_serde::from_slice(&bytes)
5222 .map_err(|err| MnemoError::Serialize(err.to_string()))?;
5223 }
5224
5225 // Also decode every live cache entry. Tombstoned entries are
5226 // skipped — their pages are orphan ciphertext awaiting
5227 // `compact_file`, same as deleted memories. Expired entries
5228 // are *still* on disk until compaction, so we verify them too.
5229 let cache_positions: Vec<(u64, u32, u32)> = self
5230 .cache_dir
5231 .iter()
5232 .filter(|e| !e.deleted)
5233 .map(|e| (e.start_page, e.page_count, e.len))
5234 .collect();
5235 for (start_page, page_count, len) in cache_positions {
5236 let mut buf = Vec::with_capacity(len as usize);
5237 for i in 0..page_count as u64 {
5238 buf.extend_from_slice(&self.pager.read_page(start_page + i)?);
5239 }
5240 buf.truncate(len as usize);
5241 let _c: CacheEntry = rmp_serde::from_slice(&buf)
5242 .map_err(|err| MnemoError::Serialize(err.to_string()))?;
5243 n += 1;
5244 }
5245 Ok(n)
5246 }
5247
5248 /// Summary statistics for the open database.
5249 pub fn stats(&mut self) -> Result<Stats> {
5250 let deleted = self.catalog.iter().filter(|e| e.deleted).count();
5251 // FIX-1 R-001.8 part 2: build the agent set lazily on first
5252 // call per handle, then maintain in `put` / invalidate in
5253 // `delete`. Pre-FIX-1 every `stats()` call decrypted every
5254 // live memory (O(N) AES-GCM decrypts just to list a usually-
5255 // tiny set of distinct agent IDs). On a 1M-memory database
5256 // this was in the multi-second range on hardware AES.
5257 if self.agent_set.is_none() {
5258 let built: std::collections::BTreeSet<String> =
5259 self.memories()?.into_iter().map(|m| m.agent_id).collect();
5260 self.agent_set = Some(built);
5261 }
5262 let agents: Vec<String> = self
5263 .agent_set
5264 .as_ref()
5265 .expect("agent_set just filled above")
5266 .iter()
5267 .cloned()
5268 .collect();
5269 let file_bytes = self.header.next_page * PAGE_SIZE as u64;
5270 Ok(Stats {
5271 memories: self.len(),
5272 deleted,
5273 dimensions: self.dimensions,
5274 file_bytes,
5275 agents,
5276 encrypted: self.header.flags & FLAG_ENCRYPTED != 0,
5277 created_at: self.header.created_at,
5278 index: self.ann.as_ref().map(|a| a.info()),
5279 wal_pages: self.header.wal_pages,
5280 fsyncs_last_flush: self.fsyncs_last_flush,
5281 })
5282 }
5283
5284 /// Rewrite the file, dropping tombstoned and expired memories and
5285 /// reclaiming stale pages left by updates. Done out-of-place via a temp
5286 /// file and an atomic rename.
5287 ///
5288 /// FIX-1 R-001.1 + R-002.2 part 2: the result cache now survives
5289 /// compaction. Every live, non-expired directory entry is copied
5290 /// into the new file verbatim — `created_at`, `ttl_secs`,
5291 /// `cost_hint_ms`, `key_preview`, `size_bytes`, `content_type`,
5292 /// `vector` and `model` come from the record body; `accessed_at`
5293 /// and `access_count` come from the *directory* (the record body
5294 /// only holds the insert-time snapshot — the directory is the
5295 /// source of truth for the live counts, as of v5's recall trick).
5296 /// Tombstoned and TTL-expired entries are dropped. Pre-FIX-1
5297 /// compaction kept only the memory catalog and silently emptied
5298 /// the cache; the Planner's interim README caveat (c417d2e) called
5299 /// that out — this PR removes the surprise.
5300 pub fn compact_file(path: &str, passphrase: &str) -> Result<CompactReport> {
5301 let mut old = Mnemo::open(path, passphrase)?;
5302 let dims = old.dimensions;
5303 let kdf = old.kdf;
5304 let tmp = format!("{path}.compact-tmp");
5305
5306 let mut new = Mnemo::create(
5307 &tmp,
5308 passphrase,
5309 MnemoConfig {
5310 dimensions: dims,
5311 kdf,
5312 ..Default::default()
5313 },
5314 )?;
5315 // ANN-1.1: compaction is "re-tune for the current size" by
5316 // default. Pass IndexConfig::default() so partitions,
5317 // subspaces, n_probe and n_rerank are all re-derived from the
5318 // compacted vector count. Previously we froze the pre-compact
5319 // (n_probe, n_rerank), which left the new index on stale
5320 // numbers for the smaller post-compact corpus.
5321 let want_index = old.ann.is_some();
5322 let now = memory::now_secs();
5323 let all = old.memories()?;
5324 let before = all.len();
5325 let mut after = 0;
5326 for m in all {
5327 if m.is_expired(now) {
5328 continue;
5329 }
5330 new.remember(m)?;
5331 after += 1;
5332 }
5333 if want_index && after > 0 {
5334 new.build_index_with(IndexConfig::default())?;
5335 }
5336
5337 // Count + copy cache entries. We walk `old.cache_dir` by index
5338 // so each hop reads the directory's live `accessed_at` /
5339 // `access_count` directly rather than going through
5340 // `cache_get` (which would also treat read-only semantics,
5341 // tombstones, etc. — we want the raw, authoritative values).
5342 let mut cache_before = 0usize;
5343 let mut cache_after = 0usize;
5344 let positions: Vec<usize> = (0..old.cache_dir.len()).collect();
5345 for pos in positions {
5346 let keep = {
5347 let e = &old.cache_dir[pos];
5348 !e.deleted && !e.is_expired(now)
5349 };
5350 if keep {
5351 cache_before += 1;
5352 new.copy_cache_entry_from(&mut old, pos)?;
5353 cache_after += 1;
5354 }
5355 }
5356
5357 new.flush()?;
5358 drop(new);
5359 drop(old);
5360
5361 std::fs::rename(&tmp, path)?;
5362 Ok(CompactReport {
5363 before,
5364 after,
5365 cache_before,
5366 cache_after,
5367 })
5368 }
5369
5370 /// Copy one live cache entry from `old` at directory position
5371 /// `pos` into `self` (a freshly-created compaction target).
5372 /// Decrypts the old record body, substitutes the old *directory*'s
5373 /// `accessed_at` + `access_count` (the record body's copies are
5374 /// stale as of v5's directory-only hit path), re-encodes, writes
5375 /// a fresh record run, and pushes a matching directory entry.
5376 ///
5377 /// Caller is expected to have already filtered out tombstoned /
5378 /// expired entries and to call this on a read-write handle.
5379 pub(crate) fn copy_cache_entry_from(&mut self, old: &mut Mnemo, pos: usize) -> Result<()> {
5380 if self.read_only {
5381 return Err(MnemoError::ReadOnly);
5382 }
5383 // Snapshot the directory fields we need, so the borrow on
5384 // `old.cache_dir` ends before we touch `old.pager`.
5385 let (start_page, page_count, len, dir_accessed_at, dir_access_count) = {
5386 let e = &old.cache_dir[pos];
5387 (
5388 e.start_page,
5389 e.page_count,
5390 e.len,
5391 e.accessed_at,
5392 e.access_count,
5393 )
5394 };
5395
5396 // Read + decrypt the old record body.
5397 let mut buf = Vec::with_capacity(len as usize);
5398 for i in 0..page_count as u64 {
5399 buf.extend_from_slice(&old.pager.read_page(start_page + i)?);
5400 }
5401 buf.truncate(len as usize);
5402 let mut entry: CacheEntry =
5403 rmp_serde::from_slice(&buf).map_err(|e| MnemoError::Serialize(e.to_string()))?;
5404
5405 // Overwrite the stale body copies with the directory's live
5406 // values before persisting. Everything else — `created_at`,
5407 // `ttl_secs`, `cost_hint_ms`, `key_preview`, `size_bytes`,
5408 // `content_type`, `value`, `vector`, `model` — stays as the
5409 // body already recorded it.
5410 entry.accessed_at = dir_accessed_at;
5411 entry.access_count = dir_access_count;
5412
5413 let bytes =
5414 rmp_serde::to_vec(&entry).map_err(|err| MnemoError::Serialize(err.to_string()))?;
5415 let (new_start_page, new_page_count) = self.write_record(&bytes)?;
5416
5417 let dir_entry = CacheDirectoryEntry {
5418 namespace: entry.namespace.clone(),
5419 key_hash: entry.key_hash,
5420 start_page: new_start_page,
5421 page_count: new_page_count,
5422 len: bytes.len() as u32,
5423 deleted: false,
5424 accessed_at: dir_accessed_at,
5425 access_count: dir_access_count,
5426 size_bytes: entry.size_bytes,
5427 ttl_secs: entry.ttl_secs,
5428 created_at: entry.created_at,
5429 vector: entry.vector.clone(),
5430 model: entry.model.clone(),
5431 };
5432 let new_pos = self.cache_dir.len();
5433 self.cache_idx
5434 .insert(&entry.namespace, entry.key_hash, new_pos);
5435 self.cache_dir.push(dir_entry);
5436 self.dirty_cache = true;
5437 Ok(())
5438 }
5439}