Skip to main content

scone_core/
ingest.rs

1//! Lane 1: episodic ingest (spec §6).
2//!
3//! Synchronous and offline-complete. One SQLite transaction is the unit of
4//! truth: episode, chunks, and embeddings commit together or not at all.
5//! Failures are typed values; nothing is ever deleted to handle one
6//! (memory/bugs.md P-2). Duplicates are a first-class outcome detected by
7//! UNIQUE(space_id, hash), never by matching error strings (P-5).
8
9use std::path::PathBuf;
10
11use crate::Engine;
12use crate::auth::ScopedSpace;
13use crate::chunker::chunk_text;
14use crate::error::{Result, SconeError};
15
16/// Default target chunk size in bytes (~250 tokens).
17pub(crate) const CHUNK_TARGET_BYTES: usize = 1000;
18
19const KINDS: [&str; 4] = ["note", "file", "conversation", "observation"];
20
21#[derive(Debug)]
22pub enum IngestInput {
23    Note { text: String },
24    File { path: PathBuf },
25}
26
27#[derive(Debug)]
28pub enum IngestOutcome {
29    Ingested { episode_id: i64, chunks: usize },
30    Deduplicated { episode_id: i64 },
31}
32
33impl Engine {
34    pub fn ingest(&mut self, space: &ScopedSpace, input: IngestInput) -> Result<IngestOutcome> {
35        let (kind, content, source) = match input {
36            IngestInput::Note { text } => ("note", text, None),
37            IngestInput::File { path } => {
38                let is_pdf = path
39                    .extension()
40                    .is_some_and(|e| e.eq_ignore_ascii_case("pdf"));
41                let text = if is_pdf {
42                    #[cfg(feature = "pdf")]
43                    {
44                        pdf_extract::extract_text(&path).map_err(|e| {
45                            SconeError::InvalidInput(format!(
46                                "{}: pdf extraction failed: {e}",
47                                path.display()
48                            ))
49                        })?
50                    }
51                    #[cfg(not(feature = "pdf"))]
52                    {
53                        return Err(SconeError::InvalidInput(format!(
54                            "{}: this build lacks the pdf feature",
55                            path.display()
56                        )));
57                    }
58                } else {
59                    let bytes = std::fs::read(&path)?;
60                    String::from_utf8(bytes).map_err(|_| {
61                        SconeError::InvalidInput(format!("{} is not valid UTF-8", path.display()))
62                    })?
63                };
64                ("file", text, Some(path.display().to_string()))
65            }
66        };
67        self.ingest_raw(space, kind, &content, source.as_deref(), None)
68    }
69
70    /// Import-grade ingest: explicit kind/source/created_at, same pipeline.
71    /// Returns the episode id and whether it was freshly stored. Public for
72    /// importers and harnesses that must preserve original timestamps —
73    /// temporal scoring is only as real as created_at.
74    pub fn import_episode(
75        &mut self,
76        space: &ScopedSpace,
77        kind: &str,
78        content: &str,
79        source: Option<&str>,
80        created_at: Option<&str>,
81    ) -> Result<(i64, bool)> {
82        match self.ingest_raw(space, kind, content, source, created_at)? {
83            IngestOutcome::Ingested { episode_id, .. } => Ok((episode_id, true)),
84            IngestOutcome::Deduplicated { episode_id } => Ok((episode_id, false)),
85        }
86    }
87
88    fn ingest_raw(
89        &mut self,
90        space: &ScopedSpace,
91        kind: &str,
92        content: &str,
93        source: Option<&str>,
94        created_at: Option<&str>,
95    ) -> Result<IngestOutcome> {
96        if !KINDS.contains(&kind) {
97            return Err(SconeError::InvalidInput(format!(
98                "kind must be one of {KINDS:?}, got {kind:?}"
99            )));
100        }
101        if content.trim().is_empty() {
102            return Err(SconeError::InvalidInput("content is empty".into()));
103        }
104        self.require_writable()?;
105
106        let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
107        let spans = chunk_text(content, self.chunk_target);
108        let texts: Vec<&str> = spans.iter().map(|s| &content[s.start..s.end]).collect();
109        let embeddings = self.embedder.embed(&texts)?;
110
111        let tx = self.conn.transaction()?;
112        let inserted = tx.execute(
113            "INSERT INTO episodes (space_id, kind, content, hash, source, created_at)
114             VALUES (?1, ?2, ?3, ?4, ?5,
115                     COALESCE(?6, strftime('%Y-%m-%dT%H:%M:%fZ','now')))
116             ON CONFLICT (space_id, hash) DO NOTHING",
117            rusqlite::params![space.id(), kind, content, hash, source, created_at],
118        )?;
119        if inserted == 0 {
120            let episode_id = tx.query_row(
121                "SELECT id FROM episodes WHERE space_id = ?1 AND hash = ?2",
122                rusqlite::params![space.id(), hash],
123                |r| r.get(0),
124            )?;
125            // Nothing was written; the revision must not move (bugs.md P-8).
126            return Ok(IngestOutcome::Deduplicated { episode_id });
127        }
128        let episode_id = tx.last_insert_rowid();
129        let mut chunk_ids = Vec::with_capacity(spans.len());
130        {
131            let mut stmt = tx.prepare(
132                "INSERT INTO chunks (episode_id, pos, start_byte, end_byte, embedding)
133                 VALUES (?1, ?2, ?3, ?4, ?5)",
134            )?;
135            for (pos, (span, emb)) in spans.iter().zip(&embeddings).enumerate() {
136                let blob: Vec<u8> = emb.iter().flat_map(|f| f.to_le_bytes()).collect();
137                stmt.execute(rusqlite::params![
138                    episode_id,
139                    pos as i64,
140                    span.start as i64,
141                    span.end as i64,
142                    blob
143                ])?;
144                chunk_ids.push(tx.last_insert_rowid());
145            }
146        }
147        tx.execute(
148            "UPDATE spaces SET revision = revision + 1 WHERE id = ?1",
149            [space.id()],
150        )?;
151        // Lane 2 is asynchronous: enqueue for distillation, never block
152        // ingest on a model (spec §6).
153        tx.execute(
154            "INSERT OR IGNORE INTO distill_queue (episode_id) VALUES (?1)",
155            [episode_id],
156        )?;
157        // Pin the embedder identity on first write (spec §9); later opens
158        // with a different embedder are refused until doctor --rebuild.
159        tx.execute(
160            "INSERT OR IGNORE INTO meta (key, value) VALUES ('embedder_id', ?1)",
161            [self.embedder.id()],
162        )?;
163        tx.execute(
164            "INSERT OR IGNORE INTO meta (key, value) VALUES ('embedder_dim', ?1)",
165            [self.embedder.dim().to_string()],
166        )?;
167        tx.commit()?;
168
169        // Feed the derived indexes after truth commits. An index failure
170        // never fails the ingest: it marks the indexes dirty for
171        // `doctor --rebuild` and the status surface says so (spec §10).
172        let fts_rows: Vec<(u64, u64, &str)> = chunk_ids
173            .iter()
174            .zip(&texts)
175            .map(|(id, text)| (*id as u64, space.id() as u64, *text))
176            .collect();
177        let vec_rows: Vec<(u64, &[f32])> = chunk_ids
178            .iter()
179            .zip(&embeddings)
180            .map(|(id, emb)| (*id as u64, emb.as_slice()))
181            .collect();
182        let index_result = self
183            .fts
184            .add(&fts_rows)
185            .and_then(|()| self.vectors.add(&vec_rows));
186        match index_result {
187            Ok(()) => self.indexes_dirty = true,
188            Err(_) => self.set_meta("index_dirty", "1")?,
189        }
190
191        Ok(IngestOutcome::Ingested {
192            episode_id,
193            chunks: spans.len(),
194        })
195    }
196}
197
198/// Result of one directory scan.
199#[derive(Debug, Default)]
200pub struct ScanReport {
201    pub ingested: usize,
202    pub deduplicated: usize,
203    pub skipped: usize,
204}
205
206impl Engine {
207    /// Recursively ingest text files under `dir`. Hidden entries and
208    /// dependency/build directories are skipped; binary and oversized
209    /// files are counted in `skipped`, never silently dropped (spec §10).
210    /// Content-hash dedup makes rescans cheap and edits append-only.
211    pub fn ingest_directory(
212        &mut self,
213        space: &ScopedSpace,
214        dir: &std::path::Path,
215        max_file_bytes: u64,
216    ) -> Result<ScanReport> {
217        self.ingest_directory_tagged(space, dir, max_file_bytes, &[])
218    }
219
220    /// Directory scan with curation tags: every ingested (or re-seen) file
221    /// gets the given tags plus its lowercase extension as a source tag.
222    pub fn ingest_directory_tagged(
223        &mut self,
224        space: &ScopedSpace,
225        dir: &std::path::Path,
226        max_file_bytes: u64,
227        tags: &[&str],
228    ) -> Result<ScanReport> {
229        const SKIP_DIRS: [&str; 4] = ["node_modules", "target", ".git", "__pycache__"];
230        let mut report = ScanReport::default();
231        let mut stack = vec![dir.to_path_buf()];
232        while let Some(current) = stack.pop() {
233            for entry in std::fs::read_dir(&current)? {
234                let entry = entry?;
235                let path = entry.path();
236                let name = entry.file_name();
237                let name = name.to_string_lossy();
238                if name.starts_with('.') {
239                    continue;
240                }
241                let file_type = entry.file_type()?;
242                if file_type.is_dir() {
243                    if !SKIP_DIRS.contains(&name.as_ref()) {
244                        stack.push(path);
245                    }
246                    continue;
247                }
248                if !file_type.is_file() {
249                    continue;
250                }
251                if entry.metadata()?.len() > max_file_bytes {
252                    report.skipped += 1;
253                    continue;
254                }
255                let extension = path.extension().map(|e| e.to_string_lossy().to_lowercase());
256                match self.ingest(space, IngestInput::File { path }) {
257                    Ok(outcome) => {
258                        let (episode_id, fresh) = match outcome {
259                            IngestOutcome::Ingested { episode_id, .. } => (episode_id, true),
260                            IngestOutcome::Deduplicated { episode_id } => (episode_id, false),
261                        };
262                        let mut all: Vec<&str> = tags.to_vec();
263                        if let Some(ext) = extension.as_deref() {
264                            all.push(ext);
265                        }
266                        if !all.is_empty() {
267                            self.tag_episode(space, episode_id, &all)?;
268                        }
269                        if fresh {
270                            report.ingested += 1;
271                        } else {
272                            report.deduplicated += 1;
273                        }
274                    }
275                    Err(SconeError::InvalidInput(_)) => report.skipped += 1,
276                    Err(other) => return Err(other),
277                }
278            }
279        }
280        Ok(report)
281    }
282}