Skip to main content

ijima_core/
store.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! The storage contract — the abstraction boundary between Ijima's domain
5//! logic and the SurrealDB / SQLite / mock backends.
6//!
7//! Every backend implements [`Store`] (memory palace + session context) and
8//! (in future) [`KnowledgeGraph`](crate::knowledge::KnowledgeGraph).
9
10use async_trait::async_trait;
11
12use crate::{
13    AcceptedExtraction, DiaryEntry, Embedding, Memory, MemoryId, NamespaceId, QueuedExtraction,
14    RepoDirectory, Result, Session, SessionId, SessionTurn,
15    harness::Harness,
16    palace::{PalaceGraph, ProjectTaxon, Room, TunnelTraversal},
17};
18
19/// A scored semantic-search hit: the matched [`Memory`] plus its cosine
20/// similarity to the query (0.0–1.0, higher = more relevant).
21///
22/// Required both for cross-namespace result merging (the pi integration's
23/// `scope=visible` search) and for downstream "% match" display.
24#[derive(Debug, Clone, PartialEq)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub struct SearchHit {
27    /// The matched memory.
28    pub memory: Memory,
29    /// Cosine similarity to the query (0.0–1.0).
30    pub similarity: f32,
31}
32/// Global memory-palace statistics (across all namespaces).
33#[derive(Debug, Clone, Default, PartialEq, Eq)]
34#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
35pub struct StoreStats {
36    /// Total memories across every namespace.
37    pub total_memories: usize,
38    /// Per-namespace breakdown.
39    pub namespaces: Vec<NamespaceCount>,
40}
41
42/// One namespace's memory count.
43#[derive(Debug, Clone, PartialEq, Eq)]
44#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
45pub struct NamespaceCount {
46    /// The namespace id.
47    pub namespace: String,
48    /// Memories in that namespace.
49    pub memories: usize,
50}
51
52/// The storage contract.
53///
54/// Implementations must be `Send + Sync` (shared across an axum handler
55/// pool). Async via [`async_trait`], matching the DB backends' async I/O.
56#[async_trait]
57pub trait Store: Send + Sync {
58    // ===== Memory palace =====
59
60    /// Stores a curated memory under `ns`, performing content-hash +
61    /// semantic dedup. Returns the stored id.
62    async fn store_memory(&self, ns: &NamespaceId, memory: Memory) -> Result<MemoryId>;
63
64    /// Recalls a single memory by id within `ns`. Returns `None` if the
65    /// id is absent or belongs to a different namespace (isolation).
66    async fn recall_memory(&self, ns: &NamespaceId, id: &MemoryId) -> Result<Option<Memory>>;
67
68    /// Deletes a memory by id within `ns`.
69    async fn delete_memory(&self, ns: &NamespaceId, id: &MemoryId) -> Result<()>;
70
71    /// Lists up to `limit` memories in `ns`, ranked by importance DESC
72    /// then recency DESC. Powers wake-up composition (L1a personal
73    /// essentials, L1b doctrine baseline).
74    async fn list_memories(&self, ns: &NamespaceId, limit: usize) -> Result<Vec<Memory>>;
75
76    /// Lists memories in `ns`, optionally filtered to `project`/`topic`.
77    /// Powers `GET /memories` (the `memory_recall` browse path — distinct
78    /// from [`Self::list_memories`], which is the importance-ranked
79    /// wake-up feed). Default: fetch a cap, filter in Rust, truncate.
80    /// Backends MAY override with a native filtered query.
81    async fn list_memories_filtered(
82        &self,
83        ns: &NamespaceId,
84        project: Option<&str>,
85        topic: Option<&str>,
86        limit: usize,
87    ) -> Result<Vec<Memory>> {
88        let cap = if project.is_some() || topic.is_some() {
89            500
90        } else {
91            limit
92        };
93        let mut mems = self.list_memories(ns, cap).await?;
94        if let Some(p) = project {
95            mems.retain(|m| m.project == p);
96        }
97        if let Some(t) = topic {
98            mems.retain(|m| m.topic == t);
99        }
100        mems.truncate(limit);
101        Ok(mems)
102    }
103
104    /// Global store statistics across all namespaces (operator/admin
105    /// view). Powers `GET /status`.
106    async fn store_stats(&self) -> Result<StoreStats>;
107
108    /// Checks whether a memory with identical content already exists in
109    /// `ns` (content-hash dedup). Returns the existing [`MemoryId`] if so.
110    async fn check_duplicate(&self, ns: &NamespaceId, content: &str) -> Result<Option<MemoryId>>;
111
112    /// Semantic search over memories in `ns` by nearest embedding.
113    ///
114    /// Implementations back this with a vector index (SurrealDB MTREE,
115    /// pgvector). v0 backends MAY return [`crate::IjimaError::Store`] until
116    /// the vector index is wired.
117    async fn search_memories(
118        &self,
119        ns: &NamespaceId,
120        embedding: &Embedding,
121        limit: usize,
122    ) -> Result<Vec<SearchHit>>;
123
124    // ===== Palace organization (Phase 3.1 + 3.2) =====
125
126    /// Lists rooms (topic cells) in `ns`, optionally filtered to a single
127    /// project. Each room carries its memory count. Ordered by count desc.
128    async fn list_rooms(
129        &self,
130        ns: &NamespaceId,
131        project: Option<&str>,
132        limit: usize,
133    ) -> Result<Vec<Room>>;
134
135    /// Full project → topic → count taxonomy of `ns`. Powers
136    /// `getTaxonomy` navigation.
137    async fn taxonomy(&self, ns: &NamespaceId) -> Result<Vec<ProjectTaxon>>;
138
139    /// The palace graph: projects as nodes, shared-topic tunnels as edges.
140    /// Powers `getPalaceGraph` — *"what connects these projects?"*
141    async fn palace_graph(&self, ns: &NamespaceId) -> Result<PalaceGraph>;
142
143    /// Traverses a tunnel: returns the actual memories from both projects on
144    /// the shared `topic`, so the caller can see what connects them.
145    async fn traverse_tunnel(
146        &self,
147        ns: &NamespaceId,
148        topic: &str,
149        project_a: &str,
150        project_b: &str,
151        limit: usize,
152    ) -> Result<TunnelTraversal>;
153
154    // ===== Session-context repository =====
155
156    /// Appends a raw turn to the session transcript under `ns`.
157    async fn ingest_turn(&self, ns: &NamespaceId, turn: SessionTurn) -> Result<()>;
158
159    /// Returns the last `limit` turns of `session` under `ns`, in order.
160    async fn session_turns(
161        &self,
162        ns: &NamespaceId,
163        session: &SessionId,
164        limit: usize,
165    ) -> Result<Vec<SessionTurn>>;
166
167    /// Creates or updates a session's metadata under `ns` (upsert by
168    /// id). Call when a session starts; turns reference the session id.
169    /// `ended_at` is set via [`Self::end_session`].
170    async fn create_session(&self, ns: &NamespaceId, session: Session) -> Result<SessionId>;
171
172    /// Lists up to `limit` sessions in `ns`, newest first, optionally
173    /// filtered by `harness`.
174    async fn list_sessions(
175        &self,
176        ns: &NamespaceId,
177        harness: Option<&Harness>,
178        limit: usize,
179    ) -> Result<Vec<Session>>;
180
181    /// Marks a session as ended (sets `ended_at`). Scoped by `ns` so a
182    /// principal can only end sessions in their own namespace.
183    async fn end_session(
184        &self,
185        ns: &NamespaceId,
186        session: &SessionId,
187        ended_at: String,
188    ) -> Result<()>;
189
190    // ===== Diaries (Phase 3.3) =====
191
192    /// Appends a diary entry under `ns`.
193    async fn write_diary(&self, ns: &NamespaceId, entry: DiaryEntry) -> Result<()>;
194
195    /// Returns the last `limit` entries of `agent`'s diary under `ns`, in
196    /// chronological order.
197    async fn read_diary(
198        &self,
199        ns: &NamespaceId,
200        agent: &str,
201        limit: usize,
202    ) -> Result<Vec<DiaryEntry>>;
203
204    // ===== Repo directory (global — Context Mapper) =====
205
206    /// Registers or upserts a repository in the global registry (keyed by
207    /// `name`). Powers `POST /repos` — the canonical Anima roster.
208    async fn register_repo(&self, repo: RepoDirectory) -> Result<()>;
209
210    /// Lists every registered repository (the ecosystem roster).
211    async fn list_repos(&self) -> Result<Vec<RepoDirectory>>;
212
213    /// Reverse-resolves a working directory to its registered repo: the
214    /// most specific repo whose `path` is a prefix of `cwd` (after
215    /// normalizing). Powers `GET /repos/resolve` (CWD → project).
216    async fn resolve_repo(&self, cwd: &str) -> Result<Option<RepoDirectory>> {
217        let target = crate::repo::normalize_path(cwd);
218        let repos = self.list_repos().await?;
219        Ok(repos
220            .into_iter()
221            .filter(|r| target == r.path || target.starts_with(&format!("{}/", r.path)))
222            .max_by_key(|r| r.path.len()))
223    }
224
225    // ===== Mining review queue (ADR M2, M3) =====
226
227    /// Stages a PendingReview extraction in the per-namespace queue.
228    async fn enqueue_extraction(
229        &self,
230        ns: &NamespaceId,
231        memory: Memory,
232        confidence: f32,
233    ) -> Result<String>;
234
235    /// Lists pending extractions in `ns`, newest first.
236    async fn list_pending(&self, ns: &NamespaceId, limit: usize) -> Result<Vec<QueuedExtraction>>;
237
238    /// Accepts a queued extraction: promotes it to the palace and removes
239    /// it from the queue.
240    async fn accept_extraction(
241        &self,
242        ns: &NamespaceId,
243        queue_id: &str,
244    ) -> Result<AcceptedExtraction>;
245
246    /// Rejects a queued extraction: drops it from the queue without promoting.
247    async fn reject_extraction(&self, ns: &NamespaceId, queue_id: &str) -> Result<()>;
248}