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