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}