Skip to main content

mnemo_engine/
memory.rs

1//! The agent memory model (Phase 5 of the build plan).
2//!
3//! This is what makes Mnemo a *memory engine* rather than a generic vector
4//! store. Memories are typed (episodic / semantic / procedural / working),
5//! carry lifecycle metadata (importance, access stats, optional TTL), and are
6//! retrieved with a **multi-signal score** that blends semantic similarity
7//! with recency, importance, and access frequency.
8
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value};
13use ulid::Ulid;
14
15/// Current unix time in whole seconds.
16pub fn now_secs() -> i64 {
17    SystemTime::now()
18        .duration_since(UNIX_EPOCH)
19        .map(|d| d.as_secs() as i64)
20        .unwrap_or(0)
21}
22
23/// The four kinds of agent memory.
24#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug, Hash)]
25pub enum MemoryType {
26    /// What happened — conversation turns, events, interactions.
27    Episodic,
28    /// What is known — facts, entities, relationships, beliefs.
29    Semantic,
30    /// What to do — preferences, behavioral patterns, rules.
31    Procedural,
32    /// Right now — current-session context and scratchpad.
33    Working,
34}
35
36impl MemoryType {
37    /// Parse from a lowercase string (used by the CLI / SDK surface).
38    pub fn parse(s: &str) -> Option<MemoryType> {
39        match s.to_ascii_lowercase().as_str() {
40            "episodic" => Some(MemoryType::Episodic),
41            "semantic" => Some(MemoryType::Semantic),
42            "procedural" => Some(MemoryType::Procedural),
43            "working" => Some(MemoryType::Working),
44            _ => None,
45        }
46    }
47
48    /// Lowercase string form.
49    pub fn as_str(&self) -> &'static str {
50        match self {
51            MemoryType::Episodic => "episodic",
52            MemoryType::Semantic => "semantic",
53            MemoryType::Procedural => "procedural",
54            MemoryType::Working => "working",
55        }
56    }
57}
58
59/// Visibility of a memory across agents sharing one `.mnemo` file.
60#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
61pub enum Scope {
62    /// Visible only to the agent that created it.
63    Private,
64    /// Visible to every agent with read access.
65    Shared,
66}
67
68/// A single memory entry.
69#[derive(Serialize, Deserialize, Clone, Debug)]
70pub struct Memory {
71    /// Time-sortable unique ID.
72    pub id: Ulid,
73    /// Cognitive category of this memory.
74    pub memory_type: MemoryType,
75    /// Embedding of `content`.
76    pub vector: Vec<f32>,
77    /// The actual text/data of the memory.
78    pub content: String,
79    /// Free-form structured metadata.
80    pub metadata: Map<String, Value>,
81    /// Which agent created this memory.
82    pub agent_id: String,
83    /// Which session produced it (relevant for episodic memory).
84    pub session_id: Option<String>,
85    /// Visibility scope (private to its agent, or shared).
86    pub scope: Scope,
87    /// Unix seconds at creation.
88    pub created_at: i64,
89    /// Unix seconds of the most recent retrieval. Updated by `recall`.
90    pub accessed_at: i64,
91    /// How many times this memory has been retrieved.
92    pub access_count: u32,
93    /// Importance in `[0.0, 1.0]`; higher importance resists decay.
94    pub importance: f32,
95    /// Optional lifetime in seconds. After expiry the memory is skipped by
96    /// `recall` and removed by `compact`.
97    pub ttl_secs: Option<i64>,
98}
99
100impl Memory {
101    /// Create a new memory with sensible defaults. The ID and timestamps are
102    /// assigned now; tune the rest with the `with_*` builders.
103    pub fn new(content: impl Into<String>, memory_type: MemoryType, vector: Vec<f32>) -> Self {
104        let now = now_secs();
105        Memory {
106            id: Ulid::new(),
107            memory_type,
108            vector,
109            content: content.into(),
110            metadata: Map::new(),
111            agent_id: "default".to_string(),
112            session_id: None,
113            scope: Scope::Private,
114            created_at: now,
115            accessed_at: now,
116            access_count: 0,
117            importance: 0.5,
118            ttl_secs: None,
119        }
120    }
121
122    /// Set the owning agent.
123    pub fn with_agent(mut self, agent_id: impl Into<String>) -> Self {
124        self.agent_id = agent_id.into();
125        self
126    }
127    /// Set the session ID.
128    pub fn with_session(mut self, session_id: impl Into<String>) -> Self {
129        self.session_id = Some(session_id.into());
130        self
131    }
132    /// Set the importance score (clamped to `[0,1]`).
133    pub fn with_importance(mut self, importance: f32) -> Self {
134        self.importance = importance.clamp(0.0, 1.0);
135        self
136    }
137    /// Set the visibility scope.
138    pub fn with_scope(mut self, scope: Scope) -> Self {
139        self.scope = scope;
140        self
141    }
142    /// Attach a TTL in seconds.
143    pub fn with_ttl(mut self, ttl_secs: i64) -> Self {
144        self.ttl_secs = Some(ttl_secs);
145        self
146    }
147    /// Attach a metadata key/value pair.
148    pub fn with_meta(mut self, key: impl Into<String>, value: Value) -> Self {
149        self.metadata.insert(key.into(), value);
150        self
151    }
152
153    /// True if the memory's TTL has elapsed as of `now`.
154    pub fn is_expired(&self, now: i64) -> bool {
155        match self.ttl_secs {
156            Some(ttl) => now - self.created_at >= ttl,
157            None => false,
158        }
159    }
160
161    /// Build the **canonical scaffold manifest** for a freshly-initialised
162    /// database. This is the default "I am a brand-new MNemo file" memory
163    /// inserted by `mnemo init` (and surfaced by `mnemo about`) so every new
164    /// database is self-describing from birth — no opt-in step required.
165    ///
166    /// The scaffold is a placeholder: the vector is all-zeros (it doesn't
167    /// pretend to carry semantic content), and `metadata.scaffold = true`
168    /// marks it as auto-generated so tooling and humans can tell it apart
169    /// from a hand-written manifest. The expected workflow is for the file's
170    /// author to replace this memory with one that records the project's
171    /// actual embedder, agent_id default, and conventions.
172    ///
173    /// See [`crate::Mnemo::about`] for the rest of the convention.
174    pub fn scaffold_manifest(dimensions: usize) -> Self {
175        let content = format!(
176            "MNEMO MANIFEST (scaffold) — Fresh {}-dimensional database created \
177             by `mnemo init`. This is a placeholder. Replace it with a memory \
178             that records your project's embedder (name, dimensions, normalize), \
179             default agent_id, and metadata conventions; keep the metadata keys \
180             `area=\"onboarding\"` and `topic=\"manifest\"` so `mnemo about` \
181             continues to surface it as the headline orientation point. Drop \
182             `metadata.scaffold` once you've replaced this entry so tooling \
183             knows the manifest has been curated.",
184            dimensions
185        );
186        let mut metadata = Map::new();
187        metadata.insert("area".into(), Value::String("onboarding".into()));
188        metadata.insert("topic".into(), Value::String("manifest".into()));
189        metadata.insert("scaffold".into(), Value::Bool(true));
190        metadata.insert(
191            "engine_version".into(),
192            Value::String(env!("CARGO_PKG_VERSION").into()),
193        );
194        metadata.insert(
195            "dimensions".into(),
196            Value::Number(serde_json::Number::from(dimensions as u64)),
197        );
198        // Sentinel vector: a single-axis unit spike `[1, 0, 0, …]`.
199        // A zero vector would now trip the Phase 3 `put` guard
200        // (all-zero refused — see store.rs). This spike is non-zero
201        // (so it passes), unit-normalized (so it reads cleanly to any
202        // downstream inspector), and uncorrelated with any real
203        // embedder's output (so it ranks near-last on any user query —
204        // appropriate for a placeholder the user is expected to
205        // replace).
206        let mut vector = vec![0.0_f32; dimensions];
207        if dimensions > 0 {
208            vector[0] = 1.0;
209        }
210        let now = now_secs();
211        Memory {
212            id: Ulid::new(),
213            memory_type: MemoryType::Semantic,
214            vector,
215            content,
216            metadata,
217            agent_id: "mnemo".to_string(),
218            session_id: None,
219            scope: Scope::Shared,
220            created_at: now,
221            accessed_at: now,
222            access_count: 0,
223            importance: 1.0,
224            ttl_secs: None,
225        }
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Phase 12.1: reserved `_mnemo.*` metadata keys for in-place updates + keyed
231// supersession. These live in `Memory.metadata` (which is already
232// `Map<String, Value>` on the record), so nothing in the on-disk record
233// layout changes — v0.7.x readers see them as ordinary user metadata and
234// ignore them. Format bump to v9 (post-12.1) will promote the ones that
235// pay for a typed catalog column.
236// ---------------------------------------------------------------------------
237
238/// Namespace prefix used for every v0.8.0+ reserved metadata key (R-001.10).
239/// Keys outside this namespace are untouched user metadata.
240pub const MNEMO_META_NS: &str = "_mnemo.";
241
242/// Upsert slot name on a `remember(…, key = K)` write (12.1). Combined with
243/// the memory's `scope` (and `agent_id` for `Private` scope) to identify a
244/// single live memory per slot: a newer `remember(key=K)` supersedes the
245/// prior one.
246pub const MNEMO_META_KEY: &str = "_mnemo.key";
247
248/// Set on the newly-inserted record when a `remember(key=K)` supersedes an
249/// earlier record. Value: the superseded record's ULID string.
250pub const MNEMO_META_SUPERSEDES: &str = "_mnemo.supersedes";
251
252/// Set on the earlier record when a newer `remember(key=K)` has replaced
253/// it. Value: the new record's ULID string. Together with `_mnemo.invalid_at`
254/// this marks the record as "superseded, not current" — default `recall`,
255/// `about` and the key index skip it; `history(key)` walks the chain.
256pub const MNEMO_META_SUPERSEDED_BY: &str = "_mnemo.superseded_by";
257
258/// Set on the earlier record when superseded. Value: unix seconds (i64).
259/// Not interpreted by the engine's scorer — purely for audit / `history`.
260pub const MNEMO_META_INVALID_AT: &str = "_mnemo.invalid_at";
261
262/// Set on a record each time `Mnemo::update` succeeds. Value: unix seconds.
263/// Lets callers distinguish an unchanged-since-creation record from one
264/// that has been edited in place. Not interpreted by the scorer.
265pub const MNEMO_META_UPDATED_AT: &str = "_mnemo.updated_at";
266
267// ---------------------------------------------------------------------------
268// Phase 12.7a: thread archive (ingest / export / forget). Reserved
269// `_mnemo.*` keys that identify thread-turn records. All records with
270// `_mnemo.kind = "turn"` are:
271//   * excluded from default recall (opt in with a 12.6
272//     `_mnemo.kind = "turn"` filter or the `--thread` CLI flag),
273//   * written by `Mnemo::ingest_thread` + the `mnemo thread ingest`
274//     CLI from a transcript file in either a generic JSONL shape or
275//     Claude Code's JSONL (`~/.claude/projects/<p>/<s>.jsonl`),
276//   * grouped by `_mnemo.thread = <id>` for `thread export` /
277//     `thread forget` and the 12.7b essence render.
278// ---------------------------------------------------------------------------
279
280/// Record kind for thread-archive turns (Phase 12.7a). Value: `"turn"`.
281pub const MNEMO_META_KIND: &str = "_mnemo.kind";
282
283/// Thread identifier the record belongs to (Phase 12.7a). Value: a
284/// caller-supplied string, typically the Claude Code session uuid or
285/// the planner-chosen thread slug.
286pub const MNEMO_META_THREAD: &str = "_mnemo.thread";
287
288/// Monotonically increasing sequence number within the thread
289/// (Phase 12.7a). Value: unsigned integer, 0-based. Lets
290/// `thread export` reconstruct the transcript in order.
291pub const MNEMO_META_SEQ: &str = "_mnemo.seq";
292
293/// Role of the turn's author (Phase 12.7a). Value: `"user"`,
294/// `"assistant"`, `"system"`, `"tool"`, or any other string the
295/// source transcript used.
296pub const MNEMO_META_ROLE: &str = "_mnemo.role";
297
298/// Opaque per-turn identifier from the source transcript
299/// (Phase 12.7a). Value: typically a uuid4. Used for idempotence —
300/// `ingest_thread` skips any turn whose `turn_id` already exists on a
301/// record in the target thread.
302pub const MNEMO_META_TURN_ID: &str = "_mnemo.turn_id";
303
304/// Zero-based part number for a turn that was chunked at the 8 KB
305/// boundary during ingest (Phase 12.7a). Absent → the turn fits in a
306/// single record.
307pub const MNEMO_META_PART: &str = "_mnemo.part";
308
309/// Hex-encoded SHA-256 of the original tool-result content
310/// (Phase 12.7a). Set when the content exceeded the 16 KB truncation
311/// cap during ingest; absent otherwise.
312pub const MNEMO_META_SHA256: &str = "_mnemo.sha256";
313
314/// Byte length of the original tool-result content (Phase 12.7a). Set
315/// alongside `_mnemo.sha256` when truncation happened; absent
316/// otherwise.
317pub const MNEMO_META_LENGTH: &str = "_mnemo.length";
318
319/// Marker value for `_mnemo.kind` on thread-turn records.
320pub const MNEMO_META_KIND_TURN: &str = "turn";
321
322/// Maximum content size for a single turn record before chunking kicks
323/// in (Phase 12.7a). 8 KB per chunk keeps each record small enough
324/// that the per-record page cost dominates — and so that a thread of
325/// mostly short messages ingests as one record each.
326pub const THREAD_TURN_CHUNK_BYTES: usize = 8 * 1024;
327
328/// Maximum content size for a tool-result turn before truncation
329/// (Phase 12.7a). Tool results frequently run tens of kilobytes;
330/// stored truncated with `_mnemo.sha256` + `_mnemo.length` so the
331/// original can be recovered (or verified) out-of-band.
332pub const THREAD_TOOL_RESULT_TRUNCATE_BYTES: usize = 16 * 1024;
333
334// ---------------------------------------------------------------------------
335// Phase 12.7b: status-record core + essence (timeline + sections). The
336// essence layer rides on the same reserved-metadata namespace as the
337// thread archive, so a file keeps the single `_mnemo.*` convention:
338//
339// - `_mnemo.kind = "essence"` identifies an essence record. Section
340//   is in `_mnemo.section`. Scope — thread or project — is in
341//   `_mnemo.thread` (string) or absent (`None` means project scope).
342// - `_mnemo.status` carries the lifecycle for section items
343//   (`"open"` → `"resolved"`). Timeline entries never get a status.
344// - `_mnemo.status_history` records the status transitions. One
345//   element per transition: `{"status": "...", "at": <unix_secs>}`.
346// - `_mnemo.refs` is an optional array of free-form reference
347//   strings (turn ids, memory ids, commits, paths).
348// ---------------------------------------------------------------------------
349
350/// Reserved `_mnemo.section` key — which essence section an item
351/// belongs to (Phase 12.7b). Values: `"timeline"`, `"intent"`,
352/// `"conventions"`, `"decisions"`, `"state"`, `"open"`, `"next"`,
353/// `"lessons"`. Timeline entries are append-only.
354pub const MNEMO_META_SECTION: &str = "_mnemo.section";
355
356/// Reserved `_mnemo.status` key — current status of a status-record.
357/// Values are per-kind (Phase 12.7b: essence uses `"open"` and
358/// `"resolved"`). Timeline entries never carry this.
359pub const MNEMO_META_STATUS: &str = "_mnemo.status";
360
361/// Reserved `_mnemo.status_history` key — array of status transitions,
362/// each `{"status": <str>, "at": <unix_secs>}`. Append-only — the
363/// first entry records creation, each later entry records a
364/// transition.
365pub const MNEMO_META_STATUS_HISTORY: &str = "_mnemo.status_history";
366
367/// Reserved `_mnemo.refs` key — optional array of free-form reference
368/// strings (turn ids, memory ids, commits, paths) attached to an
369/// essence record (Phase 12.7b).
370pub const MNEMO_META_REFS: &str = "_mnemo.refs";
371
372/// Marker value for `_mnemo.kind` on essence records (Phase 12.7b).
373pub const MNEMO_META_KIND_ESSENCE: &str = "essence";
374
375/// Maximum content length for a timeline entry (Phase 12.7b).
376/// Entries over this are rejected, not truncated — the planner's
377/// spec is explicit about this because truncation would lose the
378/// narrative, and splitting across records would lose ordering.
379pub const ESSENCE_TIMELINE_MAX_BYTES: usize = 600;
380
381/// Maximum content length for a section item (Phase 12.7b).
382/// intent / conventions / decisions / state / open / next / lessons
383/// all share the same single-line cap. Items over this are rejected.
384pub const ESSENCE_SECTION_MAX_BYTES: usize = 280;
385
386/// Default byte budget for [`crate::Mnemo::essence`] rendering
387/// (Phase 12.7b). Matches the planner's "max_bytes = 6144" spec,
388/// which sits comfortably below a 4K-token context window.
389pub const ESSENCE_DEFAULT_RENDER_BYTES: usize = 6144;
390
391/// Which essence section an item belongs to.
392///
393/// Rendering order is fixed at this type's declaration order:
394/// intent → conventions → decisions → state → open → next → lessons
395/// → timeline. See [`EssenceSection::render_order`] for the explicit
396/// list and the [`crate::Mnemo::essence`] docs for per-section rules
397/// (timeline is append-only, every other section is item-edited).
398#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
399pub enum EssenceSection {
400    /// What the thread is trying to do.
401    Intent,
402    /// How the user works — conventions that survive across threads.
403    Conventions,
404    /// Decisions made and why (newest first on render).
405    Decisions,
406    /// The current state of the work.
407    State,
408    /// Open problems.
409    Open,
410    /// What to do next.
411    Next,
412    /// Lessons learned — mistakes caught and their fixes.
413    Lessons,
414    /// Append-only milestone timeline. Entries capped at
415    /// [`ESSENCE_TIMELINE_MAX_BYTES`] and never editable.
416    Timeline,
417}
418
419impl EssenceSection {
420    /// Section → stable `_mnemo.section` string.
421    pub fn as_str(&self) -> &'static str {
422        match self {
423            EssenceSection::Intent => "intent",
424            EssenceSection::Conventions => "conventions",
425            EssenceSection::Decisions => "decisions",
426            EssenceSection::State => "state",
427            EssenceSection::Open => "open",
428            EssenceSection::Next => "next",
429            EssenceSection::Lessons => "lessons",
430            EssenceSection::Timeline => "timeline",
431        }
432    }
433
434    /// Parse a string from `_mnemo.section` into the typed enum.
435    /// Case-insensitive; returns `None` on unknown input.
436    pub fn parse(s: &str) -> Option<EssenceSection> {
437        match s.to_ascii_lowercase().as_str() {
438            "intent" => Some(EssenceSection::Intent),
439            "conventions" => Some(EssenceSection::Conventions),
440            "decisions" => Some(EssenceSection::Decisions),
441            "state" => Some(EssenceSection::State),
442            "open" => Some(EssenceSection::Open),
443            "next" => Some(EssenceSection::Next),
444            "lessons" => Some(EssenceSection::Lessons),
445            "timeline" => Some(EssenceSection::Timeline),
446            _ => None,
447        }
448    }
449
450    /// Fixed render order (intent → conventions → decisions → state
451    /// → open → next → lessons → timeline). The [`crate::Mnemo::essence`]
452    /// render walks this list, allocating a per-section byte budget
453    /// proportional to section importance in the user-centric order.
454    pub fn render_order() -> &'static [EssenceSection] {
455        &[
456            EssenceSection::Intent,
457            EssenceSection::Conventions,
458            EssenceSection::Decisions,
459            EssenceSection::State,
460            EssenceSection::Open,
461            EssenceSection::Next,
462            EssenceSection::Lessons,
463            EssenceSection::Timeline,
464        ]
465    }
466
467    /// `true` for sections whose items carry an `open → resolved`
468    /// status lifecycle (every section except [`EssenceSection::Timeline`]).
469    pub fn has_status_lifecycle(&self) -> bool {
470        !matches!(self, EssenceSection::Timeline)
471    }
472
473    /// Per-section byte cap for new items. Timeline: 600 bytes.
474    /// Every other section: 280 bytes.
475    pub fn item_max_bytes(&self) -> usize {
476        match self {
477            EssenceSection::Timeline => ESSENCE_TIMELINE_MAX_BYTES,
478            _ => ESSENCE_SECTION_MAX_BYTES,
479        }
480    }
481}
482
483/// Scope selector for an essence item (Phase 12.7b). Thread-scoped
484/// items belong to one `_mnemo.thread`; project-scoped items are
485/// shared across every thread in the file.
486#[derive(Debug, Clone, PartialEq, Eq, Hash)]
487pub enum EssenceScope {
488    /// Belongs to one thread (the string). Shows up in that
489    /// thread's render only.
490    Thread(String),
491    /// Shared across every thread in the file. Shows up in every
492    /// thread's render, and in the `about()` append.
493    Project,
494}
495
496/// Status values the essence section supports (Phase 12.7b). More
497/// kinds (tasks: `queued → active → ready → done | blocked`;
498/// findings: `open → accepted | deferred | rejected | needs-user →
499/// verified`) ship with Phase 13 reusing the same status-record
500/// core; the transition tables for those are declared as constants
501/// in this crate but currently unused.
502pub mod status {
503    /// Freshly-added section item.
504    pub const OPEN: &str = "open";
505    /// The user-facing concern has been addressed.
506    pub const RESOLVED: &str = "resolved";
507}
508
509/// Task transition table (declared for Phase 13; currently unused
510/// in the engine). `queued → active → ready → done | blocked`,
511/// with `blocked → active` as the only back-edge.
512pub const TASK_TRANSITIONS: &[(&str, &str)] = &[
513    ("queued", "active"),
514    ("active", "ready"),
515    ("active", "blocked"),
516    ("blocked", "active"),
517    ("ready", "done"),
518];
519
520/// Finding transition table (declared for Phase 13; currently unused
521/// in the engine). `open → {accepted | deferred | rejected |
522/// needs-user} → verified`.
523pub const FINDING_TRANSITIONS: &[(&str, &str)] = &[
524    ("open", "accepted"),
525    ("open", "deferred"),
526    ("open", "rejected"),
527    ("open", "needs-user"),
528    ("accepted", "verified"),
529    ("deferred", "verified"),
530    ("needs-user", "verified"),
531];
532
533/// Essence transition table for Phase 12.7b. Only `open → resolved`
534/// is allowed. Timeline entries don't use this (they're append-only
535/// and have no status).
536pub const ESSENCE_TRANSITIONS: &[(&str, &str)] = &[(status::OPEN, status::RESOLVED)];
537
538// ---------------------------------------------------------------------------
539// Phase 12.7c: continuation + commit_deltas
540// ---------------------------------------------------------------------------
541
542/// Reserved `_mnemo.continues` key (Phase 12.7c). Set on a
543/// thread-start marker record to point at the previous thread whose
544/// context the new thread continues from. Value: the prior thread id
545/// (string). Rendering a continued thread pulls the prior thread's
546/// open items, `next` and last-N timeline entries ahead of the new
547/// thread's own essence.
548pub const MNEMO_META_CONTINUES: &str = "_mnemo.continues";
549
550/// Marker value for `_mnemo.kind` on a thread-start record (one per
551/// `thread_start(new, --from old)` call, carrying the link in
552/// `_mnemo.continues`).
553pub const MNEMO_META_KIND_THREAD_LINK: &str = "thread_link";
554
555/// How many timeline entries from the predecessor thread the
556/// continuation render pulls in (Phase 12.7c). Small enough to keep
557/// the render under the default 6 KB budget, large enough to carry a
558/// handful of milestones.
559pub const CONTINUATION_TIMELINE_ENTRIES: usize = 10;
560
561/// What [`crate::Mnemo::commit_deltas`] does with the `turns` carried
562/// in the batch (Phase 12.7c).
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
564pub enum TurnPolicy {
565    /// Archive every turn in the batch via
566    /// [`crate::Mnemo::ingest_thread`]. Default — matches the
567    /// "lossless flow preservation" story of 12.7.
568    #[default]
569    Archive,
570    /// Store turns with a TTL (unix-seconds expiry).
571    ExpireAfter(i64),
572    /// Drop turns on the floor (no archive record written).
573    Drop,
574}
575
576/// Which op a [`EssenceOp`] carries. Mirrors the per-method
577/// `Mnemo::essence_*` surface so one batch can mix section + timeline
578/// edits in one call.
579#[derive(Debug, Clone, PartialEq, Eq)]
580pub enum EssenceOpKind {
581    /// Section item add (any section except timeline).
582    Add,
583    /// Append-only timeline entry.
584    AppendTimeline,
585    /// In-place update of an existing section item.
586    Update,
587    /// Transition an item `open → resolved`.
588    Resolve,
589    /// Soft-delete an item.
590    Remove,
591}
592
593/// One essence op inside a [`CommitBatch`] (Phase 12.7c). Validation
594/// (missing/extra fields per kind) runs up front in
595/// [`crate::Mnemo::commit_deltas`] — a single bad op aborts the
596/// whole batch before any write lands.
597#[derive(Debug, Clone)]
598pub struct EssenceOp {
599    /// Which op to apply.
600    pub kind: EssenceOpKind,
601    /// Scope — required for [`EssenceOpKind::Add`] and
602    /// [`EssenceOpKind::AppendTimeline`].
603    pub scope: Option<EssenceScope>,
604    /// Section — required for [`EssenceOpKind::Add`].
605    pub section: Option<EssenceSection>,
606    /// Target record id — required for [`EssenceOpKind::Update`],
607    /// [`EssenceOpKind::Resolve`], [`EssenceOpKind::Remove`].
608    pub id: Option<Ulid>,
609    /// Content text — required for [`EssenceOpKind::Add`],
610    /// [`EssenceOpKind::AppendTimeline`], [`EssenceOpKind::Update`].
611    pub text: Option<String>,
612    /// Optional refs for ops that carry text.
613    pub refs: Option<Vec<String>>,
614}
615
616/// One keyed fact inside a [`CommitBatch`] (Phase 12.7c).
617#[derive(Debug, Clone)]
618pub struct KeyedMemory {
619    /// The memory to store.
620    pub memory: Memory,
621    /// Optional 12.1 upsert key.
622    pub key: Option<String>,
623}
624
625/// Atomic write batch for [`crate::Mnemo::commit_deltas`]
626/// (Phase 12.7c). The planner's "one call that composes facts +
627/// essence + turns" shape: everything validates up front, then every
628/// write runs under a single `flush()`.
629#[derive(Debug, Clone, Default)]
630pub struct CommitBatch {
631    /// Keyed-or-not facts to remember (12.1).
632    pub facts: Vec<KeyedMemory>,
633    /// Essence ops to apply (12.7b).
634    pub essence_ops: Vec<EssenceOp>,
635    /// Thread archive turns to ingest (12.7a).
636    pub turns: Vec<ThreadTurn>,
637    /// Destination thread id for `turns`. Required when `turns` is
638    /// non-empty.
639    pub thread_id: Option<String>,
640    /// What to do with `turns` — defaults to `Archive`.
641    pub turn_policy: TurnPolicy,
642}
643
644/// Result of [`crate::Mnemo::commit_deltas`] (Phase 12.7c).
645#[derive(Debug, Clone, Default, PartialEq, Eq)]
646pub struct CommitReport {
647    /// Facts added (plain or `WriteOutcome::Added`).
648    pub facts_added: usize,
649    /// Facts whose keyed write superseded a prior record.
650    pub facts_superseded: usize,
651    /// Keyed facts whose write was a no-op (matched live record).
652    pub facts_unchanged: usize,
653    /// Essence ops that produced an Added/Updated result.
654    pub essence_ops_applied: usize,
655    /// Essence ops that were no-ops (e.g. `resolve` on resolved).
656    pub essence_ops_unchanged: usize,
657    /// Turn records written to the archive.
658    pub turns_added: usize,
659    /// Source turns skipped by the archive's idempotence check.
660    pub turns_skipped: usize,
661    /// Source turns whose content was truncated at the 16 KB cap.
662    pub turns_truncated: usize,
663    /// Source turns split into multiple 8 KB chunks.
664    pub turns_chunked: usize,
665}
666
667/// Patch body for [`crate::Mnemo::update`] (Phase 12.1): every field is
668/// optional, and only the `Some(...)` fields overwrite the corresponding
669/// field on the target memory. All fields can be set independently.
670///
671/// `content` paired with `vector` updates both at once — the engine does
672/// not re-embed under `update` (use `update_text` for that). A `content`
673/// change without a new `vector` is rejected to prevent the embedding
674/// drifting silently out of sync with the text.
675///
676/// `metadata` **replaces** the full user-metadata map — the engine's own
677/// `_mnemo.*` reserved keys on the target record are preserved across
678/// the replacement (updated-at timestamp, supersession markers, etc.).
679/// Passing an empty map drops every user-visible metadata key but keeps
680/// the reserved ones.
681///
682/// `ttl_secs` is `Option<Option<i64>>` so a caller can distinguish
683/// "leave it alone" (`None`) from "set it to a value" (`Some(Some(s))`)
684/// and "clear it" (`Some(None)`).
685#[derive(Debug, Clone, Default)]
686pub struct MemoryPatch {
687    /// New text content. If set, a matching `vector` must also be set
688    /// (or call `update_text`, which re-embeds through the registered
689    /// embedder).
690    pub content: Option<String>,
691    /// New embedding. Must match the database's dimensionality.
692    pub vector: Option<Vec<f32>>,
693    /// New importance in `[0, 1]`.
694    pub importance: Option<f32>,
695    /// Replace the memory type.
696    pub memory_type: Option<MemoryType>,
697    /// Replace the user-visible metadata (preserves `_mnemo.*` reserved
698    /// keys).
699    pub metadata: Option<Map<String, Value>>,
700    /// Change the TTL. Outer `None` → leave unchanged; `Some(Some(s))` →
701    /// set TTL to `s`; `Some(None)` → clear any existing TTL.
702    pub ttl_secs: Option<Option<i64>>,
703}
704
705/// One turn fed into [`crate::Mnemo::ingest_thread`] (Phase 12.7a).
706///
707/// The caller supplies `turn_id` (opaque, for idempotence), `role`
708/// (the transcript's author tag; no interpretation), and `content`
709/// (UTF-8 text; chunked at 8 KB, or truncated at 16 KB when `role ==
710/// "tool"`). The ingest path assigns `_mnemo.seq` monotonically within
711/// the destination thread and embeds through the registered embedder.
712///
713/// Tool messages whose content exceeds [`THREAD_TOOL_RESULT_TRUNCATE_BYTES`]
714/// are stored truncated. Other roles over [`THREAD_TURN_CHUNK_BYTES`]
715/// are chunked.
716#[derive(Debug, Clone)]
717pub struct ThreadTurn {
718    /// Opaque per-turn id from the source transcript (e.g. a Claude
719    /// Code message uuid). Used for `ingest_thread`'s idempotence
720    /// check: a turn whose `turn_id` already exists in the target
721    /// thread is skipped.
722    pub turn_id: String,
723    /// Author role verbatim from the transcript (`"user"`,
724    /// `"assistant"`, `"system"`, `"tool"`, …). `"tool"` triggers
725    /// the 16 KB truncation branch; all other roles go through the
726    /// 8 KB chunking branch.
727    pub role: String,
728    /// UTF-8 message content.
729    pub content: String,
730    /// Optional caller-supplied creation timestamp (unix seconds).
731    /// If `None`, the ingest path uses `now_secs()` so the record's
732    /// `created_at` still sorts the thread in order. Transcripts
733    /// that carry a per-turn timestamp should pass it through so a
734    /// future `thread export --by-time` can honour it.
735    pub timestamp: Option<i64>,
736}
737
738/// Result of one [`crate::Mnemo::ingest_thread`] call (Phase 12.7a).
739#[derive(Debug, Clone, Default, PartialEq, Eq)]
740pub struct IngestReport {
741    /// Number of **new** records written (turn chunks after chunking
742    /// / truncation). A long turn counts as however many chunks it
743    /// produced.
744    pub added_records: usize,
745    /// Number of **source turns** that already had a record in the
746    /// target thread and were skipped by the idempotence check.
747    pub skipped_existing_turns: usize,
748    /// Number of turns whose content was truncated at the 16 KB cap.
749    /// Only `role == "tool"` turns can trigger this branch.
750    pub truncated_turns: usize,
751    /// Number of turns that were split into multiple chunks at the
752    /// 8 KB boundary. A truncated turn counts as 0 here even if its
753    /// pre-truncation content would have chunked.
754    pub chunked_turns: usize,
755}
756
757/// Scope selector for a keyed slot (Phase 12.1.1). A keyed
758/// `remember`'s slot is always identified by `(scope_owner, key)`:
759/// `Private(agent_id)` slots are per-agent, `Shared` slots are per-file.
760/// `history_in(key, scope)` uses the same selector so the lookup matches
761/// the write exactly.
762///
763/// The convenience [`crate::Mnemo::history`] is equivalent to
764/// `history_in(key, KeyScope::Private(agent.unwrap_or("default")))` —
765/// i.e. `history(key, None)` resolves to the default agent's private
766/// slot, matching the `remember(…, agent_id=None, scope=Private, key=K)`
767/// write path. Use [`crate::Mnemo::history_in`] with
768/// [`KeyScope::Shared`] when you need the shared slot.
769#[derive(Debug, Clone, PartialEq, Eq, Hash)]
770pub enum KeyScope {
771    /// Per-agent slot. The agent id is the one carried on `Memory.agent_id`
772    /// at write time; `remember` with no explicit agent uses `"default"`.
773    Private(String),
774    /// Per-file slot shared across every agent.
775    Shared,
776}
777
778impl KeyScope {
779    /// Default agent id used when `Memory::new(...)` is called without
780    /// `.with_agent(...)` — same `"default"` the [`Memory::new`]
781    /// constructor seeds into `Memory.agent_id`.
782    pub const DEFAULT_AGENT: &'static str = "default";
783
784    /// Build the private slot for the default agent. Shorthand for
785    /// `KeyScope::Private(KeyScope::DEFAULT_AGENT.to_string())`.
786    pub fn private_default() -> Self {
787        KeyScope::Private(Self::DEFAULT_AGENT.to_string())
788    }
789}
790
791/// Outcome of a 12.1 write operation (`remember` with `key`, or `update`).
792///
793/// Plain `remember` without a key always returns a ULID, not a
794/// `WriteOutcome`, because supersession and NOOP detection are tied to
795/// the key slot.
796#[derive(Debug, Clone, Copy, PartialEq, Eq)]
797pub enum WriteOutcome {
798    /// Keyed write with no prior record for this slot. A brand-new record
799    /// was inserted, no record was superseded.
800    Added,
801    /// Keyed write whose content / type / importance / user metadata
802    /// differs from the live record for this slot. A new record was
803    /// inserted and the prior one was marked
804    /// `_mnemo.superseded_by` / `_mnemo.invalid_at`.
805    Superseded,
806    /// No-op: the incoming keyed write is byte-equal to the live record
807    /// for this slot (content, memory type, importance, user metadata).
808    /// No record was written and no catalog page was dirtied. The
809    /// returned id is the existing record's id.
810    Unchanged,
811    /// In-place `update`: fields changed on the target record. The record
812    /// was rewritten copy-on-write so the previous version remains
813    /// visible through snapshots.
814    Updated,
815}
816
817// ---------------------------------------------------------------------------
818// Phase 12.6: recall filters (session_id + per-metadata-key conditions)
819// ---------------------------------------------------------------------------
820
821/// One AND-ed condition applied to a candidate memory's metadata during
822/// filtered recall (Phase 12.6). Terms are combined conjunctively — a
823/// memory passes the filter only if *every* term evaluates true against
824/// its `metadata` map. See [`Filter`] for the surface API.
825///
826/// Values use `serde_json::Value` so the same term shape serializes
827/// cleanly through the MCP / Python / CLI bridges without a parallel
828/// enum per surface.
829#[derive(Debug, Clone)]
830pub enum FilterTerm {
831    /// The memory's `metadata[key]` must be set to any value.
832    Exists(String),
833    /// `metadata[key]` must equal this JSON value (strict JSON equality:
834    /// strings compare case-sensitively, numbers compare as f64).
835    Eq(String, Value),
836    /// `metadata[key]` must equal one of these JSON values.
837    In(String, Vec<Value>),
838    /// `metadata[key]` must be a number in `[min, max]` (inclusive on
839    /// both ends; `None` on either bound means "no bound on that side").
840    /// A non-numeric metadata value fails the term.
841    Range {
842        /// Metadata key.
843        key: String,
844        /// Lower bound (inclusive) — `None` means unbounded below.
845        min: Option<f64>,
846        /// Upper bound (inclusive) — `None` means unbounded above.
847        max: Option<f64>,
848    },
849}
850
851/// Pre-scoring filter applied by [`crate::Mnemo::recall`] (Phase 12.6).
852///
853/// `session_id` restricts candidates to a single session (when the
854/// memory's `session_id` is set). The `terms` are AND-ed metadata
855/// conditions evaluated against each candidate's `metadata` map; see
856/// [`FilterTerm`] for the shapes.
857///
858/// When an ANN index is present, the filter is applied *after* the ANN
859/// shortlist step, so a selective filter can starve the result below
860/// `top_k`. Recall handles that automatically via the Phase 12.1
861/// short-results-on-indexed-path fix (widen 2×, then exact scan).
862#[derive(Debug, Clone, Default)]
863pub struct Filter {
864    /// Only candidates whose `Memory.session_id` equals this value.
865    /// Candidates without a session_id are excluded when this is set.
866    pub session_id: Option<String>,
867    /// AND-ed metadata conditions. Empty → no metadata constraints.
868    pub terms: Vec<FilterTerm>,
869}
870
871impl Filter {
872    /// Builder helper: a filter that keeps only memories in `session`.
873    pub fn new_session(session: impl Into<String>) -> Self {
874        Self {
875            session_id: Some(session.into()),
876            terms: Vec::new(),
877        }
878    }
879
880    /// Restrict to one session_id (builder form).
881    pub fn with_session(mut self, session: impl Into<String>) -> Self {
882        self.session_id = Some(session.into());
883        self
884    }
885
886    /// Require `metadata[key]` to exist (any value).
887    pub fn metadata_exists(mut self, key: impl Into<String>) -> Self {
888        self.terms.push(FilterTerm::Exists(key.into()));
889        self
890    }
891
892    /// Require `metadata[key]` to equal `value`.
893    pub fn metadata_eq(mut self, key: impl Into<String>, value: Value) -> Self {
894        self.terms.push(FilterTerm::Eq(key.into(), value));
895        self
896    }
897
898    /// Require `metadata[key]` to be one of `values`.
899    pub fn metadata_in(mut self, key: impl Into<String>, values: Vec<Value>) -> Self {
900        self.terms.push(FilterTerm::In(key.into(), values));
901        self
902    }
903
904    /// Require `metadata[key]` to be a number in `[min, max]`.
905    pub fn metadata_range(
906        mut self,
907        key: impl Into<String>,
908        min: Option<f64>,
909        max: Option<f64>,
910    ) -> Self {
911        self.terms.push(FilterTerm::Range {
912            key: key.into(),
913            min,
914            max,
915        });
916        self
917    }
918
919    /// `true` iff the filter has no terms and no `session_id`. The
920    /// recall hot path uses this to skip the per-candidate filter
921    /// check entirely.
922    pub fn is_empty(&self) -> bool {
923        self.session_id.is_none() && self.terms.is_empty()
924    }
925
926    /// Phase 12.7a: `true` iff the filter mentions any reserved
927    /// `_mnemo.*` metadata key. Used by `recall` to decide whether
928    /// to default-exclude thread-archive turns
929    /// (`_mnemo.kind = "turn"`). A caller that wants turns in the
930    /// result opts in by adding any `_mnemo.*` term to the filter
931    /// — equality, `$in`, `$exists`, or range — because those are the
932    /// only queries that can usefully target turn records.
933    pub fn mentions_mnemo_meta(&self) -> bool {
934        self.terms.iter().any(|term| {
935            let key = match term {
936                FilterTerm::Exists(k) => k,
937                FilterTerm::Eq(k, _) => k,
938                FilterTerm::In(k, _) => k,
939                FilterTerm::Range { key, .. } => key,
940            };
941            key.starts_with(MNEMO_META_NS)
942        })
943    }
944
945    /// Evaluate the filter against one memory. Returns `true` if the
946    /// memory passes every term.
947    pub fn matches(&self, m: &Memory) -> bool {
948        if let Some(s) = &self.session_id {
949            match &m.session_id {
950                Some(ms) if ms == s => {}
951                _ => return false,
952            }
953        }
954        for term in &self.terms {
955            if !term_matches(term, &m.metadata) {
956                return false;
957            }
958        }
959        true
960    }
961}
962
963fn term_matches(term: &FilterTerm, metadata: &Map<String, Value>) -> bool {
964    match term {
965        FilterTerm::Exists(key) => metadata.contains_key(key),
966        FilterTerm::Eq(key, v) => metadata.get(key).is_some_and(|got| values_equal(got, v)),
967        FilterTerm::In(key, options) => metadata
968            .get(key)
969            .is_some_and(|got| options.iter().any(|opt| values_equal(got, opt))),
970        FilterTerm::Range { key, min, max } => metadata
971            .get(key)
972            .and_then(|v| v.as_f64())
973            .is_some_and(|n| min.map_or(true, |m_| n >= m_) && max.map_or(true, |m_| n <= m_)),
974    }
975}
976
977/// JSON value equality for filter terms. serde_json's `Value: PartialEq`
978/// compares numbers by exact representation (integer vs float). The
979/// filter surface should treat `5` and `5.0` as equal, so we compare
980/// numbers through `as_f64` when both sides are numbers; everything
981/// else falls back to structural equality.
982fn values_equal(a: &Value, b: &Value) -> bool {
983    match (a, b) {
984        (Value::Number(x), Value::Number(y)) => match (x.as_f64(), y.as_f64()) {
985            (Some(xf), Some(yf)) => (xf - yf).abs() < f64::EPSILON,
986            _ => x == y,
987        },
988        (x, y) => x == y,
989    }
990}
991
992/// Distance/similarity metric for vector comparison.
993#[derive(Clone, Copy, Debug, PartialEq, Eq)]
994pub enum Metric {
995    /// Cosine similarity (orientation only).
996    Cosine,
997    /// Negative Euclidean distance, mapped into a `(0,1]` similarity.
998    L2,
999    /// Raw dot product.
1000    Dot,
1001}
1002
1003/// Compute a *similarity* (higher = more relevant) under the given metric.
1004pub fn similarity(metric: Metric, a: &[f32], b: &[f32]) -> f32 {
1005    match metric {
1006        Metric::Cosine => cosine(a, b),
1007        Metric::Dot => dot(a, b),
1008        Metric::L2 => {
1009            let d = euclidean(a, b);
1010            1.0 / (1.0 + d)
1011        }
1012    }
1013}
1014
1015fn dot(a: &[f32], b: &[f32]) -> f32 {
1016    a.iter().zip(b).map(|(x, y)| x * y).sum()
1017}
1018
1019/// Cosine similarity in `[-1.0, 1.0]`; returns `0.0` if either vector
1020/// has zero magnitude. `pub(crate)` so [`crate::Mnemo::cache_get_semantic`]
1021/// (Phase 10.2) can reuse the same scoring routine that recall uses,
1022/// keeping the two paths semantically identical.
1023pub(crate) fn cosine(a: &[f32], b: &[f32]) -> f32 {
1024    let na = dot(a, a).sqrt();
1025    let nb = dot(b, b).sqrt();
1026    if na == 0.0 || nb == 0.0 {
1027        0.0
1028    } else {
1029        dot(a, b) / (na * nb)
1030    }
1031}
1032
1033fn euclidean(a: &[f32], b: &[f32]) -> f32 {
1034    a.iter()
1035        .zip(b)
1036        .map(|(x, y)| (x - y) * (x - y))
1037        .sum::<f32>()
1038        .sqrt()
1039}
1040
1041/// Weights for the multi-signal recall score (scoring v2, shipped in
1042/// v0.7.0).
1043///
1044/// ```text
1045/// score = similarity
1046///       + max(similarity, 0)
1047///         · ( β · recency
1048///           + γ · (importance − 0.5)
1049///           + δ · tanh(access_count / 5) )
1050///
1051/// recency = exp( -ln(2) · age_secs / half_life_secs )
1052/// age_secs = now - memory.created_at
1053/// ```
1054///
1055/// Design notes (from the 2026-11-06 scoring-v2 bake-off; see the
1056/// `ranking_v2` harness at `mnemo/examples/ranking_v2.rs`):
1057/// * **Similarity-gated.** The recency / importance / usage terms are
1058///   multiplied by `max(similarity, 0)`, so a stale-but-semantically-off
1059///   memory can't ride recency or importance past a semantically-on-point
1060///   one. Pre-v0.7.0 scoring was purely additive.
1061/// * **Importance is centered at 0.5.** A default-importance memory
1062///   contributes zero importance bonus; importance only pushes a score up
1063///   or down from that reference point.
1064/// * **Usage is saturating.** `tanh(uses/5)` caps the feedback bonus so
1065///   a heavily-returned memory can't keep accumulating weight past a
1066///   plateau. At 5 uses the bonus is ~76% of its asymptote; at 10, ~96%.
1067/// * **Recency is decoupled from the feedback loop.** Age is measured
1068///   from `created_at`, not `accessed_at`, so a `recall()` call does not
1069///   bump its own results' recency scores on later queries.
1070///
1071/// The output of this scorer is on a **different scale** from the
1072/// pre-v0.7.0 formula. Callers that compared raw scores to a fixed
1073/// threshold should re-pick the threshold against the new scale.
1074#[derive(Clone, Copy, Debug)]
1075pub struct ScoreWeights {
1076    /// Weight on semantic similarity. Kept in the struct for calibration
1077    /// hooks and symmetry with the other weights; scoring v2 always uses
1078    /// it as `1.0` because the leading `similarity` term is ungated and
1079    /// the whole score sits on the cosine-similarity scale by design.
1080    pub alpha: f32,
1081    /// Weight on the recency bonus (multiplied by `max(sim, 0)`).
1082    pub beta: f32,
1083    /// Weight on the importance bonus `(importance − 0.5)`, centered at
1084    /// the default-importance 0.5 (multiplied by `max(sim, 0)`).
1085    pub gamma: f32,
1086    /// Weight on the saturating usage bonus `tanh(access_count / 5)`
1087    /// (multiplied by `max(sim, 0)`).
1088    pub delta: f32,
1089    /// Half-life of the recency term, in seconds.
1090    pub half_life_secs: f32,
1091}
1092
1093impl Default for ScoreWeights {
1094    fn default() -> Self {
1095        // Tuned on the Phase 6 corpus + the ranking_v2 age-spread
1096        // fixture; see `ranking_v2.checks` in perf_v0.5.0.json.
1097        Self {
1098            alpha: 1.0,
1099            beta: 0.3,
1100            gamma: 0.2,
1101            delta: 0.1,
1102            half_life_secs: 7.0 * 24.0 * 3600.0,
1103        }
1104    }
1105}
1106
1107/// Saturation constant K for the usage term `tanh(access_count / K)`.
1108/// K = 5 means ~76% of the asymptote at 5 uses and ~96% at 10 — matches
1109/// the "a few repeats is signal; past that it's a habit" intuition.
1110const USAGE_SAT_K: f32 = 5.0;
1111
1112impl ScoreWeights {
1113    /// Combine the four signals into a single score. See the struct docs
1114    /// for the full formula and the scoring-v2 rationale.
1115    pub fn score(&self, similarity: f32, age_secs: f32, importance: f32, access_count: u32) -> f32 {
1116        // Recency is in [0, 1]: 1.0 at age 0, 0.5 at one half-life, etc.
1117        let recency = (-std::f32::consts::LN_2 * age_secs.max(0.0) / self.half_life_secs).exp();
1118        // Saturating usage in [0, 1); tanh caps feedback-loop drift.
1119        let usage = (access_count as f32 / USAGE_SAT_K).tanh();
1120        // Gate the non-similarity bonuses by `max(sim, 0)` so a
1121        // negative-cosine candidate (should be rare after the metric
1122        // layer, but possible with `Metric::Dot` or short-text L2)
1123        // can't collect positive bonuses, and a zero-similarity
1124        // candidate collects no bonus either.
1125        let gate = similarity.max(0.0);
1126        self.alpha * similarity
1127            + gate * (self.beta * recency + self.gamma * (importance - 0.5) + self.delta * usage)
1128    }
1129}