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(transparent)]
82 Db(#[from] crate::core::db::DbError),
83 #[error("failed to read {path}")]
84 ReadFile {
85 path: PathBuf,
86 #[source]
87 source: std::io::Error,
88 },
89 #[error("failed to normalize {path}")]
90 Normalize {
91 path: PathBuf,
92 #[source]
93 source: NormalizeError,
94 },
95 #[error("failed to load taxonomy for wing {wing}")]
96 LoadTaxonomy {
97 wing: String,
98 #[source]
99 source: crate::core::db::DbError,
100 },
101 #[error("failed to embed chunks from {path}")]
102 EmbedChunks {
103 path: PathBuf,
104 #[source]
105 source: EmbedError,
106 },
107 #[error("embedding count mismatch for {path}: expected {expected}, got {actual}")]
108 EmbeddingCountMismatch {
109 path: PathBuf,
110 expected: usize,
111 actual: usize,
112 },
113 #[error("failed to check drawer {drawer_id}")]
114 CheckDrawer {
115 drawer_id: String,
116 #[source]
117 source: crate::core::db::DbError,
118 },
119 #[error("failed to insert drawer {drawer_id}")]
120 InsertDrawer {
121 drawer_id: String,
122 #[source]
123 source: crate::core::db::DbError,
124 },
125 #[error("replacement drawer id collision for {drawer_id}")]
126 ReplacementDrawerCollision { drawer_id: String },
127 #[error("failed to replace source drawers for {source_file}")]
128 ReplaceSource {
129 source_file: String,
130 #[source]
131 source: crate::core::db::DbError,
132 },
133 #[error("failed to insert vector for {drawer_id}")]
134 InsertVector {
135 drawer_id: String,
136 #[source]
137 source: crate::core::db::DbError,
138 },
139 #[error("diary_rollup requires wing=\"agent-diary\", got wing=\"{wing}\"")]
140 DiaryRollupWrongWing { wing: String },
141 #[error("diary_rollup requires an explicit non-empty room")]
142 DiaryRollupMissingRoom,
143 #[error(
144 "daily rollup drawer {drawer_id} would exceed {limit_bytes} bytes ({attempted_bytes} bytes)"
145 )]
146 DailyRollupFull {
147 drawer_id: String,
148 limit_bytes: usize,
149 attempted_bytes: usize,
150 },
151 #[error("embedder returned no vector for {drawer_id}")]
152 EmbedderReturnedNoVector { drawer_id: String },
153 #[error("failed to acquire ingest lock: {0}")]
154 Lock(#[from] lock::LockError),
155 #[error("failed to apply project ignore rules: {0}")]
156 ProjectFilter(#[from] crate::path_filter::ProjectPathFilterError),
157 #[error("failed to walk project path {path}")]
158 WalkPath {
159 path: PathBuf,
160 #[source]
161 source: ignore::Error,
162 },
163 #[error("failed to read directory {path}")]
164 ReadDir {
165 path: PathBuf,
166 #[source]
167 source: std::io::Error,
168 },
169 #[error("failed to read entry in {path}")]
170 ReadDirEntry {
171 path: PathBuf,
172 #[source]
173 source: std::io::Error,
174 },
175}
176
177pub async fn ingest_file<E: Embedder + ?Sized>(
178 db: &Database,
179 embedder: &E,
180 path: &Path,
181 wing: &str,
182 room: Option<&str>,
183) -> Result<IngestStats> {
184 ingest_file_with_options(
185 db,
186 embedder,
187 path,
188 wing,
189 IngestOptions {
190 room,
191 source_root: path.parent(),
192 dry_run: false,
193 source_file_override: None,
194 replace_existing_source: false,
195 replace_across_rooms: false,
196 no_strip_noise: false,
197 diary_rollup: false,
198 diary_rollup_day: None,
199 project_filter: ProjectPathFilterOptions::default(),
200 },
201 )
202 .await
203}
204
205pub async fn ingest_file_with_options<E: Embedder + ?Sized>(
206 db: &Database,
207 embedder: &E,
208 path: &Path,
209 wing: &str,
210 options: IngestOptions<'_>,
211) -> Result<IngestStats> {
212 let bytes = tokio::fs::read(path)
213 .await
214 .map_err(|source| IngestError::ReadFile {
215 path: path.to_path_buf(),
216 source,
217 })?;
218 let content = String::from_utf8_lossy(&bytes).to_string();
219 if content.trim().is_empty() {
220 return Ok(IngestStats {
221 files: 1,
222 ..IngestStats::default()
223 });
224 }
225
226 let format = detect_format(&content);
227 let normalize_output = normalize_content_with_options(
228 &content,
229 format,
230 NormalizeOptions {
231 strip_noise: !options.no_strip_noise,
232 },
233 )
234 .map_err(|source| IngestError::Normalize {
235 path: path.to_path_buf(),
236 source,
237 })?;
238 let normalized = normalize_output.content;
239 let noise_bytes_stripped = normalize_output.noise_bytes_stripped;
240
241 if options.diary_rollup {
242 let mut outcome = diary::ingest_diary_rollup(
243 db,
244 embedder,
245 &normalized,
246 wing,
247 diary::DiaryRollupOptions {
248 room: options.room,
249 day: options.diary_rollup_day,
250 dry_run: options.dry_run,
251 importance: 0,
252 },
253 )
254 .await?;
255 outcome.stats.noise_bytes_stripped = noise_bytes_stripped;
256 return Ok(outcome.stats);
257 }
258
259 let resolved_room = match options.room {
260 Some(room) => room.to_string(),
261 None => {
262 let taxonomy = db
263 .taxonomy_entries()
264 .map_err(|source| IngestError::LoadTaxonomy {
265 wing: wing.to_string(),
266 source,
267 })?;
268 route_room_from_taxonomy(&normalized, wing, &taxonomy)
269 }
270 };
271 let chunks = match format {
272 Format::ClaudeJsonl | Format::ChatGptJson | Format::CodexJsonl | Format::SlackJson => {
273 chunk_conversation(&normalized)
274 }
275 Format::PlainText => chunk_text(&normalized, CHUNK_WINDOW, CHUNK_OVERLAP),
276 };
277 if chunks.is_empty() {
278 return Ok(IngestStats {
279 files: 1,
280 ..IngestStats::default()
281 });
282 }
283
284 let mut stats = IngestStats {
285 files: 1,
286 noise_bytes_stripped,
287 ..IngestStats::default()
288 };
289 let source_file = options
290 .source_file_override
291 .map(ToOwned::to_owned)
292 .unwrap_or_else(|| normalize_source_file(path, options.source_root));
293
294 let _lock_guard = if options.dry_run {
298 None
299 } else {
300 let home = mempal_home_from_db(db);
301 let key = lock::source_key(Path::new(&source_file));
302 let guard = lock::acquire_source_lock(&home, &key, LOCK_TIMEOUT)?;
303 stats.lock_wait_ms = Some(guard.wait_duration().as_millis() as u64);
304 Some(guard)
305 };
306
307 let source_type = source_type_for(format);
308 let replacing = options.replace_existing_source && !options.dry_run;
313
314 let mut pending = Vec::new();
315 let mut seen_drawer_ids = HashSet::new();
316
317 for (chunk_index, chunk) in chunks.iter().enumerate() {
318 let drawer_id = build_bootstrap_evidence_drawer_id(
319 wing,
320 Some(resolved_room.as_str()),
321 chunk,
322 &source_type,
323 Some(source_file.as_str()),
324 );
325 if !seen_drawer_ids.insert(drawer_id.clone()) {
326 stats.skipped += 1;
327 continue;
328 }
329 if !replacing
333 && db
334 .drawer_exists(&drawer_id)
335 .map_err(|source| IngestError::CheckDrawer {
336 drawer_id: drawer_id.clone(),
337 source,
338 })?
339 {
340 stats.skipped += 1;
341 continue;
342 }
343
344 if options.dry_run {
345 stats.chunks += 1;
346 continue;
347 }
348
349 pending.push((chunk_index, chunk, drawer_id));
350 }
351
352 if options.dry_run || pending.is_empty() {
353 return Ok(stats);
354 }
355
356 let chunk_refs = pending
357 .iter()
358 .map(|(_, chunk, _)| chunk.as_ref())
359 .collect::<Vec<_>>();
360 let vectors = embedder
361 .embed(&chunk_refs)
362 .await
363 .map_err(|source| IngestError::EmbedChunks {
364 path: path.to_path_buf(),
365 source,
366 })?;
367 if vectors.len() != pending.len() {
368 return Err(IngestError::EmbeddingCountMismatch {
369 path: path.to_path_buf(),
370 expected: pending.len(),
371 actual: vectors.len(),
372 });
373 }
374
375 let rows: Vec<(usize, &str, String, Vec<f32>)> = pending
376 .into_iter()
377 .zip(vectors)
378 .map(|((chunk_index, chunk, drawer_id), vector)| {
379 (chunk_index, chunk.as_ref(), drawer_id, vector)
380 })
381 .collect();
382
383 let insert_rows =
384 |db: &Database, stats: &mut IngestStats| -> std::result::Result<(), IngestError> {
385 for (chunk_index, chunk, drawer_id, vector) in &rows {
386 let drawer = Drawer::new_bootstrap_evidence(BootstrapEvidenceArgs {
387 id: drawer_id.clone(),
388 content: (*chunk).to_string(),
389 wing: wing.to_string(),
390 room: Some(resolved_room.clone()),
391 source_file: Some(source_file.clone()),
392 source_type: source_type.clone(),
393 added_at: current_timestamp(),
394 chunk_index: Some(*chunk_index as i64),
395 importance: 0,
396 });
397 let drawer = Drawer {
398 normalize_version: CURRENT_NORMALIZE_VERSION,
399 ..drawer
400 };
401
402 let inserted =
403 db.insert_drawer(&drawer)
404 .map_err(|source| IngestError::InsertDrawer {
405 drawer_id: drawer.id.clone(),
406 source,
407 })?;
408 if inserted {
409 db.insert_vector(drawer_id, vector).map_err(|source| {
410 IngestError::InsertVector {
411 drawer_id: drawer.id.clone(),
412 source,
413 }
414 })?;
415 stats.chunks += 1;
416 } else if replacing {
417 return Err(IngestError::ReplacementDrawerCollision {
418 drawer_id: drawer.id,
419 });
420 } else {
421 stats.skipped += 1;
422 }
423 }
424 Ok(())
425 };
426
427 if replacing {
428 let mut txn_stats = IngestStats::default();
430 db.with_immediate_transaction(|db| -> std::result::Result<(), IngestError> {
431 let replace_result = if options.replace_across_rooms {
432 db.replace_active_source_drawers_across_rooms_in_txn(&source_file, wing)
433 } else {
434 db.replace_active_source_drawers_in_txn(
435 &source_file,
436 wing,
437 Some(resolved_room.as_str()),
438 )
439 };
440 replace_result.map_err(|source| IngestError::ReplaceSource {
441 source_file: source_file.clone(),
442 source,
443 })?;
444 insert_rows(db, &mut txn_stats)
445 })?;
446 stats.chunks += txn_stats.chunks;
447 stats.skipped += txn_stats.skipped;
448 } else {
449 let mut direct_stats = IngestStats::default();
450 insert_rows(db, &mut direct_stats)?;
451 stats.chunks += direct_stats.chunks;
452 stats.skipped += direct_stats.skipped;
453 }
454
455 Ok(stats)
456}
457
458pub async fn ingest_dir<E: Embedder + ?Sized>(
459 db: &Database,
460 embedder: &E,
461 dir: &Path,
462 wing: &str,
463 room: Option<&str>,
464) -> Result<IngestStats> {
465 ingest_dir_with_options(
466 db,
467 embedder,
468 dir,
469 wing,
470 IngestOptions {
471 room,
472 source_root: Some(dir),
473 dry_run: false,
474 source_file_override: None,
475 replace_existing_source: false,
476 replace_across_rooms: false,
477 no_strip_noise: false,
478 diary_rollup: false,
479 diary_rollup_day: None,
480 project_filter: ProjectPathFilterOptions::default(),
481 },
482 )
483 .await
484}
485
486pub async fn ingest_dir_with_options<E: Embedder + ?Sized>(
487 db: &Database,
488 embedder: &E,
489 dir: &Path,
490 wing: &str,
491 options: IngestOptions<'_>,
492) -> Result<IngestStats> {
493 let mut stats = IngestStats::default();
494
495 for entry in project_walk(dir, &options.project_filter)? {
496 let entry = entry.map_err(|source| IngestError::WalkPath {
497 path: dir.to_path_buf(),
498 source,
499 })?;
500 let path = entry.path();
501 if path == dir || path.is_dir() {
502 continue;
503 }
504
505 if path.is_file() {
506 if should_skip_file(path, &options.project_filter) {
507 stats.skipped += 1;
508 continue;
509 }
510 let file_stats =
511 ingest_file_with_options(db, embedder, path, wing, options.clone()).await?;
512 stats.files += file_stats.files;
513 stats.chunks += file_stats.chunks;
514 stats.skipped += file_stats.skipped;
515 stats.noise_bytes_stripped =
516 merge_optional_sum(stats.noise_bytes_stripped, file_stats.noise_bytes_stripped);
517 }
518 }
519
520 Ok(stats)
521}
522
523fn merge_optional_sum(left: Option<u64>, right: Option<u64>) -> Option<u64> {
524 match (left, right) {
525 (Some(left), Some(right)) => Some(left + right),
526 (Some(value), None) | (None, Some(value)) => Some(value),
527 (None, None) => None,
528 }
529}
530
531fn source_type_for(format: Format) -> SourceType {
532 match format {
533 Format::ClaudeJsonl | Format::ChatGptJson | Format::CodexJsonl | Format::SlackJson => {
534 SourceType::Conversation
535 }
536 Format::PlainText => SourceType::Project,
537 }
538}
539
540fn should_skip_file(path: &Path, filter_options: &ProjectPathFilterOptions) -> bool {
541 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
542 return false;
543 };
544 if matches!(name, ".DS_Store" | ".gitignore" | ".mempalignore") || name.starts_with("._") {
545 return true;
546 }
547 if is_custom_ignore_file(path, filter_options) {
548 return true;
549 }
550
551 path.extension()
552 .and_then(|extension| extension.to_str())
553 .map(|extension| {
554 matches!(
555 extension.to_ascii_lowercase().as_str(),
556 "a" | "bmp"
557 | "class"
558 | "dll"
559 | "dylib"
560 | "exe"
561 | "gif"
562 | "ico"
563 | "jar"
564 | "jpeg"
565 | "jpg"
566 | "o"
567 | "pdf"
568 | "png"
569 | "so"
570 | "wasm"
571 | "webp"
572 | "zip"
573 )
574 })
575 .unwrap_or(false)
576}
577
578fn is_custom_ignore_file(path: &Path, filter_options: &ProjectPathFilterOptions) -> bool {
579 filter_options.custom_ignore_files.iter().any(|custom| {
580 path == custom || path.canonicalize().ok().as_ref() == custom.canonicalize().ok().as_ref()
581 })
582}
583
584fn normalize_source_file(path: &Path, source_root: Option<&Path>) -> String {
585 let normalized = source_root
586 .and_then(|root| path.strip_prefix(root).ok())
587 .filter(|relative| !relative.as_os_str().is_empty())
588 .map(Path::to_path_buf)
589 .or_else(|| path.file_name().map(PathBuf::from))
590 .unwrap_or_else(|| path.to_path_buf());
591
592 normalized.to_string_lossy().replace('\\', "/")
593}