Skip to main content

mempal_runtime/ingest/
mod.rs

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
34/// Max wait for per-source ingest lock before returning LockError::Timeout.
35const LOCK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
36
37/// Derive `mempal_home` from the DB path by taking the parent of
38/// `palace.db`. Falls back to `./` on unusual layouts.
39fn 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    /// Time waited acquiring the per-source ingest lock (P9-B). `None`
53    /// when the lock was bypassed (e.g. dry-run) or when no wait was
54    /// needed and the path took the fast exit before lock acquisition.
55    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    /// When replacing an existing source, delete its prior drawers across all
66    /// rooms (not just the freshly resolved room). Reindex sets this so a
67    /// source that re-routes to a new room does not leave stale drawers behind
68    /// in its old room. Ignored unless `replace_existing_source` is true.
69    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    /// Transaction bookkeeping error from the store layer (P117).
81    #[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    // Per-source ingest lock (P9-B). Guards dedup-check + insert critical
295    // section against concurrent Claude↔Codex ingests of the same source.
296    // Skip in dry-run — no writes happen there, so race is impossible.
297    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    // P117: the destructive source replacement is deferred until AFTER
309    // embeddings exist, and then runs in the same transaction as the
310    // inserts — an embed or insert failure must never leave the source's
311    // old drawers deleted.
312    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        // Under replacement the existence check is skipped: drawer ids are
330        // source-aware (P110), so a colliding id can only belong to this
331        // same source, whose rows are deleted in the replace transaction.
332        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        // Delete-old + insert-new commit or roll back together.
429        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}