1use std::path::PathBuf;
10
11use crate::Engine;
12use crate::auth::ScopedSpace;
13use crate::chunker::chunk_text;
14use crate::error::{Result, SconeError};
15
16pub(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 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 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 tx.execute(
154 "INSERT OR IGNORE INTO distill_queue (episode_id) VALUES (?1)",
155 [episode_id],
156 )?;
157 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 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#[derive(Debug, Default)]
200pub struct ScanReport {
201 pub ingested: usize,
202 pub deduplicated: usize,
203 pub skipped: usize,
204}
205
206impl Engine {
207 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 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(¤t)? {
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}