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    #[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    // Per-source ingest lock (P9-B). Guards dedup-check + insert critical
284    // section against concurrent Claude↔Codex ingests of the same source.
285    // Skip in dry-run — no writes happen there, so race is impossible.
286    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}