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) -> anyhow::Result<KnowledgeDocument>;
337 async fn revisions(&self, id: &KnowledgeDocId) -> anyhow::Result<Vec<KnowledgeRevisionInfo>>;
338}
339
340pub trait KnowledgeStoreFactory: Send + Sync + 'static {
341 fn id(&self) -> KnowledgeStoreId;
342 fn create(&self) -> Arc<dyn KnowledgeStore>;
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn knowledge_kind_serializes_as_plain_string() {
351 assert_eq!(
352 serde_json::to_value(KnowledgeKind::Decision).unwrap(),
353 serde_json::json!("decision")
354 );
355 assert_eq!(
356 serde_json::to_value(KnowledgeKind::Other("postmortem".to_string())).unwrap(),
357 serde_json::json!("postmortem")
358 );
359 }
360
361 #[test]
362 fn knowledge_kind_round_trips_known_and_custom_values() {
363 for kind in KnowledgeKind::KNOWN {
364 let parsed = KnowledgeKind::parse(kind);
365 assert_eq!(parsed.as_str(), kind);
366 assert!(!matches!(parsed, KnowledgeKind::Other(_)));
367 }
368 assert_eq!(
369 KnowledgeKind::parse("postmortem"),
370 KnowledgeKind::Other("postmortem".to_string())
371 );
372 }
373
374 #[test]
375 fn knowledge_link_serializes_type_field() {
376 let link = KnowledgeLink {
377 link_type: KnowledgeLinkType::Supersedes,
378 to: "kn-1".to_string(),
379 };
380 assert_eq!(
381 serde_json::to_value(&link).unwrap(),
382 serde_json::json!({ "type": "supersedes", "to": "kn-1" })
383 );
384 }
385
386 #[test]
387 fn document_summary_bounds_preview_and_keeps_metadata() {
388 let now = OffsetDateTime::UNIX_EPOCH;
389 let doc = KnowledgeDocument {
390 id: "kn-1".to_string(),
391 scope: MemoryScope::Project("p".to_string()),
392 kind: KnowledgeKind::Research,
393 slug: "long-doc".to_string(),
394 title: "Long doc".to_string(),
395 status: KnowledgeStatus::Active,
396 source: KnowledgeSource::Agent,
397 tags: vec!["api".to_string()],
398 links: Vec::new(),
399 revision: 3,
400 content_hash: "hash".to_string(),
401 body: "x".repeat(4000),
402 created_at: now,
403 updated_at: now,
404 };
405
406 let summary = doc.summary();
407
408 assert_eq!(summary.byte_count, 4000);
409 assert!(summary.preview.ends_with("..."));
410 assert!(summary.preview.len() < 200);
411 assert_eq!(summary.revision, 3);
412 assert_eq!(summary.tags, vec!["api".to_string()]);
413 }
414
415 #[test]
416 fn document_round_trips_json() {
417 let now = OffsetDateTime::UNIX_EPOCH;
418 let doc = KnowledgeDocument {
419 id: "kn-2".to_string(),
420 scope: MemoryScope::Global,
421 kind: KnowledgeKind::Requirement,
422 slug: "auth-req".to_string(),
423 title: "Auth requirements".to_string(),
424 status: KnowledgeStatus::Draft,
425 source: KnowledgeSource::User,
426 tags: vec!["auth".to_string()],
427 links: vec![KnowledgeLink {
428 link_type: KnowledgeLinkType::RelatesTo,
429 to: "kn-1".to_string(),
430 }],
431 revision: 1,
432 content_hash: "h".to_string(),
433 body: "Users must log in.".to_string(),
434 created_at: now,
435 updated_at: now,
436 };
437
438 let value = serde_json::to_value(&doc).unwrap();
439 let decoded: KnowledgeDocument = serde_json::from_value(value).unwrap();
440
441 assert_eq!(decoded, doc);
442 }
443}