1use 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
34const CHUNK_SIZE: usize = 800; const CHUNK_OVERLAP: usize = 100; pub const MIN_CHUNK: usize = 50; static 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#[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#[derive(Debug, Default)]
146pub struct MineStats {
147 pub files_scanned: usize,
148 pub files_skipped: usize, pub drawers_added: usize,
150 pub rooms: HashMap<String, usize>,
151}
152
153#[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 pub fn new(db: AimemDb, embedder: Option<Arc<dyn Embedder>>) -> Self {
178 Self { db, embedder }
179 }
180
181 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 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 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
307pub 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 READABLE_EXT.contains(&ext.as_str()) || ext.is_empty()
332 })
333 .map(|e| e.into_path())
334 .collect()
335}
336
337pub 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 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 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 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
413pub 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 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 start = if end < content.len() {
461 end.saturating_sub(CHUNK_OVERLAP)
462 } else {
463 end
464 };
465 }
466
467 chunks
468}
469
470fn 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#[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}