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