Skip to main content

rightkit_memory_client/
store.rs

1//! Durable cross-session memory store (ENG-81).
2//!
3//! Every write passes through [`screening::screen`] first; a rejected
4//! candidate never reaches the map, so secrets/credentials and volatile
5//! junk cannot be persisted, retrieved, or synced. The store is
6//! backend-neutral (an in-memory `BTreeMap` keyed by id) so a daemon or the
7//! mobile runtime can wrap it with whatever persistence layer (SQLite,
8//! MemRight, etc.) it already owns without this crate taking a DB
9//! dependency.
10
11use crate::screening::{self, RejectReason, ScreeningVerdict};
12use serde::{Deserialize, Serialize};
13
14/// A persisted memory row of the mobile-only local store.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct MemoryRecord {
17    pub id: String,
18    pub scope: String,
19    pub content: String,
20    pub digest: String,
21    pub subject_key: String,
22    pub created_at: String,
23    pub access_count: u32,
24    /// User-controlled hard pin: never evicted by capacity/age policy,
25    /// mirroring `TranscriptMessage::pinned` semantics (ENG-104) but scoped
26    /// to durable memory rather than the live transcript.
27    pub pinned: bool,
28}
29
30/// Caller-supplied fields for a new memory write. `id`, `digest`, and
31/// `subject_key` stay caller-computed so the store never re-implements hashing/keyword-normalization
32/// that the daemon's existing pipeline owns.
33#[derive(Debug, Clone)]
34pub struct NewMemory {
35    pub id: String,
36    pub scope: String,
37    pub content: String,
38    pub digest: String,
39    pub subject_key: String,
40    pub created_at: String,
41}
42
43/// Why a write did not reach the store.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45#[serde(rename_all = "snake_case", tag = "reason")]
46pub enum WriteRejection {
47    /// Failed screening; carries the specific reason for caller diagnostics
48    /// (e.g. surfacing "not saved: looked like a credential" in the UI).
49    Screened { detail: RejectReason },
50    /// An id collision with an existing record. Callers should use
51    /// delete-then-write for intentional
52    /// overwrites; a bare `write` never silently clobbers.
53    DuplicateId,
54}
55
56impl From<RejectReason> for WriteRejection {
57    fn from(detail: RejectReason) -> Self {
58        WriteRejection::Screened { detail }
59    }
60}
61
62/// Durable, screened memory store.
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct MemoryStore {
65    records: std::collections::BTreeMap<String, MemoryRecord>,
66}
67
68impl MemoryStore {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Screen and insert a new memory. Rejects duplicates and anything that
74    /// fails [`screening::screen`]; both are reported, never silently
75    /// dropped, so a UI can tell the user why a candidate wasn't saved.
76    pub fn write(&mut self, candidate: NewMemory) -> Result<&MemoryRecord, WriteRejection> {
77        if self.records.contains_key(&candidate.id) {
78            return Err(WriteRejection::DuplicateId);
79        }
80        match screening::screen(&candidate.content) {
81            ScreeningVerdict::Reject { reason } => Err(WriteRejection::from(reason)),
82            ScreeningVerdict::Admit => {
83                let record = MemoryRecord {
84                    id: candidate.id.clone(),
85                    scope: candidate.scope,
86                    content: candidate.content,
87                    digest: candidate.digest,
88                    subject_key: candidate.subject_key,
89                    created_at: candidate.created_at,
90                    access_count: 0,
91                    pinned: false,
92                };
93                self.records.insert(candidate.id.clone(), record);
94                Ok(self.records.get(&candidate.id).expect("just inserted"))
95            }
96        }
97    }
98
99    /// Explicit user delete. Always allowed, including for pinned rows --
100    /// pinning protects a memory from automatic eviction, not from the user
101    /// deliberately removing it.
102    pub fn delete(&mut self, id: &str) -> Option<MemoryRecord> {
103        self.records.remove(id)
104    }
105
106    pub fn get(&self, id: &str) -> Option<&MemoryRecord> {
107        self.records.get(id)
108    }
109
110    pub fn set_pinned(&mut self, id: &str, pinned: bool) -> bool {
111        if let Some(record) = self.records.get_mut(id) {
112            record.pinned = pinned;
113            true
114        } else {
115            false
116        }
117    }
118
119    pub fn record_access(&mut self, id: &str) {
120        if let Some(record) = self.records.get_mut(id) {
121            record.access_count = record.access_count.saturating_add(1);
122        }
123    }
124
125    /// All records in a scope (or every record when `scope` is `None`),
126    /// unordered (callers rank).
127    pub fn list(&self, scope: Option<&str>) -> Vec<&MemoryRecord> {
128        self.records
129            .values()
130            .filter(|record| match scope {
131                Some(s) => record.scope == s,
132                None => true,
133            })
134            .collect()
135    }
136
137    pub fn len(&self) -> usize {
138        self.records.len()
139    }
140
141    pub fn is_empty(&self) -> bool {
142        self.records.is_empty()
143    }
144}