use base_d::{DictionaryRegistry, HashAlgorithm, encode, hash};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KnowledgeEntry {
pub id: String,
pub category_id: String,
pub title: String,
#[serde(default)]
pub body: Option<String>,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub applicability: Vec<String>,
#[serde(default)]
pub source_project_id: Option<String>,
#[serde(default)]
pub source_agent_id: Option<String>,
#[serde(default)]
pub file_path: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub content_hash: Option<String>,
#[serde(default)]
pub source_type_id: Option<String>,
#[serde(default)]
pub entry_type_id: Option<String>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub ephemeral: bool,
#[serde(default)]
pub content_type_id: Option<String>,
#[serde(default)]
pub owner: Option<String>,
#[serde(default = "default_visibility")]
pub visibility: String,
#[serde(default)]
pub resonance: i32,
#[serde(default)]
pub resonance_type: Option<String>,
#[serde(default)]
pub last_activated: Option<String>,
#[serde(default)]
pub activation_count: i32,
#[serde(default = "default_decay_rate")]
pub decay_rate: f64,
#[serde(default)]
pub anchors: Vec<String>,
#[serde(default)]
pub wake_phrases: Vec<String>,
#[serde(default)]
pub triggers: Vec<String>,
#[serde(default)]
pub wake_order: Option<i32>,
#[serde(default)]
pub wake_phrase: Option<String>,
#[serde(default)]
pub embedding: Option<Vec<f32>>, #[serde(default)]
pub embedding_model: Option<String>, #[serde(default)]
pub embedded_at: Option<String>,
#[serde(default)]
pub chunk_count: i32,
#[serde(default = "default_format")]
pub format: String,
#[serde(default)]
pub effective_resonance: Option<f64>,
}
pub fn normalize_trigger(raw: &str) -> Option<String> {
use unicode_normalization::UnicodeNormalization;
let canonical: String = raw.nfc().collect();
let collapsed = canonical.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.is_empty() {
None
} else {
Some(collapsed.to_lowercase())
}
}
pub fn normalize_triggers<I, S>(raw: I) -> Vec<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for item in raw {
if let Some(norm) = normalize_trigger(item.as_ref())
&& seen.insert(norm.clone())
{
out.push(norm);
}
}
out
}
const DEDUP_UNICODE_PUNCTUATION: [char; 7] = [
'\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', '\u{2013}', '\u{2014}', '\u{2026}',
];
pub fn normalize_for_dedup(content: &str) -> String {
let no_punct: String = content
.chars()
.filter(|c| !c.is_ascii_punctuation() && !DEDUP_UNICODE_PUNCTUATION.contains(c))
.collect();
no_punct
.trim()
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
pub fn dedup_hash(title: &str, body: &str) -> String {
let norm_title = normalize_for_dedup(title);
let norm_body = normalize_for_dedup(body);
let mut buf = Vec::with_capacity(norm_title.len() + norm_body.len() + 8);
buf.extend_from_slice(&(norm_title.len() as u64).to_le_bytes());
buf.extend_from_slice(norm_title.as_bytes());
buf.extend_from_slice(norm_body.as_bytes());
KnowledgeEntry::blake3_hex(&buf)
}
fn default_format() -> String {
"markdown".to_string()
}
fn default_visibility() -> String {
"public".to_string()
}
fn default_decay_rate() -> f64 {
0.0
}
impl KnowledgeEntry {
pub fn active_wake_phrases(&self) -> Vec<&str> {
if !self.wake_phrases.is_empty() {
self.wake_phrases.iter().map(|s| s.as_str()).collect()
} else {
self.wake_phrase.as_deref().into_iter().collect()
}
}
pub fn has_any_wake_phrase(&self) -> bool {
!self.wake_phrases.is_empty() || self.wake_phrase.as_ref().is_some_and(|s| !s.is_empty())
}
pub fn embedding_text(&self) -> String {
let mut parts = vec![self.title.clone()];
if let Some(summary) = &self.summary {
parts.push(summary.clone());
} else if let Some(body) = &self.body {
parts.push(body.clone());
}
if !self.tags.is_empty() {
parts.push(format!("Tags: {}", self.tags.join(", ")));
}
parts.join("\n\n")
}
pub fn normalize_content(content: &str) -> String {
content
.trim()
.to_lowercase()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
}
pub fn get_summary_state(&self) -> Option<String> {
self.summary
.as_ref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| v.get("state").and_then(|s| s.as_str()).map(String::from))
}
pub fn generate_id(path: &str, title: &str) -> String {
let input = format!("{}:{}", path, title);
let hex = Self::blake3_hex(input.as_bytes());
format!("kn-{}", &hex[..8])
}
pub fn compute_hash(content: &str) -> String {
Self::blake3_hex(content.as_bytes())
}
fn blake3_hex(data: &[u8]) -> String {
let hash_bytes = hash(data, HashAlgorithm::Blake3);
let registry = DictionaryRegistry::load_default().expect("base-d dictionaries");
let dict = registry.dictionary("base16").expect("base16 dictionary");
encode(&hash_bytes, &dict).to_lowercase()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_id() {
let id = KnowledgeEntry::generate_id("pattern/test.md", "Test Pattern");
assert!(id.starts_with("kn-"));
assert_eq!(id.len(), 11); }
#[test]
fn test_normalize_content() {
assert_eq!(
KnowledgeEntry::normalize_content(" hello world "),
"hello world"
);
assert_eq!(
KnowledgeEntry::normalize_content("Hello World"),
"hello world"
);
assert_eq!(
KnowledgeEntry::normalize_content("hello\n world\n test"),
"hello world test"
);
assert_eq!(
KnowledgeEntry::normalize_content("hello\tworld"),
"hello world"
);
}
#[test]
fn test_normalize_trigger() {
assert_eq!(
normalize_trigger(" Blood Sugar "),
Some("blood sugar".to_string())
);
assert_eq!(normalize_trigger("Brad"), Some("brad".to_string()));
assert_eq!(normalize_trigger(""), None);
assert_eq!(normalize_trigger(" "), None);
assert_eq!(normalize_trigger("\t\n"), None);
}
#[test]
fn test_normalize_trigger_nfc_canonicalization() {
let precomposed = "caf\u{00e9}";
let decomposed = "cafe\u{0301}";
assert_ne!(precomposed, decomposed);
assert_eq!(
normalize_trigger(precomposed),
normalize_trigger(decomposed)
);
assert_eq!(normalize_trigger(decomposed), Some("café".to_string()));
}
#[test]
fn test_normalize_triggers_dedupe_and_order() {
let out = normalize_triggers(vec![
"Brad",
" blood sugar ",
"BLOOD SUGAR", "Glucose",
"", " ", "brad", ]);
assert_eq!(
out,
vec![
"brad".to_string(),
"blood sugar".to_string(),
"glucose".to_string()
]
);
}
#[test]
fn test_normalize_for_dedup_strips_punctuation_case_and_whitespace() {
assert_eq!(
normalize_for_dedup("The EXTERNAL, numberless plan."),
normalize_for_dedup("the external numberless plan")
);
assert_eq!(
normalize_for_dedup("The EXTERNAL, numberless plan."),
"the external numberless plan"
);
}
#[test]
fn test_normalize_for_dedup_strips_common_unicode_punctuation() {
assert_eq!(
normalize_for_dedup("don't"),
normalize_for_dedup("don\u{2019}t")
);
assert_eq!(
normalize_for_dedup("\u{201C}quoted\u{201D}"),
normalize_for_dedup("\"quoted\"")
);
assert_eq!(
normalize_for_dedup("wait\u{2014}really"),
normalize_for_dedup("wait-really")
);
assert_eq!(
normalize_for_dedup("hold on\u{2026}"),
normalize_for_dedup("hold on...")
);
}
#[test]
fn test_normalize_content_not_mutated_by_dedup_work() {
assert_eq!(
KnowledgeEntry::normalize_content("hello, world!"),
"hello, world!"
);
}
#[test]
fn test_dedup_hash_recased_repunctuated_pair_equal() {
let a = dedup_hash("The External Plan", "Ship it, and move on.");
let b = dedup_hash("the external plan", "ship it and move on");
assert_eq!(a, b);
}
#[test]
fn test_dedup_hash_different_bodies_unequal() {
let a = dedup_hash("The External Plan", "Ship it and move on.");
let b = dedup_hash("The External Plan", "Ship it and hold off.");
assert_ne!(a, b);
}
#[test]
fn test_dedup_hash_does_not_collapse_title_body_boundary() {
assert_ne!(dedup_hash("Ship it", "now."), dedup_hash("Ship it now", ""));
assert_ne!(
dedup_hash("Ship it now", "and move on"),
dedup_hash("Ship it", "now and move on")
);
}
#[test]
fn test_embedding_text() {
let entry = KnowledgeEntry {
id: "kn-test".to_string(),
title: "Test Entry".to_string(),
body: Some("This is the body content.".to_string()),
summary: None,
tags: vec!["rust".to_string(), "test".to_string()],
category_id: "technique".to_string(),
applicability: vec![],
source_project_id: None,
source_agent_id: None,
file_path: None,
created_at: None,
updated_at: None,
content_hash: None,
source_type_id: None,
entry_type_id: None,
session_id: None,
ephemeral: false,
content_type_id: None,
owner: None,
visibility: "public".to_string(),
resonance: 0,
resonance_type: None,
last_activated: None,
activation_count: 0,
decay_rate: 0.0,
anchors: vec![],
wake_phrases: vec![],
triggers: vec![],
wake_order: None,
wake_phrase: None,
embedding: None,
embedding_model: None,
embedded_at: None,
chunk_count: 0,
format: "markdown".to_string(),
effective_resonance: None,
};
let text = entry.embedding_text();
assert!(text.contains("Test Entry"));
assert!(text.contains("This is the body content."));
assert!(text.contains("Tags: rust, test"));
}
#[test]
fn test_embedding_text_with_summary() {
let entry = KnowledgeEntry {
id: "kn-test".to_string(),
title: "Test Entry".to_string(),
body: Some("Long body that should be ignored when summary exists.".to_string()),
summary: Some("Short summary".to_string()),
tags: vec![],
category_id: "technique".to_string(),
applicability: vec![],
source_project_id: None,
source_agent_id: None,
file_path: None,
created_at: None,
updated_at: None,
content_hash: None,
source_type_id: None,
entry_type_id: None,
session_id: None,
ephemeral: false,
content_type_id: None,
owner: None,
visibility: "public".to_string(),
resonance: 0,
resonance_type: None,
last_activated: None,
activation_count: 0,
decay_rate: 0.0,
anchors: vec![],
wake_phrases: vec![],
triggers: vec![],
wake_order: None,
wake_phrase: None,
embedding: None,
embedding_model: None,
embedded_at: None,
chunk_count: 0,
format: "markdown".to_string(),
effective_resonance: None,
};
let text = entry.embedding_text();
assert!(text.contains("Test Entry"));
assert!(text.contains("Short summary"));
assert!(!text.contains("Long body"));
}
}