1pub mod normalize;
11
12use std::path::{Path, PathBuf};
13
14use thiserror::Error;
15use walkdir::WalkDir;
16
17use crate::{
18 db::{AimemDb, DbError},
19 embedder::{EmbedError, Embedder},
20 miner::{MIN_CHUNK, chunk_text},
21 types::Drawer,
22};
23
24#[derive(Debug, Error)]
25pub enum ConvoMinerError {
26 #[error("db: {0}")]
27 Db(#[from] DbError),
28 #[error("embed: {0}")]
29 Embed(#[from] EmbedError),
30 #[error("io: {0}")]
31 Io(#[from] std::io::Error),
32}
33
34const CONVO_EXTENSIONS: &[&str] = &["txt", "md", "json", "jsonl"];
35const SKIP_DIRS: &[&str] = &[
36 ".git",
37 "node_modules",
38 "__pycache__",
39 ".venv",
40 "venv",
41 "env",
42 "dist",
43 "build",
44 ".aimem",
45 "target",
46];
47
48#[derive(Debug, Default)]
49pub struct ConvoMineStats {
50 pub files_scanned: usize,
51 pub files_skipped: usize,
52 pub drawers_added: usize,
53}
54
55#[derive(Debug, Clone)]
57pub struct ConvoMiner {
58 db: AimemDb,
59 embedder: Option<Embedder>,
60}
61
62impl ConvoMiner {
63 pub fn new(db: AimemDb, embedder: Option<Embedder>) -> Self {
64 Self { db, embedder }
65 }
66
67 pub async fn mine(
69 &self,
70 convo_dir: impl AsRef<Path>,
71 wing: &str,
72 room: &str,
73 agent: &str,
74 limit: usize,
75 dry_run: bool,
76 ) -> Result<ConvoMineStats, ConvoMinerError> {
77 let convo_dir = convo_dir.as_ref();
78 let files = scan_convo_files(convo_dir);
79 let files: Vec<_> = if limit > 0 {
80 files.into_iter().take(limit).collect()
81 } else {
82 files
83 };
84
85 let mut stats = ConvoMineStats::default();
86 stats.files_scanned = files.len();
87
88 for filepath in &files {
89 let source = filepath.to_string_lossy().to_string();
90
91 if !dry_run && self.db.source_already_mined(&source).await? {
92 stats.files_skipped += 1;
93 continue;
94 }
95
96 let content = match std::fs::read_to_string(filepath) {
97 Ok(c) => c,
98 Err(_) => continue,
99 };
100
101 let normalized = normalize::normalize_content(&content, filepath);
103 if normalized.trim().len() < MIN_CHUNK {
104 continue;
105 }
106
107 let chunks = chunk_exchanges(&normalized);
108 if chunks.is_empty() {
109 continue;
110 }
111
112 if dry_run {
113 tracing::info!(
114 "[DRY RUN] {} → {}/{} ({} chunks)",
115 filepath.file_name().unwrap_or_default().to_string_lossy(),
116 wing,
117 room,
118 chunks.len()
119 );
120 stats.drawers_added += chunks.len();
121 continue;
122 }
123
124 let embeddings: Vec<Option<Vec<f32>>> = if let Some(ref emb) = self.embedder {
125 let texts: Vec<&str> = chunks.iter().map(|c| c.as_str()).collect();
126 match emb.embed(&texts) {
127 Ok(vecs) => vecs.into_iter().map(Some).collect(),
128 Err(e) => {
129 tracing::warn!("embedding failed for {source}: {e}");
130 vec![None; chunks.len()]
131 }
132 }
133 } else {
134 vec![None; chunks.len()]
135 };
136
137 let now = chrono::Utc::now().to_rfc3339();
138 for (i, (chunk, embedding)) in chunks.iter().zip(embeddings.iter()).enumerate() {
139 let id = {
140 let input = format!("{wing}{room}{source}{i}");
141 let digest = md5::compute(input.as_bytes());
142 format!("drawer_{wing}_{room}_{digest:x}")
143 };
144 let drawer = Drawer {
145 id,
146 wing: wing.to_string(),
147 room: room.to_string(),
148 content: chunk.clone(),
149 source_file: Some(source.clone()),
150 chunk_index: i as i64,
151 added_by: agent.to_string(),
152 filed_at: now.clone(),
153 };
154 if self.db.insert_drawer(&drawer, embedding.as_deref()).await? {
155 stats.drawers_added += 1;
156 }
157 }
158 }
159
160 Ok(stats)
161 }
162}
163
164pub fn chunk_exchanges(content: &str) -> Vec<String> {
166 let lines: Vec<&str> = content.lines().collect();
167 let quote_count = lines
168 .iter()
169 .filter(|l| l.trim_start().starts_with('>'))
170 .count();
171
172 if quote_count >= 3 {
173 chunk_by_exchange(&lines)
174 } else {
175 chunk_text(content)
176 }
177}
178
179fn chunk_by_exchange(lines: &[&str]) -> Vec<String> {
180 let mut chunks = Vec::new();
181 let mut i = 0;
182
183 while i < lines.len() {
184 let line = lines[i].trim();
185 if line.starts_with('>') {
186 let user_turn = line.to_string();
187 i += 1;
188
189 let mut ai_lines = Vec::new();
190 while i < lines.len() {
191 let next = lines[i].trim();
192 if next.starts_with('>') || next == "---" {
193 break;
194 }
195 ai_lines.push(lines[i]);
196 i += 1;
197 }
198
199 let ai_response = ai_lines.join("\n");
200 let chunk = format!("{}\n{}", user_turn, ai_response);
201 if chunk.trim().len() >= MIN_CHUNK {
202 chunks.push(chunk);
203 }
204 } else {
205 i += 1;
206 }
207 }
208
209 if chunks.is_empty() {
211 chunk_text(&lines.join("\n"))
212 } else {
213 chunks
214 }
215}
216
217fn scan_convo_files(dir: &Path) -> Vec<PathBuf> {
218 WalkDir::new(dir)
219 .follow_links(false)
220 .into_iter()
221 .filter_entry(|e| {
222 let name = e.file_name().to_string_lossy();
223 !SKIP_DIRS.contains(&name.as_ref())
224 })
225 .filter_map(|e| e.ok())
226 .filter(|e| e.file_type().is_file())
227 .filter(|e| {
228 let ext = e
229 .path()
230 .extension()
231 .map(|x| x.to_string_lossy().to_lowercase())
232 .unwrap_or_default();
233 CONVO_EXTENSIONS.contains(&ext.as_str())
234 })
235 .map(|e| e.into_path())
236 .collect()
237}