1use std::collections::{HashMap, HashSet};
11use std::path::Path;
12use std::sync::Arc;
13
14use thiserror::Error;
15
16use crate::{
17 config::Config,
18 db::{AimemDb, DbError},
19 embedder::Embedder,
20 search::{SearchError, Searcher},
21 types::{Drawer, DrawerFilingRequest},
22};
23
24#[derive(Debug, Error)]
25pub enum LayerError {
26 #[error("db: {0}")]
27 Db(#[from] DbError),
28 #[error("search: {0}")]
29 Search(#[from] SearchError),
30 #[error("io: {0}")]
31 Io(#[from] std::io::Error),
32}
33
34pub async fn l0_render(identity_path: &Path) -> String {
38 if identity_path.exists() {
39 match tokio::fs::read_to_string(identity_path).await {
40 Ok(text) => return text.trim().to_string(),
41 Err(_) => {}
42 }
43 }
44 "## L0 — IDENTITY\nNo identity configured. Create ~/.aimem/identity.txt".to_string()
45}
46
47const L1_MAX_DRAWERS: usize = 15;
50const L1_MAX_CHARS: usize = 3200;
51
52pub async fn l1_generate(db: &AimemDb, wing: Option<&str>) -> Result<String, LayerError> {
54 let drawers = db.fetch_drawers(wing, None, 200).await?;
55
56 if drawers.is_empty() {
57 return Ok("## L1 — No memories yet.".to_string());
58 }
59
60 let mut by_room: HashMap<String, Vec<Drawer>> = HashMap::new();
62 for d in drawers.into_iter().take(L1_MAX_DRAWERS) {
63 by_room.entry(d.room.clone()).or_default().push(d);
64 }
65
66 let mut lines = vec!["## L1 — ESSENTIAL STORY".to_string()];
67 let mut total_len = 0usize;
68
69 let mut rooms: Vec<_> = by_room.iter().collect();
70 rooms.sort_by_key(|(r, _)| r.as_str());
71
72 for (room, entries) in rooms {
73 let room_line = format!("\n[{}]", room);
74 lines.push(room_line.clone());
75 total_len += room_line.len();
76
77 for d in entries {
78 let snippet: String = d
79 .content
80 .trim()
81 .replace('\n', " ")
82 .chars()
83 .take(200)
84 .collect();
85 let snippet = if d.content.len() > 200 {
86 format!("{}...", snippet)
87 } else {
88 snippet
89 };
90
91 let source_tag = d
92 .source_file
93 .as_deref()
94 .and_then(|s| Path::new(s).file_name())
95 .map(|n| format!(" ({})", n.to_string_lossy()))
96 .unwrap_or_default();
97
98 let entry = format!(" - {}{}", snippet, source_tag);
99
100 if total_len + entry.len() > L1_MAX_CHARS {
101 lines.push(" ... (more in L3 search)".to_string());
102 return Ok(lines.join("\n"));
103 }
104 total_len += entry.len();
105 lines.push(entry);
106 }
107 }
108
109 Ok(lines.join("\n"))
110}
111
112pub async fn l2_retrieve(
116 db: &AimemDb,
117 wing: Option<&str>,
118 room: Option<&str>,
119 n: usize,
120) -> Result<String, LayerError> {
121 let drawers = db.fetch_drawers(wing, room, n).await?;
122
123 if drawers.is_empty() {
124 let label = match (wing, room) {
125 (Some(w), Some(r)) => format!("wing={w} room={r}"),
126 (Some(w), None) => format!("wing={w}"),
127 (None, Some(r)) => format!("room={r}"),
128 (None, None) => "all".to_string(),
129 };
130 return Ok(format!("No drawers found for {label}."));
131 }
132
133 let mut lines = vec![format!("## L2 — ON-DEMAND ({} drawers)", drawers.len())];
134 for d in &drawers {
135 let snippet: String = d
136 .content
137 .trim()
138 .replace('\n', " ")
139 .chars()
140 .take(300)
141 .collect();
142 let snippet = if d.content.len() > 300 {
143 format!("{}...", snippet)
144 } else {
145 snippet
146 };
147 let source_tag = d
148 .source_file
149 .as_deref()
150 .and_then(|s| Path::new(s).file_name())
151 .map(|n| format!(" ({})", n.to_string_lossy()))
152 .unwrap_or_default();
153 lines.push(format!(" [{}] {}{}", d.room, snippet, source_tag));
154 }
155 Ok(lines.join("\n"))
156}
157
158pub async fn l3_search(
162 searcher: &Searcher,
163 query: &str,
164 wing: Option<&str>,
165 room: Option<&str>,
166 n: usize,
167) -> Result<String, LayerError> {
168 let results = searcher.hybrid_search(query, wing, room, n).await?;
169
170 if results.is_empty() {
171 return Ok("No results found.".to_string());
172 }
173
174 let mut lines = vec![format!("## L3 — SEARCH RESULTS for \"{}\"", query)];
175 for (i, r) in results.iter().enumerate() {
176 let snippet: String = r
177 .drawer
178 .content
179 .trim()
180 .replace('\n', " ")
181 .chars()
182 .take(300)
183 .collect();
184 let snippet = if r.drawer.content.len() > 300 {
185 format!("{}...", snippet)
186 } else {
187 snippet
188 };
189 let source = r
190 .drawer
191 .source_file
192 .as_deref()
193 .and_then(|s| Path::new(s).file_name())
194 .map(|n| n.to_string_lossy().to_string())
195 .unwrap_or_default();
196
197 lines.push(format!(
198 " [{}] {}/{} (score={:.3}{}{})",
199 i + 1,
200 r.drawer.wing,
201 r.drawer.room,
202 r.score,
203 r.semantic_similarity
204 .map(|sim| format!(", sim={sim:.3}"))
205 .unwrap_or_default(),
206 r.keyword_score
207 .map(|score| format!(", kw={score:.3}"))
208 .unwrap_or_default(),
209 ));
210 lines.push(format!(" {}", snippet));
211 if !source.is_empty() {
212 lines.push(format!(" src: {}", source));
213 }
214 }
215 Ok(lines.join("\n"))
216}
217
218#[derive(Debug, Clone)]
222pub struct MemoryStack {
223 db: AimemDb,
224 searcher: Searcher,
225 identity_path: std::path::PathBuf,
226}
227
228impl MemoryStack {
229 pub fn new(db: AimemDb, embedder: Arc<dyn Embedder>, cfg: &Config) -> Self {
230 let searcher = Searcher::new(db.clone(), embedder);
231 Self {
232 db,
233 searcher,
234 identity_path: cfg.identity_path.clone(),
235 }
236 }
237
238 pub async fn wake_up(&self, wing: Option<&str>) -> Result<String, LayerError> {
241 let l0 = l0_render(&self.identity_path).await;
242 let l1 = l1_generate(&self.db, wing).await?;
243 Ok(format!("{}\n\n{}", l0, l1))
244 }
245
246 pub async fn recall(
248 &self,
249 wing: Option<&str>,
250 room: Option<&str>,
251 ) -> Result<String, LayerError> {
252 l2_retrieve(&self.db, wing, room, 10).await
253 }
254
255 pub async fn search(
257 &self,
258 query: &str,
259 wing: Option<&str>,
260 room: Option<&str>,
261 ) -> Result<String, LayerError> {
262 l3_search(&self.searcher, query, wing, room, 5).await
263 }
264
265 pub async fn file_drawer(
268 &self,
269 wing: &str,
270 room: &str,
271 content: String,
272 parts: Vec<crate::types::ContentPart>,
273 agent: &str,
274 ) -> Result<String, LayerError> {
275 let now = chrono::Utc::now().to_rfc3339();
276 let digest = md5::compute(format!("{wing}{room}{content}{now}").as_bytes());
277 let id = format!("mem_{wing}_{digest:x}");
278
279 self.file_drawer_with_id(&id, wing, room, content, parts, None, 0, agent)
280 .await?;
281 Ok(id)
282 }
283
284 pub async fn file_drawer_with_id(
287 &self,
288 id: &str,
289 wing: &str,
290 room: &str,
291 content: String,
292 parts: Vec<crate::types::ContentPart>,
293 source_file: Option<&str>,
294 chunk_index: i64,
295 agent: &str,
296 ) -> Result<bool, LayerError> {
297 let request = build_filing_request(
298 id,
299 wing,
300 room,
301 content,
302 parts,
303 source_file,
304 chunk_index,
305 agent,
306 );
307 let mut results = self.file_drawers_with_ids(&[request]).await?;
308 Ok(results.pop().unwrap_or(false))
309 }
310
311 pub async fn file_drawers_with_ids(
323 &self,
324 requests: &[DrawerFilingRequest],
325 ) -> Result<Vec<bool>, LayerError> {
326 if requests.is_empty() {
327 return Ok(Vec::new());
328 }
329
330 let mut results = vec![false; requests.len()];
331 let mut pending_indexes = Vec::new();
332 let mut pending_parts = Vec::new();
333 let mut seen_ids = HashSet::new();
334
335 for (index, request) in requests.iter().enumerate() {
336 if !seen_ids.insert(request.id.clone()) {
337 continue;
338 }
339 if self.db.drawer_exists(&request.id).await? {
340 continue;
341 }
342 pending_indexes.push(index);
343 pending_parts.push(parts_for_embedding(request));
344 }
345
346 let embedder = self.searcher.embedder();
347 let embeddings = if let Some(ref embedder) = embedder {
348 if pending_parts.is_empty() {
349 Some(Vec::new())
350 } else {
351 let embeddings = embedder
352 .embed(&pending_parts)
353 .await
354 .map_err(SearchError::from)?;
355 if embeddings.len() != pending_parts.len() {
356 return Err(SearchError::EmbedBatchSizeMismatch {
357 expected: pending_parts.len(),
358 actual: embeddings.len(),
359 }
360 .into());
361 }
362 Some(embeddings)
363 }
364 } else {
365 None
366 };
367
368 let default_filed_at = chrono::Utc::now().to_rfc3339();
369
370 for (pending_offset, request_index) in pending_indexes.into_iter().enumerate() {
371 let request = &requests[request_index];
372 let drawer = drawer_from_request(request, &default_filed_at);
373 let inserted = if let (Some(embedder), Some(embeddings)) =
374 (embedder.as_ref(), embeddings.as_ref())
375 {
376 self.db
377 .insert_drawer_with_profile(
378 &drawer,
379 Some(embeddings[pending_offset].as_slice()),
380 embedder.provider_name(),
381 embedder.model_name(),
382 )
383 .await?
384 } else {
385 self.db.insert_drawer(&drawer, None).await?
386 };
387 results[request_index] = inserted;
388 }
389
390 Ok(results)
391 }
392
393 pub async fn file_text(
395 &self,
396 wing: &str,
397 room: &str,
398 content: impl Into<String>,
399 agent: &str,
400 ) -> Result<String, LayerError> {
401 self.file_drawer(wing, room, content.into(), Vec::new(), agent)
402 .await
403 }
404
405 pub fn searcher(&self) -> &Searcher {
407 &self.searcher
408 }
409
410 pub async fn status(&self) -> Result<serde_json::Value, LayerError> {
412 let count = self.db.drawer_count().await?;
413 let (wings, rooms) = self.db.taxonomy().await?;
414 let l0_exists = self.identity_path.exists();
415 let l0_tokens = if l0_exists {
416 std::fs::read_to_string(&self.identity_path)
417 .map(|s| s.len() / 4)
418 .unwrap_or(0)
419 } else {
420 0
421 };
422
423 Ok(serde_json::json!({
424 "total_drawers": count,
425 "identity_path": self.identity_path.display().to_string(),
426 "identity_exists": l0_exists,
427 "identity_tokens_est": l0_tokens,
428 "wings": wings.into_iter().map(|(w, c)| serde_json::json!({"wing": w, "count": c})).collect::<Vec<_>>(),
429 "rooms": rooms.into_iter().map(|(r, c)| serde_json::json!({"room": r, "count": c})).collect::<Vec<_>>(),
430 }))
431 }
432}
433
434fn build_filing_request(
435 id: &str,
436 wing: &str,
437 room: &str,
438 content: String,
439 parts: Vec<crate::types::ContentPart>,
440 source_file: Option<&str>,
441 chunk_index: i64,
442 agent: &str,
443) -> DrawerFilingRequest {
444 let mut request = DrawerFilingRequest::new(id, wing, room, content, agent)
445 .with_parts(parts)
446 .with_chunk_index(chunk_index);
447 if let Some(source_file) = source_file {
448 request = request.with_source_file(source_file);
449 }
450 request
451}
452
453fn parts_for_embedding(request: &DrawerFilingRequest) -> Vec<crate::types::ContentPart> {
454 if request.parts.is_empty() {
455 vec![crate::types::ContentPart::text(request.content.clone())]
456 } else {
457 request.parts.clone()
458 }
459}
460
461fn drawer_from_request(request: &DrawerFilingRequest, default_filed_at: &str) -> Drawer {
462 let mut drawer = Drawer::new(
463 request.id.clone(),
464 request.wing.clone(),
465 request.room.clone(),
466 request.content.clone(),
467 request.added_by.clone(),
468 )
469 .with_parts(request.parts.clone())
470 .with_chunk_index(request.chunk_index)
471 .with_filed_at(
472 request
473 .filed_at
474 .clone()
475 .unwrap_or_else(|| default_filed_at.to_string()),
476 );
477
478 if let Some(source_file) = request.source_file.as_deref() {
479 drawer = drawer.with_source_file(source_file);
480 }
481
482 drawer
483}