Skip to main content

innate_core/kb/
lifecycle.rs

1use super::*;
2
3impl KnowledgeBase {
4    pub fn add(
5        &self,
6        content: &str,
7        kind: &str,
8        trigger_desc: Option<&str>,
9        anti_trigger_desc: Option<&str>,
10        source: &str,
11        skill_name: Option<&str>,
12    ) -> Result<String> {
13        if !matches!(kind, "note" | "skill") {
14            return Err(InnateError::InvalidState(format!("invalid kind: {kind}")));
15        }
16        if !matches!(source, "chat" | "manual" | "doc" | "agent") {
17            return Err(InnateError::InvalidState(format!(
18                "invalid source: {source}"
19            )));
20        }
21
22        let (content, action) = self.sanitize_content(content);
23        if action == SanitizeAction::Discard {
24            return Ok(String::new());
25        }
26
27        let trigger_clean = trigger_desc.and_then(|t| {
28            let (cleaned, act) = self.sanitizer.sanitize(t);
29            if act == SanitizeAction::Discard {
30                None
31            } else {
32                Some(cleaned)
33            }
34        });
35        let anti_trigger_clean = anti_trigger_desc.and_then(|t| {
36            let (cleaned, act) = self.sanitizer.sanitize(t);
37            if act == SanitizeAction::Discard {
38                None
39            } else {
40                Some(cleaned)
41            }
42        });
43
44        let h = content_hash(&content);
45        if self.storage.is_hash_invalidated(&h)? {
46            return Err(InnateError::InvalidState(
47                "content hash is invalidated".into(),
48            ));
49        }
50
51        // Idempotency check
52        let existing = self.storage.query_chunks_params(
53            "SELECT id FROM chunks WHERE content_hash=? AND origin!='spark' AND state IN ('active','pending') ORDER BY created_at ASC LIMIT 1",
54            rusqlite::params![h],
55        )?;
56        if let Some(e) = existing.first() {
57            if let Some(id) = e.get("id").and_then(Value::as_str) {
58                return Ok(id.to_string());
59            }
60        }
61
62        let now = utc_now_iso();
63        let chunk_id = gen_uuid();
64        let redacted = action == SanitizeAction::Redact;
65
66        let (origin, state, conf, prot, init_state_reason) = if source == "agent" {
67            (
68                "captured",
69                "pending",
70                if redacted { 0.4 } else { 0.60 },
71                0,
72                "init:captured_agent",
73            )
74        } else if kind == "skill" {
75            (
76                "installed",
77                "active",
78                if redacted { 0.4 } else { 0.85 },
79                1,
80                "init:installed",
81            )
82        } else {
83            (
84                "captured",
85                "active",
86                if redacted { 0.4 } else { 0.60 },
87                0,
88                "init:captured",
89            )
90        };
91
92        // Embedding — fall back to embedding_pending on failure.
93        let trigger_str = trigger_clean.as_deref().unwrap_or(&content);
94        let (cvec, tvec, embed_ver, final_state_reason) = match (
95            self.embedding.embed_content(&content),
96            self.embedding.embed_trigger(trigger_str),
97        ) {
98            (Ok(cv), Ok(tv)) => (cv, tv, 1i64, init_state_reason.to_string()),
99            _ => (
100                vec![],
101                vec![],
102                0i64,
103                format!("embedding_pending:target={state}"),
104            ),
105        };
106
107        let tokens = estimate_tokens(&content) as i64;
108        let row = ChunkRow {
109            id: chunk_id.clone(),
110            skill_name: skill_name.map(str::to_string),
111            content: content.clone(),
112            trigger_desc: trigger_clean.clone(),
113            anti_trigger_desc: anti_trigger_clean.clone(),
114            content_hash: h,
115            token_count: Some(tokens),
116            origin: origin.to_string(),
117            source: Some(source.to_string()),
118            protected: prot,
119            state: state.to_string(),
120            state_reason: Some(final_state_reason),
121            confidence: conf,
122            confidence_reason: Some(format!("init:{origin}")),
123            version: 1,
124            embed_version: embed_ver,
125            created_at: now.clone(),
126            updated_at: now.clone(),
127            ..Default::default()
128        };
129
130        self.storage.begin_immediate()?;
131        let result = (|| -> Result<()> {
132            self.storage.insert_chunk(&row)?;
133            if embed_ver > 0 {
134                self.storage
135                    .insert_vec_content(&chunk_id, &pack_embedding(&cvec))?;
136                self.storage
137                    .insert_vec_trigger(&chunk_id, &pack_embedding(&tvec))?;
138            }
139            self.storage.commit()
140        })();
141        if result.is_err() {
142            let _ = self.storage.rollback();
143        }
144        result?;
145        Ok(chunk_id)
146    }
147
148    // ------------------------------------------------------------------
149    // Public API 4: spark
150    // ------------------------------------------------------------------
151
152    pub fn spark(
153        &self,
154        content: &str,
155        trigger_desc: Option<&str>,
156        anti_trigger_desc: Option<&str>,
157    ) -> Result<String> {
158        let (content, action) = self.sanitize_content(content);
159        if action == SanitizeAction::Discard {
160            return Ok(String::new());
161        }
162
163        let trigger_clean = trigger_desc.and_then(|t| {
164            let (cleaned, act) = self.sanitizer.sanitize(t);
165            if act == SanitizeAction::Discard {
166                None
167            } else {
168                Some(cleaned)
169            }
170        });
171        let anti_trigger_clean = anti_trigger_desc.and_then(|t| {
172            let (cleaned, act) = self.sanitizer.sanitize(t);
173            if act == SanitizeAction::Discard {
174                None
175            } else {
176                Some(cleaned)
177            }
178        });
179
180        let h = content_hash(&content);
181        if self.storage.is_hash_invalidated(&h)? {
182            return Err(InnateError::InvalidState(
183                "content hash is invalidated".into(),
184            ));
185        }
186
187        // Quick related recall (trace=false, no recursion risk)
188        let related: Vec<String> = self
189            .recall(
190                &content,
191                2000,
192                false,
193                false,
194                Some(5),
195                "sdk",
196                "false",
197                false,
198                "off",
199            )
200            .map(|r| {
201                r.knowledge
202                    .iter()
203                    .filter_map(|c| c["id"].as_str().map(str::to_string))
204                    .collect()
205            })
206            .unwrap_or_default();
207
208        let now = utc_now_iso();
209        let chunk_id = gen_uuid();
210        let tokens = estimate_tokens(&content) as i64;
211
212        let trigger_str = trigger_clean.as_deref().unwrap_or(&content);
213        let (cvec, tvec, embed_ver, state_reason) = match (
214            self.embedding.embed_content(&content),
215            self.embedding.embed_trigger(trigger_str),
216        ) {
217            (Ok(cv), Ok(tv)) => (cv, tv, 1i64, "init:spark".to_string()),
218            _ => (
219                vec![],
220                vec![],
221                0i64,
222                "embedding_pending:target=active".to_string(),
223            ),
224        };
225
226        let row = ChunkRow {
227            id: chunk_id.clone(),
228            content: content.clone(),
229            trigger_desc: trigger_clean.clone(),
230            anti_trigger_desc: anti_trigger_clean.clone(),
231            content_hash: h,
232            token_count: Some(tokens),
233            origin: "spark".to_string(),
234            maturity: Some("seed".to_string()),
235            related_ids: if related.is_empty() {
236                None
237            } else {
238                Some(related.join(","))
239            },
240            state: "active".to_string(),
241            state_reason: Some(state_reason),
242            confidence: 0.5,
243            version: 1,
244            embed_version: embed_ver,
245            created_at: now.clone(),
246            updated_at: now.clone(),
247            ..Default::default()
248        };
249
250        self.storage.begin_immediate()?;
251        let result = (|| -> Result<()> {
252            self.storage.insert_chunk(&row)?;
253            if embed_ver > 0 {
254                self.storage
255                    .insert_vec_content(&chunk_id, &pack_embedding(&cvec))?;
256                self.storage
257                    .insert_vec_trigger(&chunk_id, &pack_embedding(&tvec))?;
258            }
259            self.storage.commit()
260        })();
261        if result.is_err() {
262            let _ = self.storage.rollback();
263        }
264        result?;
265        Ok(chunk_id)
266    }
267
268    // ------------------------------------------------------------------
269    // Public API 5: mature_spark / promote_spark / drop_spark
270    // ------------------------------------------------------------------
271
272    pub fn mature_spark(&self, spark_id: &str, to: &str) -> Result<()> {
273        let chunk = self
274            .storage
275            .get_chunk(spark_id)?
276            .ok_or_else(|| InnateError::ChunkNotFound(spark_id.to_string()))?;
277        if chunk.get("origin").and_then(Value::as_str) != Some("spark") {
278            return Err(InnateError::ChunkNotFound(spark_id.to_string()));
279        }
280        let current = chunk
281            .get("maturity")
282            .and_then(Value::as_str)
283            .unwrap_or("seed");
284        let valid_next: &[&str] = match current {
285            "seed" => &["sprouting"],
286            "sprouting" => &["incubating"],
287            _ => {
288                return Err(InnateError::InvalidState(format!(
289                    "spark {spark_id} already {current}"
290                )))
291            }
292        };
293        if current == to {
294            return Ok(());
295        }
296        if !valid_next.contains(&to) {
297            return Err(InnateError::InvalidState(format!(
298                "invalid spark maturity transition: {current} -> {to}"
299            )));
300        }
301        let now = utc_now_iso();
302        self.storage.begin_immediate()?;
303        let result = self
304            .storage
305            .query_chunks_params(
306                "UPDATE chunks SET maturity=?, updated_at=? WHERE id=?",
307                rusqlite::params![to, now, spark_id],
308            )
309            .and_then(|_| self.storage.commit());
310        if result.is_err() {
311            let _ = self.storage.rollback();
312        }
313        result.map(|_| ())
314    }
315
316    pub fn promote_spark(&self, spark_id: &str, to: &str) -> Result<String> {
317        let spark = self
318            .storage
319            .get_chunk(spark_id)?
320            .ok_or_else(|| InnateError::ChunkNotFound(spark_id.to_string()))?;
321        if spark.get("origin").and_then(Value::as_str) != Some("spark") {
322            return Err(InnateError::ChunkNotFound(spark_id.to_string()));
323        }
324        let maturity = spark.get("maturity").and_then(Value::as_str).unwrap_or("");
325        if maturity == "promoted" || maturity == "dropped" {
326            return Err(InnateError::InvalidState(format!(
327                "spark {spark_id} already {maturity}"
328            )));
329        }
330        if !matches!(to, "note" | "skill") {
331            return Err(InnateError::InvalidState(format!(
332                "invalid spark promotion target: {to}"
333            )));
334        }
335
336        let content = spark.get("content").and_then(Value::as_str).unwrap_or("");
337        let (content, action) = self.sanitize_content(content);
338        if action == SanitizeAction::Discard {
339            return Err(InnateError::InvalidState(
340                "sanitize discard on promote".into(),
341            ));
342        }
343
344        let promoted_hash = content_hash(&content);
345        if self.storage.is_hash_invalidated(&promoted_hash)? {
346            return Err(InnateError::InvalidState(
347                "spark content hash is invalidated".into(),
348            ));
349        }
350
351        let now = utc_now_iso();
352
353        // Idempotency: existing non-spark chunk with same hash
354        let existing = self.storage.query_chunks_params(
355            "SELECT id FROM chunks WHERE content_hash=? AND origin!='spark' AND state IN ('active','pending') ORDER BY created_at ASC LIMIT 1",
356            rusqlite::params![promoted_hash],
357        )?;
358        if let Some(e) = existing.first() {
359            if let Some(id) = e.get("id").and_then(Value::as_str) {
360                let id = id.to_string();
361                self.storage.begin_immediate()?;
362                let result = self
363                    .storage
364                    .query_chunks_params(
365                        "UPDATE chunks SET maturity='promoted', updated_at=? WHERE id=?",
366                        rusqlite::params![now, spark_id],
367                    )
368                    .and_then(|_| self.storage.commit());
369                if result.is_err() {
370                    let _ = self.storage.rollback();
371                    result?;
372                }
373                return Ok(id);
374            }
375        }
376
377        let (state, conf, prot, origin, state_reason) = if to == "skill" {
378            ("active", 0.85, 1, "installed", "init:installed")
379        } else {
380            ("active", 0.60, 0, "captured", "init:captured")
381        };
382
383        let conf = if action == SanitizeAction::Redact {
384            0.4_f64
385        } else {
386            conf
387        };
388        let new_id = gen_uuid();
389        let trigger = spark.get("trigger_desc").and_then(Value::as_str);
390        let anti = spark.get("anti_trigger_desc").and_then(Value::as_str);
391
392        let row = ChunkRow {
393            id: new_id.clone(),
394            content: content.clone(),
395            trigger_desc: trigger.map(str::to_string),
396            anti_trigger_desc: anti.map(str::to_string),
397            content_hash: promoted_hash,
398            token_count: Some(estimate_tokens(&content) as i64),
399            origin: origin.to_string(),
400            source: Some("manual".to_string()),
401            protected: prot,
402            state: state.to_string(),
403            state_reason: Some(state_reason.to_string()),
404            confidence: conf,
405            confidence_reason: Some("manual_set".to_string()),
406            parent_id: Some(spark_id.to_string()),
407            version: 1,
408            embed_version: 1,
409            created_at: now.clone(),
410            updated_at: now.clone(),
411            ..Default::default()
412        };
413
414        let cvec = self.embedding.embed_content(&content)?;
415        let tvec = self.embedding.embed_trigger(trigger.unwrap_or(&content))?;
416
417        self.storage.begin_immediate()?;
418        let result = (|| -> Result<()> {
419            self.storage.insert_chunk(&row)?;
420            self.storage
421                .insert_vec_content(&new_id, &pack_embedding(&cvec))?;
422            self.storage
423                .insert_vec_trigger(&new_id, &pack_embedding(&tvec))?;
424            self.storage.query_chunks_params(
425                "UPDATE chunks SET maturity='promoted', updated_at=? WHERE id=?",
426                rusqlite::params![now, spark_id],
427            )?;
428            self.storage.commit()
429        })();
430        if result.is_err() {
431            let _ = self.storage.rollback();
432        }
433        result?;
434        Ok(new_id)
435    }
436
437    pub fn drop_spark(&self, spark_id: &str, reason: &str) -> Result<()> {
438        let spark = self
439            .storage
440            .get_chunk(spark_id)?
441            .ok_or_else(|| InnateError::ChunkNotFound(spark_id.to_string()))?;
442        if spark.get("origin").and_then(Value::as_str) != Some("spark") {
443            return Err(InnateError::ChunkNotFound(spark_id.to_string()));
444        }
445        let maturity = spark.get("maturity").and_then(Value::as_str).unwrap_or("");
446        if maturity == "promoted" {
447            return Err(InnateError::InvalidState(format!(
448                "spark {spark_id} already promoted"
449            )));
450        }
451        if maturity == "dropped" {
452            return Ok(());
453        }
454        let now = utc_now_iso();
455        let reason_str = if reason.is_empty() {
456            "dropped".to_string()
457        } else {
458            format!("dropped:{reason}")
459        };
460        self.storage.begin_immediate()?;
461        let result = self
462            .storage
463            .query_chunks_params(
464                "UPDATE chunks SET maturity='dropped', state_reason=?, updated_at=? WHERE id=?",
465                rusqlite::params![reason_str, now, spark_id],
466            )
467            .and_then(|_| self.storage.commit());
468        if result.is_err() {
469            let _ = self.storage.rollback();
470        }
471        result.map(|_| ())
472    }
473
474    // ------------------------------------------------------------------
475    // Public API 6: approve / archive / invalidate / restore
476    // ------------------------------------------------------------------
477
478    pub fn approve(&self, chunk_id: &str) -> Result<()> {
479        let chunk = self
480            .storage
481            .get_chunk(chunk_id)?
482            .ok_or_else(|| InnateError::ChunkNotFound(chunk_id.to_string()))?;
483        if chunk.get("origin").and_then(Value::as_str) == Some("spark") {
484            return Err(InnateError::InvalidState(
485                "spark lifecycle uses promote_spark() or invalidate()".into(),
486            ));
487        }
488        if chunk.get("state").and_then(Value::as_str) == Some("active") {
489            return Ok(());
490        }
491        if chunk.get("state").and_then(Value::as_str) != Some("pending") {
492            return Err(InnateError::InvalidState(
493                "approve requires pending chunk".into(),
494            ));
495        }
496        let now = utc_now_iso();
497        self.storage.begin_immediate()?;
498        let result = (|| -> Result<()> {
499            self.storage
500                .update_chunk_state(chunk_id, "active", Some("approved"), &now)?;
501            self.storage.query_chunks_params(
502                "UPDATE chunks SET confidence_reason='manual_set', updated_at=? WHERE id=?",
503                rusqlite::params![now, chunk_id],
504            )?;
505            self.storage.commit()
506        })();
507        if result.is_err() {
508            let _ = self.storage.rollback();
509        }
510        result
511    }
512
513    pub fn archive(&self, chunk_id: &str, reason: &str) -> Result<()> {
514        let chunk = self
515            .storage
516            .get_chunk(chunk_id)?
517            .ok_or_else(|| InnateError::ChunkNotFound(chunk_id.to_string()))?;
518        if chunk.get("origin").and_then(Value::as_str) == Some("spark") {
519            return Err(InnateError::InvalidState(
520                "spark lifecycle uses drop_spark() or invalidate()".into(),
521            ));
522        }
523        let now = utc_now_iso();
524        self.storage.begin_immediate()?;
525        let result = self
526            .storage
527            .update_chunk_state(chunk_id, "archived", Some(reason), &now)
528            .and_then(|_| self.storage.commit());
529        if result.is_err() {
530            let _ = self.storage.rollback();
531        }
532        result
533    }
534
535    pub fn invalidate(&self, chunk_id: &str, reason: &str) -> Result<()> {
536        let chunk = self
537            .storage
538            .get_chunk(chunk_id)?
539            .ok_or_else(|| InnateError::ChunkNotFound(chunk_id.to_string()))?;
540        let h = chunk
541            .get("content_hash")
542            .and_then(Value::as_str)
543            .unwrap_or("")
544            .to_string();
545        let now = utc_now_iso();
546        let reason_str = if reason.is_empty() {
547            "invalidated".to_string()
548        } else {
549            format!("invalidated:{reason}")
550        };
551
552        self.storage.begin_immediate()?;
553        let result = (|| -> Result<()> {
554            self.storage.query_chunks_params(
555                "UPDATE chunks
556                 SET state='archived', confidence=0.0, confidence_base=0.0,
557                     confidence_reason='invalidated', state_reason=?,
558                     state_updated_at=?, updated_at=?
559                 WHERE id=?",
560                rusqlite::params![reason_str, now, now, chunk_id],
561            )?;
562            self.storage.query_chunks_params(
563                "UPDATE chunks
564                 SET state='archived', confidence=0.0, confidence_base=0.0,
565                     confidence_reason='invalidated',
566                     state_reason='invalidated:same_hash',
567                     state_updated_at=?, updated_at=?
568                 WHERE content_hash=? AND id!=?",
569                rusqlite::params![now, now, h, chunk_id],
570            )?;
571            self.storage.conn_execute(
572                "DELETE FROM confidence_evidence
573                 WHERE chunk_id IN (SELECT id FROM chunks WHERE content_hash=?)",
574                rusqlite::params![h],
575            )?;
576            self.storage
577                .insert_invalidated_hash(&h, Some(reason), &now)?;
578            self.storage.commit()
579        })();
580        if result.is_err() {
581            let _ = self.storage.rollback();
582        }
583        result
584    }
585
586    pub fn restore(&self, chunk_id: &str) -> Result<()> {
587        let chunk = self
588            .storage
589            .get_chunk(chunk_id)?
590            .ok_or_else(|| InnateError::ChunkNotFound(chunk_id.to_string()))?;
591        let state = chunk.get("state").and_then(Value::as_str).unwrap_or("");
592        if state == "active" {
593            return Ok(());
594        }
595        if state != "archived" {
596            return Err(InnateError::InvalidState(
597                "restore requires archived chunk".into(),
598            ));
599        }
600        let was_invalidated = chunk
601            .get("state_reason")
602            .and_then(Value::as_str)
603            .map(|r| r.starts_with("invalidated"))
604            .unwrap_or(false);
605        let h = chunk
606            .get("content_hash")
607            .and_then(Value::as_str)
608            .unwrap_or("")
609            .to_string();
610        let now = utc_now_iso();
611
612        self.storage.begin_immediate()?;
613        let result = (|| -> Result<()> {
614            self.storage
615                .update_chunk_state(chunk_id, "active", Some("restore"), &now)?;
616            if was_invalidated {
617                self.storage.query_chunks_params(
618                    "DELETE FROM invalidated_hashes WHERE content_hash=?",
619                    rusqlite::params![h],
620                )?;
621            }
622            self.storage.query_chunks_params(
623                "UPDATE chunks
624                 SET confidence_base=0.5, confidence=0.5,
625                     confidence_reason='restore',
626                     selected_count=0, selected_count_base=0,
627                     used_count=0, used_count_base=0,
628                     used_success_count=0, used_success_count_base=0,
629                     success_trace_ids_count=0,
630                     last_used_at=NULL, last_used_base=NULL,
631                     last_success_at=NULL, last_decayed_at=NULL,
632                     evidence_cutoff_at=?, updated_at=?
633                 WHERE id=?",
634                rusqlite::params![now, now, chunk_id],
635            )?;
636            self.storage.conn_execute(
637                "DELETE FROM confidence_evidence WHERE chunk_id=?",
638                rusqlite::params![chunk_id],
639            )?;
640            self.storage.conn_execute(
641                "DELETE FROM chunk_success_traces WHERE chunk_id=?",
642                rusqlite::params![chunk_id],
643            )?;
644            self.storage.conn_execute(
645                "DELETE FROM chunk_context_stats_base WHERE chunk_id=?",
646                rusqlite::params![chunk_id],
647            )?;
648            self.storage.conn_execute(
649                "DELETE FROM chunk_context_stats WHERE chunk_id=?",
650                rusqlite::params![chunk_id],
651            )?;
652            self.storage.conn_execute(
653                "UPDATE governance_proposals
654                 SET state='rejected', reason=reason || '; restored by user', updated_at=?
655                 WHERE chunk_id=? AND state IN ('pending','accepted')",
656                rusqlite::params![now, chunk_id],
657            )?;
658            self.storage.commit()
659        })();
660        if result.is_err() {
661            let _ = self.storage.rollback();
662        }
663        result
664    }
665
666    // ------------------------------------------------------------------
667    // Public API 7: evolve
668    // ------------------------------------------------------------------
669}