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 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 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 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 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 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 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 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 }