1use 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
33const CHUNK_SIZE: usize = 800; const CHUNK_OVERLAP: usize = 100; pub const MIN_CHUNK: usize = 50; static 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#[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#[derive(Debug, Default)]
150pub struct MineStats {
151 pub files_scanned: usize,
152 pub files_skipped: usize, pub drawers_added: usize,
154 pub rooms: HashMap<String, usize>,
155}
156
157#[derive(Debug, Clone)]
161pub struct Miner {
162 db: PalaceDb,
163 embedder: Option<Embedder>,
164}
165
166impl Miner {
167 pub fn new(db: PalaceDb, embedder: Option<Embedder>) -> Self {
170 Self { db, embedder }
171 }
172
173 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 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 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
284pub 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 READABLE_EXT.contains(&ext.as_str()) || ext.is_empty()
309 })
310 .map(|e| e.into_path())
311 .collect()
312}
313
314pub 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 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 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 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
390pub 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 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 start = if end < content.len() {
438 end.saturating_sub(CHUNK_OVERLAP)
439 } else {
440 end
441 };
442 }
443
444 chunks
445}
446
447fn 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#[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}