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