Skip to main content

aimem_core/convo/
mod.rs

1//! Conversation miner — mine chat exports into AiMem.
2//!
3//! Supported formats (via normalize module):
4//!   - Plain text with `>` markers
5//!   - Claude.ai JSON export
6//!   - ChatGPT conversations.json
7//!   - Claude Code JSONL
8//!   - Slack JSON export
9
10pub 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/// Conversation miner.
56#[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    /// Mine a directory of conversation exports into AiMem.
68    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            // Normalize to plain transcript
102            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
164/// Chunk a conversation by exchange pairs (user turn + AI response = 1 unit).
165pub 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    // Fallback: if we got nothing, just use paragraph chunking
210    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}