1use std::fmt;
9use std::sync::Arc;
10
11use serde::{Deserialize, Serialize};
12use time::OffsetDateTime;
13
14use crate::extension::KnowledgeStoreId;
15use crate::memory::MemoryScope;
16
17pub type KnowledgeDocId = String;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum KnowledgeKind {
22 Memory,
23 Requirement,
24 Decision,
25 Research,
26 Runbook,
27 Artifact,
28 Note,
29 Other(String),
30}
31
32impl KnowledgeKind {
33 pub const KNOWN: [&'static str; 7] = [
34 "memory",
35 "requirement",
36 "decision",
37 "research",
38 "runbook",
39 "artifact",
40 "note",
41 ];
42
43 pub fn as_str(&self) -> &str {
44 match self {
45 KnowledgeKind::Memory => "memory",
46 KnowledgeKind::Requirement => "requirement",
47 KnowledgeKind::Decision => "decision",
48 KnowledgeKind::Research => "research",
49 KnowledgeKind::Runbook => "runbook",
50 KnowledgeKind::Artifact => "artifact",
51 KnowledgeKind::Note => "note",
52 KnowledgeKind::Other(value) => value,
53 }
54 }
55
56 pub fn parse(value: &str) -> Self {
57 match value {
58 "memory" => KnowledgeKind::Memory,
59 "requirement" => KnowledgeKind::Requirement,
60 "decision" => KnowledgeKind::Decision,
61 "research" => KnowledgeKind::Research,
62 "runbook" => KnowledgeKind::Runbook,
63 "artifact" => KnowledgeKind::Artifact,
64 "note" => KnowledgeKind::Note,
65 other => KnowledgeKind::Other(other.to_string()),
66 }
67 }
68}
69
70impl fmt::Display for KnowledgeKind {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 f.write_str(self.as_str())
73 }
74}
75
76impl Serialize for KnowledgeKind {
77 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
78 serializer.serialize_str(self.as_str())
79 }
80}
81
82impl<'de> Deserialize<'de> for KnowledgeKind {
83 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
84 let value = String::deserialize(deserializer)?;
85 Ok(KnowledgeKind::parse(&value))
86 }
87}
88
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum KnowledgeStatus {
92 Active,
93 Draft,
94 Superseded,
95 Archived,
96}
97
98impl KnowledgeStatus {
99 pub fn as_str(&self) -> &'static str {
100 match self {
101 KnowledgeStatus::Active => "active",
102 KnowledgeStatus::Draft => "draft",
103 KnowledgeStatus::Superseded => "superseded",
104 KnowledgeStatus::Archived => "archived",
105 }
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(rename_all = "snake_case")]
111pub enum KnowledgeSource {
112 User,
113 Agent,
114 Reconciler,
115 Import,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(rename_all = "snake_case")]
120pub enum KnowledgeLinkType {
121 RelatesTo,
122 Supersedes,
123 DerivedFrom,
124 Contradicts,
125 Duplicates,
126}
127
128impl KnowledgeLinkType {
129 pub fn as_str(&self) -> &'static str {
130 match self {
131 KnowledgeLinkType::RelatesTo => "relates_to",
132 KnowledgeLinkType::Supersedes => "supersedes",
133 KnowledgeLinkType::DerivedFrom => "derived_from",
134 KnowledgeLinkType::Contradicts => "contradicts",
135 KnowledgeLinkType::Duplicates => "duplicates",
136 }
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "camelCase")]
143pub struct KnowledgeLink {
144 #[serde(rename = "type")]
145 pub link_type: KnowledgeLinkType,
146 pub to: KnowledgeDocId,
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct KnowledgeDocument {
152 pub id: KnowledgeDocId,
153 pub scope: MemoryScope,
154 pub kind: KnowledgeKind,
155 pub slug: String,
156 pub title: String,
157 pub status: KnowledgeStatus,
158 pub source: KnowledgeSource,
159 #[serde(default)]
160 pub tags: Vec<String>,
161 #[serde(default)]
162 pub links: Vec<KnowledgeLink>,
163 pub revision: u32,
164 pub content_hash: String,
165 pub body: String,
166 #[serde(with = "time::serde::rfc3339")]
167 pub created_at: OffsetDateTime,
168 #[serde(with = "time::serde::rfc3339")]
169 pub updated_at: OffsetDateTime,
170}
171
172impl KnowledgeDocument {
173 pub fn summary(&self) -> KnowledgeDocSummary {
174 const PREVIEW_CHARS: usize = 160;
175 let preview = if self.body.chars().count() <= PREVIEW_CHARS {
176 self.body.clone()
177 } else {
178 let mut out = self.body.chars().take(PREVIEW_CHARS).collect::<String>();
179 out.push_str("...");
180 out
181 };
182 KnowledgeDocSummary {
183 id: self.id.clone(),
184 scope: self.scope.clone(),
185 kind: self.kind.clone(),
186 slug: self.slug.clone(),
187 title: self.title.clone(),
188 status: self.status,
189 source: self.source,
190 tags: self.tags.clone(),
191 links: self.links.clone(),
192 revision: self.revision,
193 byte_count: self.body.len() as u64,
194 preview,
195 created_at: self.created_at,
196 updated_at: self.updated_at,
197 }
198 }
199}
200
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
203#[serde(rename_all = "camelCase")]
204pub struct KnowledgeDocSummary {
205 pub id: KnowledgeDocId,
206 pub scope: MemoryScope,
207 pub kind: KnowledgeKind,
208 pub slug: String,
209 pub title: String,
210 pub status: KnowledgeStatus,
211 pub source: KnowledgeSource,
212 #[serde(default)]
213 pub tags: Vec<String>,
214 #[serde(default)]
215 pub links: Vec<KnowledgeLink>,
216 pub revision: u32,
217 pub byte_count: u64,
218 pub preview: String,
219 #[serde(with = "time::serde::rfc3339")]
220 pub created_at: OffsetDateTime,
221 #[serde(with = "time::serde::rfc3339")]
222 pub updated_at: OffsetDateTime,
223}
224
225#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226#[serde(rename_all = "camelCase")]
227pub struct KnowledgeListQuery {
228 pub scope: Option<MemoryScope>,
229 #[serde(default)]
230 pub kind: Option<KnowledgeKind>,
231 #[serde(default)]
232 pub tag: Option<String>,
233 #[serde(default)]
234 pub status: Option<KnowledgeStatus>,
235 #[serde(default)]
236 pub include_archived: bool,
237 pub limit: usize,
238}
239
240#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241#[serde(rename_all = "camelCase")]
242pub struct KnowledgeQuery {
243 pub scope: Option<MemoryScope>,
244 pub text: String,
245 #[serde(default)]
246 pub kind: Option<KnowledgeKind>,
247 pub limit: usize,
248 #[serde(default)]
249 pub include_global: bool,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253#[serde(rename_all = "camelCase")]
254pub struct KnowledgeCitation {
255 pub doc_id: KnowledgeDocId,
256 pub scope_id: String,
257 pub title: String,
258 pub snippet: String,
259 pub score_millis: u32,
260}
261
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
263#[serde(rename_all = "camelCase")]
264pub struct KnowledgeSearchResult {
265 pub document: KnowledgeDocSummary,
266 pub score: f32,
267 pub snippet: String,
268 pub citation: KnowledgeCitation,
269}
270
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase")]
273pub struct KnowledgeSaveRequest {
274 pub scope: MemoryScope,
275 pub kind: KnowledgeKind,
276 pub title: String,
277 #[serde(default)]
278 pub tags: Vec<String>,
279 pub body: String,
280 pub source: KnowledgeSource,
281}
282
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
286#[serde(rename_all = "camelCase")]
287pub struct KnowledgeUpdateRequest {
288 pub id: KnowledgeDocId,
289 #[serde(default)]
290 pub title: Option<String>,
291 #[serde(default)]
292 pub body: Option<String>,
293 #[serde(default)]
294 pub status: Option<KnowledgeStatus>,
295 #[serde(default)]
296 pub tags: Option<Vec<String>>,
297 pub source: KnowledgeSource,
298}
299
300#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
301#[serde(rename_all = "camelCase")]
302pub struct KnowledgeLinkRequest {
303 pub from: KnowledgeDocId,
304 pub to: KnowledgeDocId,
305 #[serde(rename = "type")]
306 pub link_type: KnowledgeLinkType,
307 #[serde(default)]
308 pub remove: bool,
309}
310
311#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub struct KnowledgeRevisionInfo {
314 pub revision: u32,
315 pub content_hash: String,
316 #[serde(with = "time::serde::rfc3339")]
317 pub created_at: OffsetDateTime,
318}
319
320#[async_trait::async_trait]
321pub trait KnowledgeStore: Send + Sync {
322 fn id(&self) -> KnowledgeStoreId;
323
324 async fn save(&self, request: KnowledgeSaveRequest) -> anyhow::Result<KnowledgeDocument>;
325 async fn get(&self, id: &KnowledgeDocId) -> anyhow::Result<Option<KnowledgeDocument>>;
326 async fn get_revision(
327 &self,
328 id: &KnowledgeDocId,
329 revision: u32,
330 ) -> anyhow::Result<Option<KnowledgeDocument>>;
331 async fn list(&self, query: KnowledgeListQuery) -> anyhow::Result<Vec<KnowledgeDocSummary>>;
332 async fn search(&self, query: KnowledgeQuery) -> anyhow::Result<Vec<KnowledgeSearchResult>>;
333 async fn update(&self, request: KnowledgeUpdateRequest) -> anyhow::Result<KnowledgeDocument>;
334 async fn archive(&self, id: &KnowledgeDocId) -> anyhow::Result<bool>;
336 async fn set_link(&self, request: KnowledgeLinkRequest)
337 -> anyhow::Result<KnowledgeDocument>;
338 async fn revisions(&self, id: &KnowledgeDocId)
339 -> anyhow::Result<Vec<KnowledgeRevisionInfo>>;
340}
341
342pub trait KnowledgeStoreFactory: Send + Sync + 'static {
343 fn id(&self) -> KnowledgeStoreId;
344 fn create(&self) -> Arc<dyn KnowledgeStore>;
345}
346
347#[cfg(test)]
348mod tests {
349 use super::*;
350
351 #[test]
352 fn knowledge_kind_serializes_as_plain_string() {
353 assert_eq!(
354 serde_json::to_value(KnowledgeKind::Decision).unwrap(),
355 serde_json::json!("decision")
356 );
357 assert_eq!(
358 serde_json::to_value(KnowledgeKind::Other("postmortem".to_string())).unwrap(),
359 serde_json::json!("postmortem")
360 );
361 }
362
363 #[test]
364 fn knowledge_kind_round_trips_known_and_custom_values() {
365 for kind in KnowledgeKind::KNOWN {
366 let parsed = KnowledgeKind::parse(kind);
367 assert_eq!(parsed.as_str(), kind);
368 assert!(!matches!(parsed, KnowledgeKind::Other(_)));
369 }
370 assert_eq!(
371 KnowledgeKind::parse("postmortem"),
372 KnowledgeKind::Other("postmortem".to_string())
373 );
374 }
375
376 #[test]
377 fn knowledge_link_serializes_type_field() {
378 let link = KnowledgeLink {
379 link_type: KnowledgeLinkType::Supersedes,
380 to: "kn-1".to_string(),
381 };
382 assert_eq!(
383 serde_json::to_value(&link).unwrap(),
384 serde_json::json!({ "type": "supersedes", "to": "kn-1" })
385 );
386 }
387
388 #[test]
389 fn document_summary_bounds_preview_and_keeps_metadata() {
390 let now = OffsetDateTime::UNIX_EPOCH;
391 let doc = KnowledgeDocument {
392 id: "kn-1".to_string(),
393 scope: MemoryScope::Project("p".to_string()),
394 kind: KnowledgeKind::Research,
395 slug: "long-doc".to_string(),
396 title: "Long doc".to_string(),
397 status: KnowledgeStatus::Active,
398 source: KnowledgeSource::Agent,
399 tags: vec!["api".to_string()],
400 links: Vec::new(),
401 revision: 3,
402 content_hash: "hash".to_string(),
403 body: "x".repeat(4000),
404 created_at: now,
405 updated_at: now,
406 };
407
408 let summary = doc.summary();
409
410 assert_eq!(summary.byte_count, 4000);
411 assert!(summary.preview.ends_with("..."));
412 assert!(summary.preview.len() < 200);
413 assert_eq!(summary.revision, 3);
414 assert_eq!(summary.tags, vec!["api".to_string()]);
415 }
416
417 #[test]
418 fn document_round_trips_json() {
419 let now = OffsetDateTime::UNIX_EPOCH;
420 let doc = KnowledgeDocument {
421 id: "kn-2".to_string(),
422 scope: MemoryScope::Global,
423 kind: KnowledgeKind::Requirement,
424 slug: "auth-req".to_string(),
425 title: "Auth requirements".to_string(),
426 status: KnowledgeStatus::Draft,
427 source: KnowledgeSource::User,
428 tags: vec!["auth".to_string()],
429 links: vec![KnowledgeLink {
430 link_type: KnowledgeLinkType::RelatesTo,
431 to: "kn-1".to_string(),
432 }],
433 revision: 1,
434 content_hash: "h".to_string(),
435 body: "Users must log in.".to_string(),
436 created_at: now,
437 updated_at: now,
438 };
439
440 let value = serde_json::to_value(&doc).unwrap();
441 let decoded: KnowledgeDocument = serde_json::from_value(value).unwrap();
442
443 assert_eq!(decoded, doc);
444 }
445}