1#![warn(clippy::all)]
2
3pub mod chunk;
4pub mod detect;
5pub mod diary;
6pub mod lock;
7pub mod noise;
8pub mod normalize;
9pub mod reindex;
10
11use std::collections::HashSet;
12use std::path::{Path, PathBuf};
13
14use crate::core::{
15 db::Database,
16 types::{BootstrapEvidenceArgs, Drawer, SourceType},
17 utils::{build_bootstrap_evidence_drawer_id, current_timestamp, route_room_from_taxonomy},
18};
19use crate::embed::{EmbedError, Embedder};
20use crate::path_filter::{ProjectPathFilterOptions, project_walk};
21use thiserror::Error;
22
23use crate::ingest::{
24 chunk::{chunk_conversation, chunk_text},
25 detect::{Format, detect_format},
26 normalize::{
27 CURRENT_NORMALIZE_VERSION, NormalizeError, NormalizeOptions, normalize_content_with_options,
28 },
29};
30
31const CHUNK_WINDOW: usize = 800;
32const CHUNK_OVERLAP: usize = 100;
33
34const LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
36
37fn mempal_home_from_db(db: &Database) -> PathBuf {
40 db.path()
41 .parent()
42 .map(Path::to_path_buf)
43 .unwrap_or_else(|| PathBuf::from("."))
44}
45
46#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
47pub struct IngestStats {
48 pub files: usize,
49 pub chunks: usize,
50 pub skipped: usize,
51 pub noise_bytes_stripped: Option<u64>,
52 pub lock_wait_ms: Option<u64>,
56}
57
58#[derive(Debug, Clone, Default)]
59pub struct IngestOptions<'a> {
60 pub room: Option<&'a str>,
61 pub source_root: Option<&'a Path>,
62 pub dry_run: bool,
63 pub source_file_override: Option<&'a str>,
64 pub replace_existing_source: bool,
65 pub replace_across_rooms: bool,
70 pub no_strip_noise: bool,
71 pub diary_rollup: bool,
72 pub diary_rollup_day: Option<&'a str>,
73 pub project_filter: ProjectPathFilterOptions,
74}
75
76pub type Result<T> = std::result::Result<T, IngestError>;
77
78#[derive(Debug, Error)]
79pub enum IngestError {
80 #[error("failed to read {path}")]
81 ReadFile {
82 path: PathBuf,
83 #[source]
84 source: std::io::Error,
85 },
86 #[error("failed to normalize {path}")]
87 Normalize {
88 path: PathBuf,
89 #[source]
90 source: NormalizeError,
91 },
92 #[error("failed to load taxonomy for wing {wing}")]
93 LoadTaxonomy {
94 wing: String,
95 #[source]
96 source: crate::core::db::DbError,
97 },
98 #[error("failed to embed chunks from {path}")]
99 EmbedChunks {
100 path: PathBuf,
101 #[source]
102 source: EmbedError,
103 },
104 #[error("failed to check drawer {drawer_id}")]
105 CheckDrawer {
106 drawer_id: String,
107 #[source]
108 source: crate::core::db::DbError,
109 },
110 #[error("failed to insert drawer {drawer_id}")]
111 InsertDrawer {
112 drawer_id: String,
113 #[source]
114 source: crate::core::db::DbError,
115 },
116 #[error("failed to replace source drawers for {source_file}")]
117 ReplaceSource {
118 source_file: String,
119 #[source]
120 source: crate::core::db::DbError,
121 },
122 #[error("failed to insert vector for {drawer_id}")]
123 InsertVector {
124 drawer_id: String,
125 #[source]
126 source: crate::core::db::DbError,
127 },
128 #[error("diary_rollup requires wing=\"agent-diary\", got wing=\"{wing}\"")]
129 DiaryRollupWrongWing { wing: String },
130 #[error("diary_rollup requires an explicit non-empty room")]
131 DiaryRollupMissingRoom,
132 #[error(
133 "daily rollup drawer {drawer_id} would exceed {limit_bytes} bytes ({attempted_bytes} bytes)"
134 )]
135 DailyRollupFull {
136 drawer_id: String,
137 limit_bytes: usize,
138 attempted_bytes: usize,
139 },
140 #[error("embedder returned no vector for {drawer_id}")]
141 EmbedderReturnedNoVector { drawer_id: String },
142 #[error("failed to acquire ingest lock: {0}")]
143 Lock(#[from] lock::LockError),
144 #[error("failed to apply project ignore rules: {0}")]
145 ProjectFilter(#[from] crate::path_filter::ProjectPathFilterError),
146 #[error("failed to walk project path {path}")]
147 WalkPath {
148 path: PathBuf,
149 #[source]
150 source: ignore::Error,
151 },
152 #[error("failed to read directory {path}")]
153 ReadDir {
154 path: PathBuf,
155 #[source]
156 source: std::io::Error,
157 },
158 #[error("failed to read entry in {path}")]
159 ReadDirEntry {
160 path: PathBuf,
161 #[source]
162 source: std::io::Error,
163 },
164}
165
166pub async fn ingest_file<E: Embedder + ?Sized>(
167 db: &Database,
168 embedder: &E,
169 path: &Path,
170 wing: &str,
171 room: Option<&str>,
172) -> Result<IngestStats> {
173 ingest_file_with_options(
174 db,
175 embedder,
176 path,
177 wing,
178 IngestOptions {
179 room,
180 source_root: path.parent(),
181 dry_run: false,
182 source_file_override: None,
183 replace_existing_source: false,
184 replace_across_rooms: false,
185 no_strip_noise: false,
186 diary_rollup: false,
187 diary_rollup_day: None,
188 project_filter: ProjectPathFilterOptions::default(),
189 },
190 )
191 .await
192}
193
194pub async fn ingest_file_with_options<E: Embedder + ?Sized>(
195 db: &Database,
196 embedder: &E,
197 path: &Path,
198 wing: &str,
199 options: IngestOptions<'_>,
200) -> Result<IngestStats> {
201 let bytes = tokio::fs::read(path)
202 .await
203 .map_err(|source| IngestError::ReadFile {
204 path: path.to_path_buf(),
205 source,
206 })?;
207 let content = String::from_utf8_lossy(&bytes).to_string();
208 if content.trim().is_empty() {
209 return Ok(IngestStats {
210 files: 1,
211 ..IngestStats::default()
212 });
213 }
214
215 let format = detect_format(&content);
216 let normalize_output = normalize_content_with_options(
217 &content,
218 format,
219 NormalizeOptions {
220 strip_noise: !options.no_strip_noise,
221 },
222 )
223 .map_err(|source| IngestError::Normalize {
224 path: path.to_path_buf(),
225 source,
226 })?;
227 let normalized = normalize_output.content;
228 let noise_bytes_stripped = normalize_output.noise_bytes_stripped;
229
230 if options.diary_rollup {
231 let mut outcome = diary::ingest_diary_rollup(
232 db,
233 embedder,
234 &normalized,
235 wing,
236 diary::DiaryRollupOptions {
237 room: options.room,
238 day: options.diary_rollup_day,
239 dry_run: options.dry_run,
240 importance: 0,
241 },
242 )
243 .await?;
244 outcome.stats.noise_bytes_stripped = noise_bytes_stripped;
245 return Ok(outcome.stats);
246 }
247
248 let resolved_room = match options.room {
249 Some(room) => room.to_string(),
250 None => {
251 let taxonomy = db
252 .taxonomy_entries()
253 .map_err(|source| IngestError::LoadTaxonomy {
254 wing: wing.to_string(),
255 source,
256 })?;
257 route_room_from_taxonomy(&normalized, wing, &taxonomy)
258 }
259 };
260 let chunks = match format {
261 Format::ClaudeJsonl | Format::ChatGptJson | Format::CodexJsonl | Format::SlackJson => {
262 chunk_conversation(&normalized)
263 }
264 Format::PlainText => chunk_text(&normalized, CHUNK_WINDOW, CHUNK_OVERLAP),
265 };
266 if chunks.is_empty() {
267 return Ok(IngestStats {
268 files: 1,
269 ..IngestStats::default()
270 });
271 }
272
273 let mut stats = IngestStats {
274 files: 1,
275 noise_bytes_stripped,
276 ..IngestStats::default()
277 };
278 let source_file = options
279 .source_file_override
280 .map(ToOwned::to_owned)
281 .unwrap_or_else(|| normalize_source_file(path, options.source_root));
282
283 let _lock_guard = if options.dry_run {
287 None
288 } else {
289 let home = mempal_home_from_db(db);
290 let key = lock::source_key(Path::new(&source_file));
291 let guard = lock::acquire_source_lock(&home, &key, LOCK_TIMEOUT)?;
292 stats.lock_wait_ms = Some(guard.wait_duration().as_millis() as u64);
293 Some(guard)
294 };
295
296 let source_type = source_type_for(format);
297 if options.replace_existing_source && !options.dry_run {
298 let replace_result = if options.replace_across_rooms {
299 db.replace_active_source_drawers_across_rooms(&source_file, wing)
300 } else {
301 db.replace_active_source_drawers(&source_file, wing, Some(resolved_room.as_str()))
302 };
303 replace_result.map_err(|source| IngestError::ReplaceSource {
304 source_file: source_file.clone(),
305 source,
306 })?;
307 }
308
309 let mut pending = Vec::new();
310 let mut seen_drawer_ids = HashSet::new();
311
312 for (chunk_index, chunk) in chunks.iter().enumerate() {
313 let drawer_id = build_bootstrap_evidence_drawer_id(
314 wing,
315 Some(resolved_room.as_str()),
316 chunk,
317 &source_type,
318 Some(source_file.as_str()),
319 );
320 if !seen_drawer_ids.insert(drawer_id.clone()) {
321 stats.skipped += 1;
322 continue;
323 }
324 if db
325 .drawer_exists(&drawer_id)
326 .map_err(|source| IngestError::CheckDrawer {
327 drawer_id: drawer_id.clone(),
328 source,
329 })?
330 {
331 stats.skipped += 1;
332 continue;
333 }
334
335 if options.dry_run {
336 stats.chunks += 1;
337 continue;
338 }
339
340 pending.push((chunk_index, chunk, drawer_id));
341 }
342
343 if options.dry_run || pending.is_empty() {
344 return Ok(stats);
345 }
346
347 let chunk_refs = pending
348 .iter()
349 .map(|(_, chunk, _)| chunk.as_ref())
350 .collect::<Vec<_>>();
351 let vectors = embedder
352 .embed(&chunk_refs)
353 .await
354 .map_err(|source| IngestError::EmbedChunks {
355 path: path.to_path_buf(),
356 source,
357 })?;
358
359 for ((chunk_index, chunk, drawer_id), vector) in pending.into_iter().zip(vectors) {
360 let drawer = Drawer::new_bootstrap_evidence(BootstrapEvidenceArgs {
361 id: drawer_id.clone(),
362 content: chunk.to_string(),
363 wing: wing.to_string(),
364 room: Some(resolved_room.clone()),
365 source_file: Some(source_file.clone()),
366 source_type: source_type.clone(),
367 added_at: current_timestamp(),
368 chunk_index: Some(chunk_index as i64),
369 importance: 0,
370 });
371 let drawer = Drawer {
372 normalize_version: CURRENT_NORMALIZE_VERSION,
373 ..drawer
374 };
375
376 let inserted = db
377 .insert_drawer(&drawer)
378 .map_err(|source| IngestError::InsertDrawer {
379 drawer_id: drawer.id.clone(),
380 source,
381 })?;
382 if inserted {
383 db.insert_vector(&drawer_id, &vector)
384 .map_err(|source| IngestError::InsertVector {
385 drawer_id: drawer.id.clone(),
386 source,
387 })?;
388 stats.chunks += 1;
389 } else {
390 stats.skipped += 1;
391 }
392 }
393
394 Ok(stats)
395}
396
397pub async fn ingest_dir<E: Embedder + ?Sized>(
398 db: &Database,
399 embedder: &E,
400 dir: &Path,
401 wing: &str,
402 room: Option<&str>,
403) -> Result<IngestStats> {
404 ingest_dir_with_options(
405 db,
406 embedder,
407 dir,
408 wing,
409 IngestOptions {
410 room,
411 source_root: Some(dir),
412 dry_run: false,
413 source_file_override: None,
414 replace_existing_source: false,
415 replace_across_rooms: false,
416 no_strip_noise: false,
417 diary_rollup: false,
418 diary_rollup_day: None,
419 project_filter: ProjectPathFilterOptions::default(),
420 },
421 )
422 .await
423}
424
425pub async fn ingest_dir_with_options<E: Embedder + ?Sized>(
426 db: &Database,
427 embedder: &E,
428 dir: &Path,
429 wing: &str,
430 options: IngestOptions<'_>,
431) -> Result<IngestStats> {
432 let mut stats = IngestStats::default();
433
434 for entry in project_walk(dir, &options.project_filter)? {
435 let entry = entry.map_err(|source| IngestError::WalkPath {
436 path: dir.to_path_buf(),
437 source,
438 })?;
439 let path = entry.path();
440 if path == dir || path.is_dir() {
441 continue;
442 }
443
444 if path.is_file() {
445 if should_skip_file(path, &options.project_filter) {
446 stats.skipped += 1;
447 continue;
448 }
449 let file_stats =
450 ingest_file_with_options(db, embedder, path, wing, options.clone()).await?;
451 stats.files += file_stats.files;
452 stats.chunks += file_stats.chunks;
453 stats.skipped += file_stats.skipped;
454 stats.noise_bytes_stripped =
455 merge_optional_sum(stats.noise_bytes_stripped, file_stats.noise_bytes_stripped);
456 }
457 }
458
459 Ok(stats)
460}
461
462fn merge_optional_sum(left: Option<u64>, right: Option<u64>) -> Option<u64> {
463 match (left, right) {
464 (Some(left), Some(right)) => Some(left + right),
465 (Some(value), None) | (None, Some(value)) => Some(value),
466 (None, None) => None,
467 }
468}
469
470fn source_type_for(format: Format) -> SourceType {
471 match format {
472 Format::ClaudeJsonl | Format::ChatGptJson | Format::CodexJsonl | Format::SlackJson => {
473 SourceType::Conversation
474 }
475 Format::PlainText => SourceType::Project,
476 }
477}
478
479fn should_skip_file(path: &Path, filter_options: &ProjectPathFilterOptions) -> bool {
480 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
481 return false;
482 };
483 if matches!(name, ".DS_Store" | ".gitignore" | ".mempalignore") || name.starts_with("._") {
484 return true;
485 }
486 if is_custom_ignore_file(path, filter_options) {
487 return true;
488 }
489
490 path.extension()
491 .and_then(|extension| extension.to_str())
492 .map(|extension| {
493 matches!(
494 extension.to_ascii_lowercase().as_str(),
495 "a" | "bmp"
496 | "class"
497 | "dll"
498 | "dylib"
499 | "exe"
500 | "gif"
501 | "ico"
502 | "jar"
503 | "jpeg"
504 | "jpg"
505 | "o"
506 | "pdf"
507 | "png"
508 | "so"
509 | "wasm"
510 | "webp"
511 | "zip"
512 )
513 })
514 .unwrap_or(false)
515}
516
517fn is_custom_ignore_file(path: &Path, filter_options: &ProjectPathFilterOptions) -> bool {
518 filter_options.custom_ignore_files.iter().any(|custom| {
519 path == custom || path.canonicalize().ok().as_ref() == custom.canonicalize().ok().as_ref()
520 })
521}
522
523fn normalize_source_file(path: &Path, source_root: Option<&Path>) -> String {
524 let normalized = source_root
525 .and_then(|root| path.strip_prefix(root).ok())
526 .filter(|relative| !relative.as_os_str().is_empty())
527 .map(Path::to_path_buf)
528 .or_else(|| path.file_name().map(PathBuf::from))
529 .unwrap_or_else(|| path.to_path_buf());
530
531 normalized.to_string_lossy().replace('\\', "/")
532}