Skip to main content

aimem_core/
miner.rs

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