Skip to main content

aimem_core/
miner.rs

1//! File miner — scan a project directory and file chunks into AiMem.
2//!
3//! Mirrors Python's miner.py.  Reads `aimem.yaml` from the project root
4//! to discover the wing name and room definitions.
5
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10use thiserror::Error;
11use walkdir::WalkDir;
12
13use crate::{
14    db::{AimemDb, DbError},
15    embedder::{EmbedError, Embedder},
16    types::Drawer,
17};
18
19#[derive(Debug, Error)]
20pub enum MinerError {
21    #[error("db: {0}")]
22    Db(#[from] DbError),
23    #[error("embed: {0}")]
24    Embed(#[from] EmbedError),
25    #[error("io: {0}")]
26    Io(#[from] std::io::Error),
27    #[error("yaml: {0}")]
28    Yaml(#[from] serde_yaml::Error),
29    #[error("config not found at {0}")]
30    ConfigNotFound(String),
31}
32
33// ── Constants ────────────────────────────────────────────────────────────────
34
35const CHUNK_SIZE: usize = 800; // chars per drawer
36const CHUNK_OVERLAP: usize = 100; // overlap between adjacent chunks
37pub const MIN_CHUNK: usize = 50; // skip tiny chunks
38
39static READABLE_EXT: &[&str] = &[
40    "txt",
41    "md",
42    "py",
43    "js",
44    "ts",
45    "jsx",
46    "tsx",
47    "json",
48    "yaml",
49    "yml",
50    "html",
51    "css",
52    "java",
53    "go",
54    "rs",
55    "rb",
56    "sh",
57    "csv",
58    "sql",
59    "toml",
60    "c",
61    "cpp",
62    "h",
63    "hpp",
64    "swift",
65    "kt",
66    "dart",
67    "lua",
68    "vim",
69    "conf",
70    "ini",
71    "env",
72    "Makefile",
73    "Dockerfile",
74];
75
76static SKIP_DIRS: &[&str] = &[
77    ".git",
78    "node_modules",
79    "__pycache__",
80    ".venv",
81    "venv",
82    "env",
83    "dist",
84    "build",
85    ".next",
86    "coverage",
87    ".aimem",
88    "target",
89    ".cargo",
90    ".idea",
91    ".vscode",
92];
93
94static SKIP_FILES: &[&str] = &[
95    "aimem.yaml",
96    "aimem.yml",
97    ".gitignore",
98    "package-lock.json",
99    "Cargo.lock",
100];
101
102// ── Project config (aimem.yaml) ─────────────────────────────────────────
103
104#[derive(Debug, Clone, Deserialize, Serialize)]
105pub struct RoomDef {
106    pub name: String,
107    #[serde(default)]
108    pub description: String,
109    #[serde(default)]
110    pub keywords: Vec<String>,
111}
112
113#[derive(Debug, Clone, Deserialize, Serialize)]
114pub struct ProjectConfig {
115    pub wing: String,
116    #[serde(default = "default_rooms")]
117    pub rooms: Vec<RoomDef>,
118}
119
120fn default_rooms() -> Vec<RoomDef> {
121    vec![RoomDef {
122        name: "general".to_string(),
123        description: "All project files".to_string(),
124        keywords: vec![],
125    }]
126}
127
128impl ProjectConfig {
129    pub fn load(project_dir: impl AsRef<Path>) -> Result<Self, MinerError> {
130        let dir = project_dir.as_ref();
131        let yaml = dir.join("aimem.yaml");
132        let path = if yaml.exists() {
133            yaml
134        } else {
135            return Err(MinerError::ConfigNotFound(dir.display().to_string()));
136        };
137        let raw = std::fs::read_to_string(&path)?;
138        Ok(serde_yaml::from_str(&raw)?)
139    }
140}
141
142// ── Mining stats ─────────────────────────────────────────────────────────────
143
144#[derive(Debug, Default)]
145pub struct MineStats {
146    pub files_scanned: usize,
147    pub files_skipped: usize, // already mined
148    pub drawers_added: usize,
149    pub rooms: HashMap<String, usize>,
150}
151
152// ── Miner ────────────────────────────────────────────────────────────────────
153
154/// Project file miner.
155#[derive(Debug, Clone)]
156pub struct Miner {
157    db: AimemDb,
158    embedder: Option<Embedder>,
159}
160
161impl Miner {
162    /// Create a miner with optional embedding support.
163    /// Pass `None` for `embedder` to store text only (no vector search).
164    pub fn new(db: AimemDb, embedder: Option<Embedder>) -> Self {
165        Self { db, embedder }
166    }
167
168    /// Mine a project directory into AiMem.
169    ///
170    /// # Arguments
171    /// * `project_dir`   — path to the project root (must contain `aimem.yaml`)
172    /// * `wing_override` — override the wing name from config
173    /// * `agent`         — who is filing these drawers
174    /// * `limit`         — max files to process (0 = all)
175    /// * `dry_run`       — scan and report without writing
176    pub async fn mine(
177        &self,
178        project_dir: impl AsRef<Path>,
179        wing_override: Option<&str>,
180        agent: &str,
181        limit: usize,
182        dry_run: bool,
183    ) -> Result<MineStats, MinerError> {
184        let project_dir = project_dir.as_ref().canonicalize()?;
185        let cfg = ProjectConfig::load(&project_dir)?;
186        let wing = wing_override.unwrap_or(&cfg.wing).to_string();
187
188        let files = scan_files(&project_dir);
189        let files: Vec<_> = if limit > 0 {
190            files.into_iter().take(limit).collect()
191        } else {
192            files
193        };
194
195        let mut stats = MineStats::default();
196        stats.files_scanned = files.len();
197
198        for filepath in &files {
199            let source = filepath.to_string_lossy().to_string();
200
201            // Skip already-mined files
202            if !dry_run && self.db.source_already_mined(&source).await? {
203                stats.files_skipped += 1;
204                continue;
205            }
206
207            let content = match std::fs::read_to_string(filepath) {
208                Ok(c) => c,
209                Err(_) => continue,
210            };
211            let content = content.trim().to_string();
212            if content.len() < MIN_CHUNK {
213                continue;
214            }
215
216            let room = detect_room(filepath, &content, &cfg.rooms, &project_dir);
217            let chunks = chunk_text(&content);
218
219            if dry_run {
220                tracing::info!(
221                    "[DRY RUN] {} → room:{} ({} chunks)",
222                    filepath.file_name().unwrap_or_default().to_string_lossy(),
223                    room,
224                    chunks.len()
225                );
226                stats.drawers_added += chunks.len();
227                *stats.rooms.entry(room.clone()).or_default() += 1;
228                continue;
229            }
230
231            // Optionally embed all chunks at once (batch is faster)
232            let embeddings: Vec<Option<Vec<f32>>> = if let Some(ref emb) = self.embedder {
233                let texts: Vec<&str> = chunks.iter().map(|c| c.as_str()).collect();
234                match emb.embed(&texts) {
235                    Ok(vecs) => vecs.into_iter().map(Some).collect(),
236                    Err(e) => {
237                        tracing::warn!("embedding failed for {source}: {e}");
238                        vec![None; chunks.len()]
239                    }
240                }
241            } else {
242                vec![None; chunks.len()]
243            };
244
245            let now = chrono::Utc::now().to_rfc3339();
246            let mut file_drawers = 0usize;
247            for (i, (chunk, embedding)) in chunks.iter().zip(embeddings.iter()).enumerate() {
248                let id = drawer_id(&wing, &room, &source, i);
249                let drawer = Drawer {
250                    id,
251                    wing: wing.clone(),
252                    room: room.clone(),
253                    content: chunk.clone(),
254                    source_file: Some(source.clone()),
255                    chunk_index: i as i64,
256                    added_by: agent.to_string(),
257                    filed_at: now.clone(),
258                };
259                if self.db.insert_drawer(&drawer, embedding.as_deref()).await? {
260                    file_drawers += 1;
261                }
262            }
263
264            if file_drawers > 0 {
265                stats.drawers_added += file_drawers;
266                *stats.rooms.entry(room).or_default() += 1;
267                tracing::debug!(
268                    "✓ {} +{}",
269                    filepath.file_name().unwrap_or_default().to_string_lossy(),
270                    file_drawers
271                );
272            }
273        }
274
275        Ok(stats)
276    }
277}
278
279// ── Helpers ───────────────────────────────────────────────────────────────────
280
281/// Scan a project directory for all readable files.
282pub fn scan_files(project_dir: &Path) -> Vec<PathBuf> {
283    WalkDir::new(project_dir)
284        .follow_links(false)
285        .into_iter()
286        .filter_entry(|e| {
287            let name = e.file_name().to_string_lossy();
288            !SKIP_DIRS.contains(&name.as_ref())
289        })
290        .filter_map(|e| e.ok())
291        .filter(|e| e.file_type().is_file())
292        .filter(|e| {
293            let name = e.file_name().to_string_lossy();
294            if SKIP_FILES.contains(&name.as_ref()) {
295                return false;
296            }
297            let ext = e
298                .path()
299                .extension()
300                .map(|x| x.to_string_lossy().to_lowercase())
301                .unwrap_or_default();
302            // Accept files with known extensions OR no extension (Makefile, Dockerfile…)
303            READABLE_EXT.contains(&ext.as_str()) || ext.is_empty()
304        })
305        .map(|e| e.into_path())
306        .collect()
307}
308
309/// Route a file to the best-matching room.
310///
311/// Priority:
312/// 1. Directory path component matches a room name
313/// 2. Filename stem matches a room name
314/// 3. Content keyword scoring
315/// 4. Fallback: "general"
316pub fn detect_room(
317    filepath: &Path,
318    content: &str,
319    rooms: &[RoomDef],
320    project_root: &Path,
321) -> String {
322    let relative = filepath
323        .strip_prefix(project_root)
324        .unwrap_or(filepath)
325        .to_string_lossy()
326        .to_lowercase();
327    let stem = filepath
328        .file_stem()
329        .unwrap_or_default()
330        .to_string_lossy()
331        .to_lowercase();
332    let content_lower = &content[..content.len().min(2000)].to_lowercase();
333    let path_parts: Vec<&str> = relative.split('/').collect();
334
335    // Priority 1: directory component
336    for part in path_parts.iter().take(path_parts.len().saturating_sub(1)) {
337        for room in rooms {
338            let rn = room.name.to_lowercase();
339            if rn == *part || part.contains(rn.as_str()) {
340                return room.name.clone();
341            }
342        }
343    }
344
345    // Priority 2: filename stem
346    for room in rooms {
347        let rn = room.name.to_lowercase();
348        if stem.contains(rn.as_str()) || rn.contains(stem.as_str()) {
349            return room.name.clone();
350        }
351    }
352
353    // Priority 3: keyword scoring
354    let mut scores: Vec<(usize, &str)> = rooms
355        .iter()
356        .map(|r| {
357            let mut score = 0usize;
358            let all_kw: Vec<String> = r
359                .keywords
360                .iter()
361                .chain(std::iter::once(&r.name))
362                .cloned()
363                .collect();
364            for kw in &all_kw {
365                let kw_lower = kw.to_lowercase();
366                let mut pos = 0;
367                while let Some(idx) = content_lower[pos..].find(kw_lower.as_str()) {
368                    score += 1;
369                    pos += idx + kw_lower.len();
370                }
371            }
372            (score, r.name.as_str())
373        })
374        .collect();
375    scores.sort_by(|a, b| b.0.cmp(&a.0));
376    if let Some((score, name)) = scores.first() {
377        if *score > 0 {
378            return name.to_string();
379        }
380    }
381
382    "general".to_string()
383}
384
385/// Split content into overlapping chunks, preferring paragraph boundaries.
386pub fn chunk_text(content: &str) -> Vec<String> {
387    let content = content.trim();
388    if content.is_empty() {
389        return vec![];
390    }
391
392    let mut chunks = Vec::new();
393    let mut start = 0usize;
394
395    while start < content.len() {
396        let end = (start + CHUNK_SIZE).min(content.len());
397
398        // Try to break at paragraph boundary
399        let end = if end < content.len() {
400            let slice = &content[start..end];
401            if let Some(pos) = slice.rfind("\n\n") {
402                if pos > CHUNK_SIZE / 2 {
403                    start + pos
404                } else if let Some(pos2) = slice.rfind('\n') {
405                    if pos2 > CHUNK_SIZE / 2 {
406                        start + pos2
407                    } else {
408                        end
409                    }
410                } else {
411                    end
412                }
413            } else if let Some(pos) = slice.rfind('\n') {
414                if pos > CHUNK_SIZE / 2 {
415                    start + pos
416                } else {
417                    end
418                }
419            } else {
420                end
421            }
422        } else {
423            end
424        };
425
426        let chunk = content[start..end].trim().to_string();
427        if chunk.len() >= MIN_CHUNK {
428            chunks.push(chunk);
429        }
430
431        // Advance with overlap
432        start = if end < content.len() {
433            end.saturating_sub(CHUNK_OVERLAP)
434        } else {
435            end
436        };
437    }
438
439    chunks
440}
441
442/// Deterministic drawer ID from wing/room/source/chunk_index.
443fn drawer_id(wing: &str, room: &str, source_file: &str, chunk_index: usize) -> String {
444    let input = format!("{wing}{room}{source_file}{chunk_index}");
445    let digest = md5::compute(input.as_bytes());
446    format!("drawer_{wing}_{room}_{digest:x}")
447}
448
449// ── Tests ─────────────────────────────────────────────────────────────────────
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454
455    #[test]
456    fn test_chunk_text_basic() {
457        let text = "hello world".repeat(200);
458        let chunks = chunk_text(&text);
459        assert!(!chunks.is_empty());
460        for c in &chunks {
461            assert!(c.len() <= CHUNK_SIZE + CHUNK_OVERLAP);
462        }
463    }
464
465    #[test]
466    fn test_detect_room_fallback() {
467        let rooms = vec![RoomDef {
468            name: "general".to_string(),
469            description: "all".to_string(),
470            keywords: vec![],
471        }];
472        let path = PathBuf::from("/proj/src/main.rs");
473        let room = detect_room(&path, "fn main() {}", &rooms, Path::new("/proj"));
474        assert_eq!(room, "general");
475    }
476
477    #[test]
478    fn test_detect_room_by_keyword() {
479        let rooms = vec![
480            RoomDef {
481                name: "backend".to_string(),
482                description: "".to_string(),
483                keywords: vec!["database".to_string(), "query".to_string()],
484            },
485            RoomDef {
486                name: "general".to_string(),
487                description: "".to_string(),
488                keywords: vec![],
489            },
490        ];
491        let path = PathBuf::from("/proj/main.rs");
492        let room = detect_room(
493            &path,
494            "SELECT * FROM users; database query optimization",
495            &rooms,
496            Path::new("/proj"),
497        );
498        assert_eq!(room, "backend");
499    }
500}