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}