1use super::*;
2
3impl Storage {
4 pub fn insert_chunk(&self, c: &ChunkRow) -> Result<()> {
5 self.conn.execute(
6 "INSERT INTO chunks (
7 id, skill_name, seq, content, trigger_desc, anti_trigger_desc,
8 content_hash, token_count, origin, source, maturity, related_ids,
9 protected, state, state_reason, state_updated_at,
10 confidence, confidence_base, confidence_reason, version, distilled_from,
11 distill_provider, distill_model, distill_prompt_version, parent_id,
12 selected_count, used_count, used_success_count,
13 success_trace_ids_count, last_success_at, last_agg_ts,
14 embed_version, created_at, updated_at, last_used_at, agent
15 ) VALUES (
16 ?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,
17 ?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,
18 ?26,?27,?28,?29,?30,?31,?32,?33,?34,?35,?36
19 )",
20 params![
21 c.id,
22 c.skill_name,
23 c.seq,
24 c.content,
25 c.trigger_desc,
26 c.anti_trigger_desc,
27 c.content_hash,
28 c.token_count,
29 c.origin,
30 c.source,
31 c.maturity,
32 c.related_ids,
33 c.protected,
34 c.state,
35 c.state_reason,
36 c.state_updated_at,
37 c.confidence,
38 c.confidence,
39 c.confidence_reason,
40 c.version,
41 c.distilled_from,
42 c.distill_provider,
43 c.distill_model,
44 c.distill_prompt_version,
45 c.parent_id,
46 c.selected_count,
47 c.used_count,
48 c.used_success_count,
49 c.success_trace_ids_count,
50 c.last_success_at,
51 c.last_agg_ts,
52 c.embed_version,
53 c.created_at,
54 c.updated_at,
55 c.last_used_at,
56 c.agent
57 ],
58 )?;
59 if c.origin != "spark" {
64 self.replace_chunk_entities(
65 &c.id,
66 &crate::entities::extract_entities(&c.content, c.trigger_desc.as_deref()),
67 )?;
68 }
69 Ok(())
70 }
71
72 pub fn replace_chunk_entities(
74 &self,
75 chunk_id: &str,
76 entities: &[crate::entities::ExtractedEntity],
77 ) -> Result<()> {
78 self.conn
79 .execute("DELETE FROM chunk_entities WHERE chunk_id=?1", params![chunk_id])?;
80 if entities.is_empty() {
81 return Ok(());
82 }
83 let mut stmt = self.conn.prepare_cached(
84 "INSERT OR IGNORE INTO chunk_entities (chunk_id, entity, etype, weight)
85 VALUES (?1, ?2, ?3, 1.0)",
86 )?;
87 for e in entities {
88 stmt.execute(params![chunk_id, e.entity, e.etype])?;
89 }
90 Ok(())
91 }
92
93 pub fn entities_for_chunks(&self, ids: &[&str]) -> Result<HashMap<String, Vec<String>>> {
96 let mut out: HashMap<String, Vec<String>> = HashMap::new();
97 if ids.is_empty() {
98 return Ok(out);
99 }
100 let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
101 let sql = format!(
102 "SELECT chunk_id, entity FROM chunk_entities WHERE chunk_id IN ({placeholders})"
103 );
104 let mut stmt = self.conn.prepare(&sql)?;
105 let params = rusqlite::params_from_iter(ids.iter());
106 let rows = stmt.query_map(params, |r| {
107 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
108 })?;
109 for row in rows {
110 let (cid, ent) = row?;
111 out.entry(cid).or_default().push(ent);
112 }
113 Ok(out)
114 }
115
116 pub fn entity_links(&self, entities: &[&str]) -> Result<Vec<(String, String)>> {
121 if entities.is_empty() {
122 return Ok(Vec::new());
123 }
124 let placeholders = entities.iter().map(|_| "?").collect::<Vec<_>>().join(",");
125 let sql = format!(
126 "SELECT e.entity, e.chunk_id
127 FROM chunk_entities e
128 JOIN chunks c ON c.id = e.chunk_id
129 WHERE e.entity IN ({placeholders})
130 AND c.state != 'archived' AND c.origin != 'spark'"
131 );
132 let mut stmt = self.conn.prepare(&sql)?;
133 let params = rusqlite::params_from_iter(entities.iter());
134 let rows = stmt.query_map(params, |r| {
135 Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?))
136 })?;
137 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
138 }
139
140 pub fn insert_vec_content(&self, chunk_id: &str, emb: &[u8]) -> Result<()> {
141 self.conn.execute(
142 "INSERT OR REPLACE INTO vec_content(chunk_id, embedding) VALUES (?,?)",
143 params![chunk_id, emb],
144 )?;
145 self.note_vector_write(&self.vec_content_cache, chunk_id, emb)
146 }
147
148 pub fn insert_vec_trigger(&self, chunk_id: &str, emb: &[u8]) -> Result<()> {
149 self.conn.execute(
150 "INSERT OR REPLACE INTO vec_trigger(chunk_id, embedding) VALUES (?,?)",
151 params![chunk_id, emb],
152 )?;
153 self.note_vector_write(&self.vec_trigger_cache, chunk_id, emb)
154 }
155
156 fn note_vector_write(&self, cache: &VectorCache, chunk_id: &str, emb: &[u8]) -> Result<()> {
166 self.bump_vector_revision()?;
167 if let Some(entries) = cache.borrow_mut().as_mut() {
168 let mut v = unpack_embedding(emb);
169 l2_normalize(&mut v);
170 match entries.iter_mut().find(|(id, _)| id == chunk_id) {
171 Some(slot) => slot.1 = v,
172 None => entries.push((chunk_id.to_string(), v)),
173 }
174 }
175 self.sync_vector_revision()
176 }
177
178 fn bump_vector_revision(&self) -> Result<()> {
182 self.conn.execute(
183 "INSERT INTO meta(key, value) VALUES ('vector_revision', '1')
184 ON CONFLICT(key) DO UPDATE SET value=CAST(value AS INTEGER)+1",
185 [],
186 )?;
187 Ok(())
188 }
189
190 fn sync_vector_revision(&self) -> Result<()> {
193 let current = self
194 .get_meta("vector_revision")?
195 .and_then(|v| v.parse::<i64>().ok())
196 .unwrap_or(0);
197 self.vector_cache_revision.set(Some(current));
198 Ok(())
199 }
200
201 pub(crate) fn invalidate_vector_caches(&self) {
205 *self.vec_content_cache.borrow_mut() = None;
206 *self.vec_trigger_cache.borrow_mut() = None;
207 self.vector_cache_revision.set(None);
208 }
209
210 pub fn list_chunks(
214 &self,
215 state: Option<&str>,
216 origin: Option<&str>,
217 limit: usize,
218 offset: usize,
219 ) -> Result<Vec<Value>> {
220 let mut sql = String::from(
221 "SELECT id, skill_name, seq, origin, state, state_reason, maturity, \
222 confidence, token_count, protected, selected_count, used_count, \
223 used_success_count, substr(content, 1, 280) AS content_preview, \
224 created_at, updated_at, last_used_at \
225 FROM chunks",
226 );
227 let mut clauses: Vec<&str> = Vec::new();
228 if state.is_some() {
229 clauses.push("state = :state");
230 }
231 if origin.is_some() {
232 clauses.push("origin = :origin");
233 }
234 if !clauses.is_empty() {
235 sql.push_str(" WHERE ");
236 sql.push_str(&clauses.join(" AND "));
237 }
238 sql.push_str(" ORDER BY created_at DESC LIMIT :limit OFFSET :offset");
239
240 let mut stmt = self.conn.prepare(&sql)?;
241 let names: Vec<String> = stmt.column_names().into_iter().map(String::from).collect();
242 let mut params: Vec<(&str, &dyn rusqlite::ToSql)> = Vec::new();
243 if let Some(s) = state.as_ref() {
244 params.push((":state", s));
245 }
246 if let Some(o) = origin.as_ref() {
247 params.push((":origin", o));
248 }
249 let limit_i = limit as i64;
250 let offset_i = offset as i64;
251 params.push((":limit", &limit_i));
252 params.push((":offset", &offset_i));
253
254 let rows = stmt.query_map(params.as_slice(), |r| row_to_json_with_names(r, &names))?;
255 let mut out = Vec::new();
256 for row in rows {
257 out.push(row?);
258 }
259 Ok(out)
260 }
261
262 pub fn export_chunks(&self, include_archived: bool) -> Result<Vec<Value>> {
267 let sql = if include_archived {
268 "SELECT id, content, trigger_desc, anti_trigger_desc, skill_name,
269 origin, source, state, confidence, created_at
270 FROM chunks WHERE origin != 'spark' ORDER BY created_at ASC"
271 } else {
272 "SELECT id, content, trigger_desc, anti_trigger_desc, skill_name,
273 origin, source, state, confidence, created_at
274 FROM chunks WHERE origin != 'spark' AND state != 'archived'
275 ORDER BY created_at ASC"
276 };
277 let mut stmt = self.conn.prepare(sql)?;
278 let rows = stmt.query_map([], |r| {
279 Ok(serde_json::json!({
280 "id": r.get::<_, String>(0)?,
281 "content": r.get::<_, String>(1)?,
282 "trigger_desc": r.get::<_, Option<String>>(2)?,
283 "anti_trigger_desc": r.get::<_, Option<String>>(3)?,
284 "skill_name": r.get::<_, Option<String>>(4)?,
285 "origin": r.get::<_, String>(5)?,
286 "source": r.get::<_, Option<String>>(6)?,
287 "state": r.get::<_, String>(7)?,
288 "confidence": r.get::<_, Option<f64>>(8)?,
289 "created_at": r.get::<_, String>(9)?,
290 }))
291 })?;
292 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
293 }
294
295 pub fn get_chunk(&self, id: &str) -> Result<Option<Value>> {
296 let mut stmt = self
297 .conn
298 .prepare_cached("SELECT * FROM chunks WHERE id=?")?;
299 let row = stmt.query_row([id], row_to_json);
300 match row {
301 Ok(v) => Ok(Some(v)),
302 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
303 Err(e) => Err(e.into()),
304 }
305 }
306
307 pub fn update_chunk_state(
308 &self,
309 id: &str,
310 state: &str,
311 reason: Option<&str>,
312 now: &str,
313 ) -> Result<()> {
314 self.conn.execute(
315 "UPDATE chunks SET state=?, state_reason=?, state_updated_at=?, updated_at=? WHERE id=?",
316 params![state, reason, now, now, id],
317 )?;
318 Ok(())
319 }
320
321 pub fn update_chunk_confidence(
322 &self,
323 id: &str,
324 conf: f64,
325 reason: Option<&str>,
326 now: &str,
327 ) -> Result<()> {
328 self.conn.execute(
329 "UPDATE chunks
330 SET confidence=?, confidence_base=?, confidence_reason=?, updated_at=?
331 WHERE id=?",
332 params![conf, conf, reason, now, id],
333 )?;
334 Ok(())
335 }
336
337 pub fn update_chunk_last_used(&self, id: &str, now: &str) -> Result<()> {
338 self.conn.execute(
339 "UPDATE chunks SET last_used_at=?, updated_at=? WHERE id=?",
340 params![now, now, id],
341 )?;
342 Ok(())
343 }
344
345 pub fn get_chunk_by_hash(&self, hash: &str) -> Result<Option<Value>> {
346 let row = self.conn.query_row(
347 "SELECT * FROM chunks WHERE content_hash=? LIMIT 1",
348 [hash],
349 row_to_json,
350 );
351 match row {
352 Ok(v) => Ok(Some(v)),
353 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
354 Err(e) => Err(e.into()),
355 }
356 }
357
358 pub fn search_vec_content(&self, query: &[f32], limit: usize) -> Result<Vec<(String, f32)>> {
363 self.search_vec(&self.vec_content_cache, "vec_content", query, limit)
364 }
365
366 pub fn search_vec_trigger(&self, query: &[f32], limit: usize) -> Result<Vec<(String, f32)>> {
367 self.search_vec(&self.vec_trigger_cache, "vec_trigger", query, limit)
368 }
369
370 fn search_vec(
371 &self,
372 cache_cell: &VectorCache,
373 table: &str,
374 query: &[f32],
375 limit: usize,
376 ) -> Result<Vec<(String, f32)>> {
377 if limit == 0 {
378 return Ok(Vec::new());
379 }
380 self.refresh_vector_caches_if_changed()?;
381
382 if cache_cell.borrow().is_none() {
386 let sql = format!("SELECT chunk_id, embedding FROM {table}");
387 let mut stmt = self.conn.prepare(&sql)?;
388 let raw: Vec<(String, Vec<u8>)> = stmt
389 .query_map([], |r| {
390 Ok((r.get::<_, String>(0)?, r.get::<_, Vec<u8>>(1)?))
391 })?
392 .collect::<rusqlite::Result<Vec<_>>>()?;
393 let mut entries: Vec<(String, Vec<f32>)> = Vec::with_capacity(raw.len());
394 for (id, blob) in raw {
395 if blob.is_empty() || blob.len() % 4 != 0 {
399 return Err(crate::errors::InnateError::Other(format!(
400 "corrupt embedding for chunk {id} in {table}: {} bytes (not a non-zero multiple of 4)",
401 blob.len()
402 )));
403 }
404 let mut v = unpack_embedding(&blob);
405 l2_normalize(&mut v);
406 entries.push((id, v));
407 }
408 *cache_cell.borrow_mut() = Some(entries);
409 }
410
411 let cache = cache_cell.borrow();
412 let entries = cache.as_ref().unwrap();
413
414 let mut q = query.to_vec();
417 l2_normalize(&mut q);
418
419 let mut scored: Vec<(usize, f32)> = entries
425 .iter()
426 .enumerate()
427 .filter(|(_, (_, v))| v.len() == q.len())
428 .map(|(i, (_, v))| (i, dot_product(&q, v)))
429 .collect();
430 if scored.len() > limit {
431 scored.select_nth_unstable_by(limit - 1, |a, b| {
432 b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
433 });
434 scored.truncate(limit);
435 }
436 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
437 Ok(scored
438 .into_iter()
439 .map(|(i, sim)| (entries[i].0.clone(), sim))
440 .collect())
441 }
442
443 pub fn search_lexical(&self, query: &str, limit: usize) -> Result<Vec<(String, f32)>> {
449 if limit == 0 {
450 return Ok(Vec::new());
451 }
452 let Some(match_expr) = fts5_match_query(query) else {
453 return Ok(Vec::new());
454 };
455 let mut stmt = self.conn.prepare_cached(
456 "SELECT chunks_fts.id, bm25(chunks_fts) AS score
457 FROM chunks_fts
458 JOIN chunks c ON c.id = chunks_fts.id
459 WHERE chunks_fts MATCH ?1
460 AND c.state != 'archived' AND c.origin != 'spark'
461 ORDER BY score ASC
462 LIMIT ?2",
463 )?;
464 let rows = stmt.query_map(params![match_expr, limit as i64], |r| {
465 Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?))
466 })?;
467 let raw: Vec<(String, f64)> = rows.collect::<rusqlite::Result<Vec<_>>>()?;
471 let best_rel = raw.iter().map(|(_, s)| -s).fold(f64::MIN, f64::max);
472 let out = raw
473 .into_iter()
474 .enumerate()
475 .map(|(i, (id, score))| {
476 let sim = if best_rel > 0.0 {
477 (-score / best_rel).clamp(0.0, 1.0) as f32
478 } else {
479 ((limit - i) as f32) / (limit as f32)
481 };
482 (id, sim)
483 })
484 .collect();
485 Ok(out)
486 }
487
488 fn refresh_vector_caches_if_changed(&self) -> Result<()> {
489 let current = self
490 .get_meta("vector_revision")?
491 .and_then(|value| value.parse::<i64>().ok())
492 .unwrap_or(0);
493 let previous = self.vector_cache_revision.replace(Some(current));
494 if previous.is_some_and(|revision| revision != current) {
495 *self.vec_content_cache.borrow_mut() = None;
496 *self.vec_trigger_cache.borrow_mut() = None;
497 }
498 Ok(())
499 }
500
501 pub fn get_chunks_by_ids(&self, ids: &[&str]) -> Result<HashMap<String, Value>> {
503 if ids.is_empty() {
504 return Ok(HashMap::new());
505 }
506 let placeholders = ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
507 let sql = format!("SELECT * FROM chunks WHERE id IN ({placeholders})");
508 let mut stmt = self.conn.prepare(&sql)?;
509 let names: Vec<String> = stmt.column_names().iter().map(|s| s.to_string()).collect();
510 let rows = stmt.query_map(rusqlite::params_from_iter(ids.iter()), |r| {
511 row_to_json_with_names(r, &names)
512 })?;
513 let mut map = HashMap::with_capacity(ids.len());
514 for row in rows {
515 let row = row?;
516 if let Some(id) = row.get("id").and_then(Value::as_str) {
517 map.insert(id.to_string(), row);
518 }
519 }
520 Ok(map)
521 }
522
523 pub fn is_hash_invalidated(&self, hash: &str) -> Result<bool> {
528 let count: i64 = self.conn.query_row(
529 "SELECT count(*) FROM invalidated_hashes WHERE content_hash=?",
530 [hash],
531 |r| r.get(0),
532 )?;
533 Ok(count > 0)
534 }
535
536 pub fn insert_invalidated_hash(
537 &self,
538 hash: &str,
539 reason: Option<&str>,
540 ts: &str,
541 ) -> Result<()> {
542 self.conn.execute(
543 "INSERT OR IGNORE INTO invalidated_hashes(content_hash, reason, ts) VALUES (?,?,?)",
544 params![hash, reason, ts],
545 )?;
546 Ok(())
547 }
548
549 pub(crate) fn query_chunks(&self, sql: &str) -> Result<Vec<Value>> {
557 self.query_json(sql, params![])
558 }
559
560 pub(crate) fn query_chunks_params<P: rusqlite::Params>(
561 &self,
562 sql: &str,
563 p: P,
564 ) -> Result<Vec<Value>> {
565 self.query_json(sql, p)
566 }
567
568 pub fn get_deps(&self, chunk_id: &str) -> Result<Vec<DepEdge>> {
573 let mut stmt = self
574 .conn
575 .prepare_cached("SELECT dst, kind, dst_lib FROM deps WHERE src=?")?;
576 let rows = stmt.query_map([chunk_id], |r| {
577 Ok((
578 r.get::<_, String>(0)?,
579 r.get::<_, String>(1)?,
580 r.get::<_, Option<String>>(2)?,
581 ))
582 })?;
583 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
584 }
585
586 pub fn get_deps_batch(&self, srcs: &[&str]) -> Result<HashMap<String, Vec<DepEdge>>> {
590 if srcs.is_empty() {
591 return Ok(HashMap::new());
592 }
593 let placeholders = srcs.iter().map(|_| "?").collect::<Vec<_>>().join(",");
594 let sql = format!("SELECT src, dst, kind, dst_lib FROM deps WHERE src IN ({placeholders})");
595 let mut stmt = self.conn.prepare(&sql)?;
596 let rows = stmt.query_map(rusqlite::params_from_iter(srcs.iter()), |r| {
597 Ok((
598 r.get::<_, String>(0)?,
599 r.get::<_, String>(1)?,
600 r.get::<_, String>(2)?,
601 r.get::<_, Option<String>>(3)?,
602 ))
603 })?;
604 let mut map: HashMap<String, Vec<(String, String, Option<String>)>> = HashMap::new();
605 for row in rows {
606 let (src, dst, kind, lib) = row?;
607 map.entry(src).or_default().push((dst, kind, lib));
608 }
609 Ok(map)
610 }
611
612 pub fn get_reverse_deps(&self, chunk_id: &str) -> Result<Vec<String>> {
613 let mut stmt = self
614 .conn
615 .prepare_cached("SELECT src FROM deps WHERE dst=?")?;
616 let rows = stmt.query_map([chunk_id], |r| r.get::<_, String>(0))?;
617 Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
618 }
619
620 pub fn insert_dep(
621 &self,
622 src: &str,
623 dst: &str,
624 kind: &str,
625 dst_lib: Option<&str>,
626 ) -> Result<()> {
627 self.conn.execute(
628 "INSERT OR IGNORE INTO deps(src,dst,kind,dst_lib) VALUES (?,?,?,?)",
629 params![src, dst, kind, dst_lib],
630 )?;
631 Ok(())
632 }
633
634 pub fn upsert_chunk_success_trace(
639 &self,
640 chunk_id: &str,
641 trace_id: &str,
642 ts: &str,
643 ) -> Result<()> {
644 self.conn.execute(
645 "INSERT OR IGNORE INTO chunk_success_traces(chunk_id, trace_id, ts) VALUES (?,?,?)",
646 params![chunk_id, trace_id, ts],
647 )?;
648 Ok(())
649 }
650
651 }
653
654pub(crate) fn fts5_match_query(query: &str) -> Option<String> {
660 const STOP: &[&str] = &[
663 "the", "a", "an", "to", "of", "in", "on", "for", "and", "or", "is", "do", "i",
664 ];
665 let mut seen = std::collections::HashSet::new();
666 let tokens: Vec<String> = query
667 .split(|c: char| !c.is_alphanumeric())
668 .filter(|t| t.len() >= 2)
669 .map(|t| t.to_lowercase())
670 .filter(|t| !STOP.contains(&t.as_str()))
671 .filter(|t| seen.insert(t.clone()))
672 .take(32)
673 .map(|t| format!("\"{t}\""))
674 .collect();
675 if tokens.is_empty() {
676 None
677 } else {
678 Some(tokens.join(" OR "))
679 }
680}