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