1use std::collections::HashMap;
8use std::fmt;
9use std::str::FromStr;
10use std::sync::Arc;
11
12use anyhow::{Context, Result, anyhow};
13use async_trait::async_trait;
14use nenjo_tool_api::{Tool, ToolCategory, ToolOrigin, ToolResult, ToolSpec};
15use serde::{Deserialize, Serialize};
16use serde_json::json;
17
18use crate::{
19 KnowledgeDocFilter, KnowledgeDocManifest, KnowledgeDocNeighbor, KnowledgeDocSearchHit,
20 KnowledgePack, KnowledgePackManifest,
21};
22
23#[async_trait]
24pub trait KnowledgeRegistry: Send + Sync {
25 async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>>;
26 async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>>;
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct KnowledgeName(String);
31
32impl KnowledgeName {
33 pub fn parse(value: impl AsRef<str>) -> Result<Self> {
34 let value = value.as_ref().trim().to_ascii_lowercase();
35 if value.is_empty() {
36 return Err(anyhow!("knowledge name cannot be empty"));
37 }
38 if value.starts_with(['_', '-']) || value.ends_with(['_', '-']) {
39 return Err(anyhow!(
40 "knowledge name cannot start or end with a separator"
41 ));
42 }
43 if !value
44 .chars()
45 .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' || ch == '-')
46 {
47 return Err(anyhow!(
48 "knowledge name may contain only lowercase letters, numbers, underscores, and hyphens"
49 ));
50 }
51 Ok(Self(value))
52 }
53
54 pub fn as_str(&self) -> &str {
55 &self.0
56 }
57
58 pub fn prompt_segment(&self) -> String {
59 normalize_var_segment(&self.0)
60 }
61}
62
63impl fmt::Display for KnowledgeName {
64 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65 f.write_str(&self.0)
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
70pub struct PackageKnowledgeName(Vec<KnowledgeName>);
71
72impl PackageKnowledgeName {
73 pub fn parse(value: impl AsRef<str>) -> Result<Self> {
74 let raw = value.as_ref().trim();
75 let raw = raw.strip_prefix('@').unwrap_or(raw);
76 let segments = raw
77 .split(['.', '/'])
78 .map(KnowledgeName::parse)
79 .collect::<Result<Vec<_>>>()?;
80 if segments.is_empty() {
81 return Err(anyhow!("package knowledge name cannot be empty"));
82 }
83 Ok(Self(segments))
84 }
85
86 pub fn prompt_path(&self) -> String {
87 self.0
88 .iter()
89 .map(KnowledgeName::prompt_segment)
90 .collect::<Vec<_>>()
91 .join(".")
92 }
93
94 pub fn selector_name(&self) -> String {
95 self.0
96 .iter()
97 .map(KnowledgeName::as_str)
98 .collect::<Vec<_>>()
99 .join(".")
100 }
101}
102
103impl fmt::Display for PackageKnowledgeName {
104 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105 f.write_str(&self.selector_name())
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
110pub enum KnowledgeRef {
111 Library { pack: KnowledgeName },
112 Package { package: PackageKnowledgeName },
113 Local { pack: KnowledgeName },
114}
115
116impl KnowledgeRef {
117 pub fn library(pack: impl AsRef<str>) -> Result<Self> {
118 Ok(Self::Library {
119 pack: KnowledgeName::parse(pack)?,
120 })
121 }
122
123 pub fn package(package: impl AsRef<str>) -> Result<Self> {
124 Ok(Self::Package {
125 package: PackageKnowledgeName::parse(package)?,
126 })
127 }
128
129 pub fn local(pack: impl AsRef<str>) -> Result<Self> {
130 Ok(Self::Local {
131 pack: KnowledgeName::parse(pack)?,
132 })
133 }
134
135 pub fn selector(&self) -> String {
136 self.to_string()
137 }
138
139 pub fn prompt_prefix(&self) -> String {
140 match self {
141 Self::Library { pack } => format!("lib.{}", pack.prompt_segment()),
142 Self::Package { package } => format!("pkg.{}", package.prompt_path()),
143 Self::Local { pack } => format!("local.{}", pack.prompt_segment()),
144 }
145 }
146}
147
148impl fmt::Display for KnowledgeRef {
149 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150 match self {
151 Self::Library { pack } => write!(f, "lib:{pack}"),
152 Self::Package { package } => write!(f, "pkg:{package}"),
153 Self::Local { pack } => write!(f, "local:{pack}"),
154 }
155 }
156}
157
158impl FromStr for KnowledgeRef {
159 type Err = anyhow::Error;
160
161 fn from_str(value: &str) -> Result<Self, Self::Err> {
162 let value = value.trim();
163 if let Some(pack) = value.strip_prefix("lib:") {
164 return Self::library(pack);
165 }
166 if let Some(pack) = value.strip_prefix("local:") {
167 return Self::local(pack);
168 }
169 if let Some(package) = value.strip_prefix("pkg:") {
170 return Self::package(package);
171 }
172 Err(anyhow!(
173 "invalid knowledge selector '{value}'; expected lib:<pack>, pkg:<package>, or local:<pack>"
174 ))
175 }
176}
177
178#[derive(Clone)]
179pub struct KnowledgePackEntry {
180 knowledge_ref: KnowledgeRef,
181 pack: Arc<dyn KnowledgePack>,
182}
183
184impl KnowledgePackEntry {
185 pub fn new(knowledge_ref: KnowledgeRef, pack: impl KnowledgePack + 'static) -> Self {
186 Self {
187 knowledge_ref,
188 pack: Arc::new(pack),
189 }
190 }
191
192 pub fn library(pack_name: impl AsRef<str>, pack: impl KnowledgePack + 'static) -> Result<Self> {
193 Ok(Self::new(KnowledgeRef::library(pack_name)?, pack))
194 }
195
196 pub fn package(
197 package_name: impl AsRef<str>,
198 pack: impl KnowledgePack + 'static,
199 ) -> Result<Self> {
200 Ok(Self::new(KnowledgeRef::package(package_name)?, pack))
201 }
202
203 pub fn local(pack_name: impl AsRef<str>, pack: impl KnowledgePack + 'static) -> Result<Self> {
204 Ok(Self::new(KnowledgeRef::local(pack_name)?, pack))
205 }
206
207 pub fn knowledge_ref(&self) -> &KnowledgeRef {
208 &self.knowledge_ref
209 }
210
211 pub fn selector(&self) -> String {
212 self.knowledge_ref.selector()
213 }
214
215 pub fn pack(&self) -> &Arc<dyn KnowledgePack> {
216 &self.pack
217 }
218
219 fn into_parts(self) -> (KnowledgeRef, Arc<dyn KnowledgePack>) {
220 (self.knowledge_ref, self.pack)
221 }
222}
223
224#[derive(Clone, Default)]
225pub struct StaticKnowledgeRegistry {
226 packs: Arc<HashMap<String, Arc<dyn KnowledgePack>>>,
227}
228
229impl StaticKnowledgeRegistry {
230 pub fn new() -> Self {
231 Self::default()
232 }
233
234 pub fn with_pack(mut self, selector: impl Into<String>, pack: Arc<dyn KnowledgePack>) -> Self {
235 Arc::make_mut(&mut self.packs).insert(selector.into(), pack);
236 self
237 }
238
239 pub fn with_entry(self, entry: KnowledgePackEntry) -> Self {
240 let (knowledge_ref, pack) = entry.into_parts();
241 self.with_pack(knowledge_ref.selector(), pack)
242 }
243
244 pub fn with_entries(mut self, entries: impl IntoIterator<Item = KnowledgePackEntry>) -> Self {
245 for entry in entries {
246 self = self.with_entry(entry);
247 }
248 self
249 }
250
251 pub fn is_empty(&self) -> bool {
252 self.packs.is_empty()
253 }
254}
255
256#[derive(Clone, Default)]
257pub struct CompositeKnowledgeRegistry {
258 library: StaticKnowledgeRegistry,
259 package: StaticKnowledgeRegistry,
260 local: StaticKnowledgeRegistry,
261}
262
263impl CompositeKnowledgeRegistry {
264 pub fn new() -> Self {
265 Self::default()
266 }
267
268 pub fn with_entry(mut self, entry: KnowledgePackEntry) -> Self {
269 match entry.knowledge_ref() {
270 KnowledgeRef::Library { .. } => {
271 self.library = self.library.with_entry(entry);
272 }
273 KnowledgeRef::Package { .. } => {
274 self.package = self.package.with_entry(entry);
275 }
276 KnowledgeRef::Local { .. } => {
277 self.local = self.local.with_entry(entry);
278 }
279 }
280 self
281 }
282
283 pub fn with_entries(mut self, entries: impl IntoIterator<Item = KnowledgePackEntry>) -> Self {
284 for entry in entries {
285 self = self.with_entry(entry);
286 }
287 self
288 }
289
290 pub fn is_empty(&self) -> bool {
291 self.library.is_empty() && self.package.is_empty() && self.local.is_empty()
292 }
293
294 pub fn from_entries(entries: impl IntoIterator<Item = KnowledgePackEntry>) -> Self {
295 entries
296 .into_iter()
297 .fold(Self::new(), |registry, entry| registry.with_entry(entry))
298 }
299
300 fn static_registry_for_selector(&self, selector: &str) -> Result<&StaticKnowledgeRegistry> {
301 match KnowledgeRef::from_str(selector)? {
302 KnowledgeRef::Library { .. } => Ok(&self.library),
303 KnowledgeRef::Package { .. } => Ok(&self.package),
304 KnowledgeRef::Local { .. } => Ok(&self.local),
305 }
306 }
307}
308
309#[async_trait]
310impl KnowledgeRegistry for CompositeKnowledgeRegistry {
311 async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>> {
312 let mut packs = Vec::new();
313 packs.extend(self.library.list_packs().await?);
314 packs.extend(self.package.list_packs().await?);
315 packs.extend(self.local.list_packs().await?);
316 packs.sort_by(|a, b| a.pack.cmp(&b.pack));
317 Ok(packs)
318 }
319
320 async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>> {
321 self.static_registry_for_selector(selector)?
322 .resolve_pack(selector)
323 .await
324 }
325}
326
327#[async_trait]
328impl KnowledgeRegistry for StaticKnowledgeRegistry {
329 async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>> {
330 let mut packs = self
331 .packs
332 .iter()
333 .map(|(selector, pack)| KnowledgePackSummary::new(selector, pack.manifest()))
334 .collect::<Vec<_>>();
335 packs.sort_by(|a, b| a.pack.cmp(&b.pack));
336 Ok(packs)
337 }
338
339 async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>> {
340 self.packs
341 .get(selector)
342 .cloned()
343 .ok_or_else(|| anyhow!("unknown knowledge pack '{selector}'"))
344 }
345}
346
347#[derive(Debug, Clone, Serialize)]
348pub struct KnowledgePackSummary {
349 pub selector: String,
351 pub pack: String,
353 pub pack_id: String,
354 pub version: String,
355 pub root_uri: String,
356 pub document_count: usize,
357}
358
359impl KnowledgePackSummary {
360 pub fn new(pack: impl Into<String>, manifest: &dyn KnowledgePackManifest) -> Self {
361 let selector = pack.into();
362 Self {
363 pack: selector.clone(),
364 selector,
365 pack_id: manifest.pack_id().to_string(),
366 version: manifest.version().to_string(),
367 root_uri: manifest.root_uri().to_string(),
368 document_count: manifest.docs().len(),
369 }
370 }
371
372 pub fn from_parts(
373 selector: impl Into<String>,
374 pack_id: impl Into<String>,
375 version: impl Into<String>,
376 root_uri: impl Into<String>,
377 document_count: usize,
378 ) -> Self {
379 let selector = selector.into();
380 Self {
381 pack: selector.clone(),
382 selector,
383 pack_id: pack_id.into(),
384 version: version.into(),
385 root_uri: root_uri.into(),
386 document_count,
387 }
388 }
389}
390
391#[derive(Debug, Clone, Deserialize)]
392pub struct KnowledgeReadArgs {
393 pub pack: String,
394 pub selector: String,
395}
396
397#[derive(Debug, Clone, Deserialize)]
398pub struct KnowledgeSearchArgs {
399 pub pack: String,
400 pub query: String,
401 #[serde(flatten)]
402 pub filter: KnowledgeFilterArgs,
403}
404
405#[derive(Debug, Clone, Deserialize)]
406pub struct KnowledgeNeighborArgs {
407 pub pack: String,
408 pub selector: String,
409 pub edge_type: Option<String>,
410}
411
412#[derive(Debug, Clone, Default, Deserialize)]
413pub struct KnowledgeFilterArgs {
414 #[serde(default)]
415 pub tags: Vec<String>,
416 pub kind: Option<String>,
417 pub selector_prefix: Option<String>,
418 pub related_to: Option<String>,
419 pub edge_type: Option<String>,
420}
421
422#[derive(Debug, Clone, Serialize)]
423pub struct KnowledgeDocMetadataResult {
424 pub pack: String,
426 pub slug: String,
428 pub selector: String,
430 pub title: String,
432 pub summary: String,
434 pub kind: String,
436 pub tags: Vec<String>,
438 pub related: Vec<KnowledgeDocRelatedResult>,
440}
441
442#[derive(Debug, Clone, Serialize)]
443pub struct KnowledgeDocRelatedResult {
444 #[serde(rename = "type")]
445 pub edge_type: String,
446 pub target: String,
448 pub target_doc: String,
450}
451
452#[derive(Debug, Clone, Serialize)]
453pub struct KnowledgeDocReadResult {
454 pub document: KnowledgeDocMetadataResult,
456 pub content: String,
458}
459
460#[derive(Debug, Clone, Serialize)]
461pub struct KnowledgeDocSearchResult {
462 pub document: KnowledgeDocMetadataResult,
464 pub score: usize,
466 pub matched: Vec<String>,
468}
469
470#[derive(Debug, Clone, Serialize)]
471pub struct KnowledgeDocNeighborsResult {
472 pub document: KnowledgeDocMetadataResult,
474 pub edges: Vec<KnowledgeDocNeighborEdgeResult>,
476}
477
478#[derive(Debug, Clone, Serialize)]
479pub struct KnowledgeDocNeighborEdgeResult {
480 #[serde(rename = "type")]
481 pub edge_type: String,
482 pub target: KnowledgeDocMetadataResult,
484}
485
486pub fn knowledge_filter(filter: KnowledgeFilterArgs) -> Result<KnowledgeDocFilter> {
487 Ok(KnowledgeDocFilter {
488 tags: filter.tags,
489 kind: parse_knowledge_enum(filter.kind)?,
490 selector_prefix: filter.selector_prefix,
491 related_to: filter.related_to,
492 edge_type: parse_knowledge_enum(filter.edge_type)?,
493 })
494}
495
496pub fn parse_knowledge_enum<T>(value: Option<String>) -> Result<Option<T>>
497where
498 T: serde::de::DeserializeOwned,
499{
500 value
501 .map(|value| {
502 serde_json::from_value(serde_json::Value::String(value.to_lowercase()))
503 .with_context(|| "invalid knowledge filter value")
504 })
505 .transpose()
506}
507
508pub fn knowledge_document_metadata(
509 pack: impl Into<String>,
510 doc: &KnowledgeDocManifest,
511 pack_source: Option<&dyn KnowledgePack>,
512) -> KnowledgeDocMetadataResult {
513 KnowledgeDocMetadataResult {
514 pack: pack.into(),
515 slug: doc.id.clone(),
516 selector: doc.selector.clone(),
517 title: doc.title.clone(),
518 summary: doc.summary.clone(),
519 kind: doc.kind.as_str().to_string(),
520 tags: doc.tags.clone(),
521 related: doc
522 .related
523 .iter()
524 .map(|edge| KnowledgeDocRelatedResult {
525 edge_type: edge.edge_type.as_str().to_string(),
526 target: edge.target.clone(),
527 target_doc: pack_source
528 .and_then(|pack| pack.read_manifest(&edge.target))
529 .map(|target| target.id.clone())
530 .unwrap_or_else(|| edge.target.clone()),
531 })
532 .collect(),
533 }
534}
535
536pub fn knowledge_search_result(
537 pack: impl Into<String>,
538 pack_source: &dyn KnowledgePack,
539 hit: KnowledgeDocSearchHit,
540) -> KnowledgeDocSearchResult {
541 KnowledgeDocSearchResult {
542 document: knowledge_document_metadata(pack, &hit.document, Some(pack_source)),
543 score: hit.score,
544 matched: hit.matched,
545 }
546}
547
548pub fn knowledge_neighbors_result(
549 pack: impl Into<String> + Clone,
550 pack_source: &dyn KnowledgePack,
551 neighbors: KnowledgeDocNeighbor,
552) -> KnowledgeDocNeighborsResult {
553 KnowledgeDocNeighborsResult {
554 document: knowledge_document_metadata(pack.clone(), &neighbors.document, Some(pack_source)),
555 edges: neighbors
556 .edges
557 .into_iter()
558 .map(|edge| KnowledgeDocNeighborEdgeResult {
559 edge_type: edge.edge_type.as_str().to_string(),
560 target: knowledge_document_metadata(pack.clone(), &edge.target, Some(pack_source)),
561 })
562 .collect(),
563 }
564}
565
566pub fn knowledge_document_metadata_vars(
567 knowledge_ref: &KnowledgeRef,
568 pack: &dyn KnowledgePack,
569) -> HashMap<String, String> {
570 let mut vars = HashMap::new();
571 for doc in pack.manifest().docs() {
572 let metadata = doc_metadata(knowledge_ref, doc);
573 vars.insert(
574 knowledge_document_var_key(knowledge_ref, doc),
575 metadata.clone(),
576 );
577 for key in knowledge_document_alias_var_keys(knowledge_ref, doc) {
578 vars.entry(key).or_insert_with(|| metadata.clone());
579 }
580 }
581 vars
582}
583
584pub fn knowledge_prompt_vars_from_entries(
585 entries: impl IntoIterator<Item = KnowledgePackEntry>,
586) -> HashMap<String, String> {
587 let mut vars = HashMap::new();
588 for entry in entries {
589 vars.extend(knowledge_pack_prompt_vars(
590 entry.knowledge_ref(),
591 entry.pack().as_ref(),
592 ));
593 }
594 vars
595}
596
597pub fn knowledge_pack_prompt_vars(
598 knowledge_ref: &KnowledgeRef,
599 pack: &dyn KnowledgePack,
600) -> HashMap<String, String> {
601 let prefix = knowledge_ref.prompt_prefix();
602 let mut vars = HashMap::new();
603 vars.insert(prefix, knowledge_pack_summary(knowledge_ref, pack));
604 vars.extend(knowledge_document_metadata_vars(knowledge_ref, pack));
605 vars
606}
607
608pub fn knowledge_pack_summary(knowledge_ref: &KnowledgeRef, pack: &dyn KnowledgePack) -> String {
609 let manifest = pack.manifest();
610 let selector = knowledge_ref.selector();
611 let namespace = match knowledge_ref {
612 KnowledgeRef::Library { .. } => "lib",
613 KnowledgeRef::Package { .. } => "pkg",
614 KnowledgeRef::Local { .. } => "local",
615 };
616 let ctx = KnowledgePackSummaryContext {
617 selector: selector.as_str(),
618 namespace,
619 name: manifest.pack_id(),
620 root: manifest.root_uri(),
621 usage: "Use the knowledge tools to search, inspect metadata, expand graph neighbors, and read documents from this pack when relevant.",
622 docs: manifest
623 .docs()
624 .iter()
625 .map(|doc| KnowledgeDocumentSummaryContext {
626 selector: doc.selector.as_str(),
627 id: doc.id.as_str(),
628 kind: doc.kind.as_str(),
629 title: doc.title.as_str(),
630 summary: doc.summary.as_str(),
631 related: doc
632 .related
633 .iter()
634 .map(|edge| KnowledgeDocumentRelatedSummaryContext {
635 edge_type: edge.edge_type.as_str(),
636 target: edge.target.as_str(),
637 })
638 .collect(),
639 })
640 .collect(),
641 };
642
643 nenjo_xml::to_xml_pretty(&ctx, 2)
644}
645
646#[derive(Debug, Serialize)]
647#[serde(rename = "knowledge_pack")]
648struct KnowledgePackSummaryContext<'a> {
649 #[serde(rename = "@selector")]
650 selector: &'a str,
651 #[serde(rename = "@namespace")]
652 namespace: &'a str,
653 #[serde(rename = "@name")]
654 name: &'a str,
655 #[serde(rename = "@root")]
656 root: &'a str,
657 usage: &'a str,
658 #[serde(rename = "doc")]
659 docs: Vec<KnowledgeDocumentSummaryContext<'a>>,
660}
661
662#[derive(Debug, Serialize)]
663#[serde(rename = "doc")]
664struct KnowledgeDocumentSummaryContext<'a> {
665 #[serde(rename = "@selector")]
666 selector: &'a str,
667 #[serde(rename = "@id")]
668 id: &'a str,
669 #[serde(rename = "@kind")]
670 kind: &'a str,
671 title: &'a str,
672 summary: &'a str,
673 #[serde(rename = "related", skip_serializing_if = "Vec::is_empty", default)]
674 related: Vec<KnowledgeDocumentRelatedSummaryContext<'a>>,
675}
676
677#[derive(Debug, Serialize)]
678#[serde(rename = "related")]
679struct KnowledgeDocumentRelatedSummaryContext<'a> {
680 #[serde(rename = "@type")]
681 edge_type: &'a str,
682 #[serde(rename = "@target")]
683 target: &'a str,
684}
685
686pub fn knowledge_document_var_key(
687 knowledge_ref: &KnowledgeRef,
688 doc: &KnowledgeDocManifest,
689) -> String {
690 let pack_prefix = knowledge_ref.prompt_prefix();
691 let selector = prompt_doc_selector(doc);
692 let path = selector
693 .strip_suffix(".md")
694 .unwrap_or(selector.as_str())
695 .split(['.', '/'])
696 .filter(|segment| !segment.is_empty())
697 .map(normalize_var_segment)
698 .filter(|segment| !segment.is_empty())
699 .collect::<Vec<_>>()
700 .join(".");
701 if path.is_empty() {
702 pack_prefix
703 } else {
704 format!("{pack_prefix}.{path}")
705 }
706}
707
708fn knowledge_document_alias_var_keys(
709 knowledge_ref: &KnowledgeRef,
710 doc: &KnowledgeDocManifest,
711) -> Vec<String> {
712 let mut keys = Vec::new();
713 let pack_prefix = knowledge_ref.prompt_prefix();
714 let selector = prompt_doc_selector(doc);
715 let Some((parent, _leaf)) = selector
716 .strip_suffix(".md")
717 .unwrap_or(selector.as_str())
718 .rsplit_once(['.', '/'])
719 else {
720 return keys;
721 };
722 let parent = parent
723 .split(['.', '/'])
724 .filter(|segment| !segment.is_empty())
725 .map(normalize_var_segment)
726 .filter(|segment| !segment.is_empty())
727 .collect::<Vec<_>>()
728 .join(".");
729
730 if let Some(stripped) = doc.id.strip_prefix("nenjo.") {
731 let id_segments = stripped
732 .split('.')
733 .map(normalize_var_segment)
734 .filter(|segment| !segment.is_empty())
735 .collect::<Vec<_>>();
736 if id_segments.len() >= 2
737 && id_segments
738 .first()
739 .is_some_and(|segment| segment == &parent)
740 {
741 let basename = id_segments[1..].join("_");
742 keys.push(format!("{pack_prefix}.{parent}.nenjo_{basename}"));
743 }
744 }
745
746 keys
747}
748
749fn normalize_var_segment(segment: &str) -> String {
750 let mut normalized = String::new();
751 let mut last_was_underscore = false;
752 for ch in segment.chars() {
753 let ch = ch.to_ascii_lowercase();
754 if ch.is_ascii_alphanumeric() {
755 normalized.push(ch);
756 last_was_underscore = false;
757 } else if !last_was_underscore {
758 normalized.push('_');
759 last_was_underscore = true;
760 }
761 }
762 normalized.trim_matches('_').to_string()
763}
764
765#[derive(Debug, Serialize)]
766#[serde(rename = "knowledge_doc")]
767struct KnowledgeDocMetadataContext<'a> {
768 #[serde(rename = "@pack")]
769 pack: &'a str,
770 #[serde(rename = "@selector")]
771 selector: &'a str,
772 #[serde(rename = "@title")]
773 title: &'a str,
774 #[serde(rename = "@kind")]
775 kind: &'a str,
776 summary: &'a str,
777 #[serde(skip_serializing_if = "Vec::is_empty", default)]
778 tags: Vec<&'a str>,
779 #[serde(rename = "related", skip_serializing_if = "Vec::is_empty", default)]
780 related: Vec<KnowledgeDocumentRelatedSummaryContext<'a>>,
781}
782
783fn doc_metadata(knowledge_ref: &KnowledgeRef, doc: &KnowledgeDocManifest) -> String {
784 let selector = prompt_doc_selector(doc);
785 let pack = knowledge_ref.selector();
786 let ctx = KnowledgeDocMetadataContext {
787 pack: &pack,
788 selector: &selector,
789 title: &doc.title,
790 summary: &doc.summary,
791 kind: doc.kind.as_str(),
792 tags: doc.tags.iter().map(String::as_str).collect(),
793 related: doc
794 .related
795 .iter()
796 .map(|edge| KnowledgeDocumentRelatedSummaryContext {
797 edge_type: edge.edge_type.as_str(),
798 target: edge.target.as_str(),
799 })
800 .collect(),
801 };
802 nenjo_xml::to_xml_pretty(&ctx, 2)
803}
804
805fn prompt_doc_selector(doc: &KnowledgeDocManifest) -> String {
806 if doc.selector.starts_with("library://") {
807 doc.selector
808 .splitn(4, '/')
809 .nth(3)
810 .unwrap_or(&doc.selector)
811 .to_string()
812 } else {
813 doc.selector.clone()
814 }
815}
816
817fn pack_schema() -> serde_json::Value {
818 json!({
819 "type": "string",
820 "description": "Canonical knowledge pack selector. Use exactly the selector returned by list_knowledge_packs or the pack attribute in seeded knowledge metadata, such as pkg:<source>.<repo>.<package>.<pack>."
821 })
822}
823
824fn knowledge_filter_schema(
825 extra_properties: Option<serde_json::Value>,
826 required: &[&str],
827) -> serde_json::Value {
828 let mut properties = json!({
829 "pack": pack_schema(),
830 "tags": {
831 "type": "array",
832 "items": { "type": "string" },
833 "description": "Optional tags that all returned docs must have"
834 },
835 "kind": {
836 "type": "string",
837 "description": "Optional kind filter such as guide or reference"
838 },
839 "selector_prefix": {
840 "type": "string",
841 "description": "Optional virtual or pack-relative selector prefix"
842 },
843 "related_to": {
844 "type": "string",
845 "description": "Optional selector of a document this result must be related to"
846 },
847 "edge_type": {
848 "type": "string",
849 "description": "Optional relationship type used with related_to or neighbors"
850 }
851 });
852
853 if let Some(extra) = extra_properties
854 && let Some(map) = properties.as_object_mut()
855 && let Some(extra_map) = extra.as_object()
856 {
857 for (key, value) in extra_map {
858 map.insert(key.clone(), value.clone());
859 }
860 }
861
862 json!({
863 "type": "object",
864 "properties": properties,
865 "required": required,
866 "additionalProperties": false
867 })
868}
869
870fn knowledge_lookup_schema() -> serde_json::Value {
871 json!({
872 "type": "object",
873 "properties": {
874 "pack": pack_schema(),
875 "selector": {
876 "type": "string",
877 "description": "Document selector or id within the selected pack"
878 }
879 },
880 "required": ["pack", "selector"],
881 "additionalProperties": false
882 })
883}
884
885pub fn knowledge_tools() -> Vec<ToolSpec> {
886 vec![
887 ToolSpec {
888 name: "list_knowledge_packs".into(),
889 description: "List locally available knowledge packs. Copy the returned selector value into the pack argument for read_knowledge_doc, search_knowledge, and list_knowledge_neighbors.".into(),
890 parameters: json!({
891 "type": "object",
892 "properties": {},
893 "additionalProperties": false
894 }),
895 category: ToolCategory::Read,
896 },
897 ToolSpec {
898 name: "read_knowledge_doc".into(),
899 description: "Read one full document body from a knowledge pack by path, selector, or document slug. The returned document.slug is the stable slug to use with update_knowledge_doc, delete_knowledge_doc, or related.target_doc.".into(),
900 parameters: knowledge_lookup_schema(),
901 category: ToolCategory::Read,
902 },
903 ToolSpec {
904 name: "search_knowledge".into(),
905 description: "Search a knowledge pack and return candidate document metadata without loading document bodies. Each result includes document.slug, the stable slug to use with update_knowledge_doc, delete_knowledge_doc, or related.target_doc.".into(),
906 parameters: knowledge_filter_schema(
907 Some(json!({
908 "query": {
909 "type": "string",
910 "description": "Search query, path, title, tag, or summary"
911 }
912 })),
913 &["pack", "query"],
914 ),
915 category: ToolCategory::Read,
916 },
917 ToolSpec {
918 name: "list_knowledge_neighbors".into(),
919 description: "List outbound graph neighbors for one document in a knowledge pack.".into(),
920 parameters: json!({
921 "type": "object",
922 "properties": {
923 "pack": pack_schema(),
924 "selector": {
925 "type": "string",
926 "description": "Document selector or id within the selected pack"
927 },
928 "edge_type": {
929 "type": "string",
930 "description": "Optional relationship type filter such as references or depends_on"
931 }
932 },
933 "required": ["pack", "selector"],
934 "additionalProperties": false
935 }),
936 category: ToolCategory::Read,
937 },
938 ]
939}
940
941fn knowledge_tool_spec(name: &str) -> ToolSpec {
942 knowledge_tools()
943 .into_iter()
944 .find(|tool| tool.name == name)
945 .unwrap_or_else(|| panic!("missing knowledge tool spec: {name}"))
946}
947
948pub fn knowledge_list_packs_tool(registry: Arc<dyn KnowledgeRegistry>) -> Arc<dyn Tool> {
951 Arc::new(KnowledgeTool::new(
952 knowledge_tool_spec("list_knowledge_packs"),
953 registry,
954 ))
955}
956
957pub fn knowledge_traversal_tools(registry: Arc<dyn KnowledgeRegistry>) -> Vec<Arc<dyn Tool>> {
959 knowledge_tools()
960 .into_iter()
961 .filter(|spec| spec.name != "list_knowledge_packs")
962 .map(|spec| Arc::new(KnowledgeTool::new(spec, registry.clone())) as Arc<dyn Tool>)
963 .collect()
964}
965
966pub fn knowledge_toolbelt(registry: Arc<dyn KnowledgeRegistry>) -> Vec<Arc<dyn Tool>> {
967 let mut tools = vec![knowledge_list_packs_tool(registry.clone())];
968 tools.extend(knowledge_traversal_tools(registry));
969 tools
970}
971
972struct KnowledgeTool {
973 spec: ToolSpec,
974 registry: Arc<dyn KnowledgeRegistry>,
975}
976
977impl KnowledgeTool {
978 fn new(spec: ToolSpec, registry: Arc<dyn KnowledgeRegistry>) -> Self {
979 Self { spec, registry }
980 }
981}
982
983#[async_trait]
984impl Tool for KnowledgeTool {
985 fn name(&self) -> &str {
986 &self.spec.name
987 }
988
989 fn description(&self) -> &str {
990 &self.spec.description
991 }
992
993 fn parameters_schema(&self) -> serde_json::Value {
994 self.spec.parameters.clone()
995 }
996
997 fn category(&self) -> ToolCategory {
998 self.spec.category
999 }
1000
1001 fn origin(&self) -> ToolOrigin {
1002 ToolOrigin::Platform
1003 }
1004
1005 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
1006 let output = match self.name() {
1007 "list_knowledge_packs" => serde_json::to_value(self.registry.list_packs().await?)?,
1008 "read_knowledge_doc" => {
1009 let args: KnowledgeReadArgs = serde_json::from_value(args)?;
1010 let pack = self.registry.resolve_pack(&args.pack).await?;
1011 let doc = pack.read_doc(&args.selector).ok_or_else(|| {
1012 anyhow!(
1013 "knowledge document '{}' not found in pack '{}'",
1014 args.selector,
1015 args.pack
1016 )
1017 })?;
1018 serde_json::to_value(KnowledgeDocReadResult {
1019 document: knowledge_document_metadata(
1020 args.pack,
1021 &doc.manifest,
1022 Some(pack.as_ref()),
1023 ),
1024 content: doc.content,
1025 })?
1026 }
1027 "search_knowledge" => {
1028 let args: KnowledgeSearchArgs = serde_json::from_value(args)?;
1029 let pack = self.registry.resolve_pack(&args.pack).await?;
1030 let filter = knowledge_filter(args.filter)?;
1031 let hits = pack
1032 .search(&args.query, filter)
1033 .into_iter()
1034 .map(|hit| knowledge_search_result(args.pack.clone(), pack.as_ref(), hit))
1035 .collect::<Vec<_>>();
1036 serde_json::to_value(hits)?
1037 }
1038 "list_knowledge_neighbors" => {
1039 let args: KnowledgeNeighborArgs = serde_json::from_value(args)?;
1040 let pack = self.registry.resolve_pack(&args.pack).await?;
1041 let edge_type = parse_knowledge_enum(args.edge_type)?;
1042 let neighbors = pack.neighbors(&args.selector, edge_type).ok_or_else(|| {
1043 anyhow!(
1044 "knowledge document '{}' not found in pack '{}'",
1045 args.selector,
1046 args.pack
1047 )
1048 })?;
1049 serde_json::to_value(knowledge_neighbors_result(
1050 args.pack,
1051 pack.as_ref(),
1052 neighbors,
1053 ))?
1054 }
1055 name => return Err(anyhow!("unknown knowledge tool '{name}'")),
1056 };
1057
1058 Ok(ToolResult {
1059 success: true,
1060 output: serde_json::to_string_pretty(&output)?,
1061 error: None,
1062 })
1063 }
1064}
1065
1066#[cfg(test)]
1067mod tests {
1068 use std::borrow::Cow;
1069 use std::future::Future;
1070 use std::task::{Context, Poll, Waker};
1071
1072 use super::{
1073 CompositeKnowledgeRegistry, KnowledgeDocReadResult, KnowledgePackEntry, KnowledgeRef,
1074 KnowledgeRegistry, knowledge_document_var_key, knowledge_list_packs_tool,
1075 knowledge_neighbors_result, knowledge_search_result, knowledge_tools,
1076 };
1077 use crate::{
1078 KnowledgeDocEdge, KnowledgeDocEdgeType, KnowledgeDocKind, KnowledgeDocManifest,
1079 KnowledgePack, KnowledgePackManifest, KnowledgePackManifestData,
1080 };
1081 use serde_json::json;
1082
1083 struct TestPack {
1084 manifest: KnowledgePackManifestData,
1085 }
1086
1087 impl KnowledgePack for TestPack {
1088 fn manifest(&self) -> &dyn KnowledgePackManifest {
1089 &self.manifest
1090 }
1091
1092 fn doc_content(&self, manifest: &KnowledgeDocManifest) -> Option<Cow<'_, str>> {
1093 Some(Cow::Owned(format!("body for {}", manifest.title)))
1094 }
1095 }
1096
1097 fn block_on<F: Future>(future: F) -> F::Output {
1098 let waker = Waker::noop();
1099 let mut context = Context::from_waker(waker);
1100 let mut future = Box::pin(future);
1101 match future.as_mut().poll(&mut context) {
1102 Poll::Ready(output) => output,
1103 Poll::Pending => panic!("test future unexpectedly yielded"),
1104 }
1105 }
1106
1107 fn test_doc(
1108 id: &str,
1109 path: &str,
1110 title: &str,
1111 related: Vec<KnowledgeDocEdge>,
1112 ) -> KnowledgeDocManifest {
1113 KnowledgeDocManifest {
1114 id: id.into(),
1115 selector: path.into(),
1116 source_path: path.trim_start_matches("library://test/").into(),
1117 title: title.into(),
1118 summary: format!("{title} summary"),
1119 kind: KnowledgeDocKind::new("routing-guide"),
1120 tags: vec!["core".into()],
1121 related,
1122 updated_at: String::new(),
1123 }
1124 }
1125
1126 fn test_pack() -> TestPack {
1127 TestPack {
1128 manifest: KnowledgePackManifestData {
1129 pack_id: "test".into(),
1130 version: "1".into(),
1131 schema_version: 1,
1132 root_uri: "library://test/".into(),
1133 content_hash: String::new(),
1134 docs: vec![
1135 test_doc(
1136 "root",
1137 "library://test/root.md",
1138 "Root",
1139 vec![KnowledgeDocEdge {
1140 edge_type: KnowledgeDocEdgeType::DependsOn,
1141 target: "library://test/leaf.md".into(),
1142 description: Some("root to leaf".into()),
1143 }],
1144 ),
1145 test_doc(
1146 "leaf",
1147 "library://test/leaf.md",
1148 "Leaf",
1149 vec![KnowledgeDocEdge {
1150 edge_type: KnowledgeDocEdgeType::References,
1151 target: "library://test/root.md".into(),
1152 description: Some("reverse edge".into()),
1153 }],
1154 ),
1155 ],
1156 },
1157 }
1158 }
1159
1160 #[test]
1161 fn composite_registry_routes_builtin_knowledge_namespaces() {
1162 block_on(async {
1163 let registry = CompositeKnowledgeRegistry::new()
1164 .with_entry(KnowledgePackEntry::library("docs", test_pack()).unwrap())
1165 .with_entry(KnowledgePackEntry::package("nenjo/core", test_pack()).unwrap())
1166 .with_entry(KnowledgePackEntry::local("scratch", test_pack()).unwrap());
1167
1168 let packs = registry.list_packs().await.unwrap();
1169 let selectors = packs
1170 .iter()
1171 .map(|pack| pack.selector.as_str())
1172 .collect::<Vec<_>>();
1173 assert_eq!(
1174 selectors,
1175 vec!["lib:docs", "local:scratch", "pkg:nenjo.core"]
1176 );
1177
1178 assert_eq!(
1179 registry
1180 .resolve_pack("lib:docs")
1181 .await
1182 .unwrap()
1183 .manifest()
1184 .pack_id(),
1185 "test"
1186 );
1187 assert!(registry.resolve_pack("pkg:nenjo.core").await.is_ok());
1188 assert!(registry.resolve_pack("local:scratch").await.is_ok());
1189 });
1190 }
1191
1192 #[test]
1193 fn list_knowledge_packs_tool_works_with_empty_registry() {
1194 block_on(async {
1195 let registry = std::sync::Arc::new(CompositeKnowledgeRegistry::new());
1196 let tool = knowledge_list_packs_tool(registry);
1197 assert_eq!(tool.name(), "list_knowledge_packs");
1198
1199 let result = tool.execute(serde_json::json!({})).await.unwrap();
1200 assert!(result.success);
1201 let packs: Vec<serde_json::Value> = serde_json::from_str(&result.output).unwrap();
1202 assert!(packs.is_empty());
1203 });
1204 }
1205
1206 #[test]
1207 fn default_knowledge_tool_registry_exposes_graph_first_tools_only() {
1208 let names = knowledge_tools()
1209 .into_iter()
1210 .map(|tool| tool.name)
1211 .collect::<Vec<_>>();
1212
1213 assert_eq!(
1214 names,
1215 vec![
1216 "list_knowledge_packs",
1217 "read_knowledge_doc",
1218 "search_knowledge",
1219 "list_knowledge_neighbors",
1220 ]
1221 );
1222 }
1223
1224 #[test]
1225 fn knowledge_pack_summary_returns_selector_for_tool_calls() {
1226 let pack = test_pack();
1227 let summary = super::KnowledgePackSummary::new(
1228 "pkg:nenjo-ai.packages.knowledge.core",
1229 pack.manifest(),
1230 );
1231
1232 assert_eq!(summary.selector, "pkg:nenjo-ai.packages.knowledge.core");
1233 assert_eq!(summary.pack, summary.selector);
1234 }
1235
1236 #[test]
1237 fn pack_prompt_summary_includes_compact_related_edges() {
1238 let pack = TestPack {
1239 manifest: KnowledgePackManifestData {
1240 pack_id: "test".into(),
1241 version: "1".into(),
1242 schema_version: 1,
1243 root_uri: "file:///tmp/test/".into(),
1244 content_hash: String::new(),
1245 docs: vec![
1246 test_doc(
1247 "root",
1248 "docs/root.md",
1249 "Root",
1250 vec![KnowledgeDocEdge {
1251 edge_type: KnowledgeDocEdgeType::DependsOn,
1252 target: "docs/leaf.md".into(),
1253 description: Some("root to leaf".into()),
1254 }],
1255 ),
1256 test_doc(
1257 "leaf",
1258 "docs/leaf.md",
1259 "Leaf",
1260 vec![KnowledgeDocEdge {
1261 edge_type: KnowledgeDocEdgeType::References,
1262 target: "docs/root.md".into(),
1263 description: Some("reverse edge".into()),
1264 }],
1265 ),
1266 ],
1267 },
1268 };
1269 let knowledge_ref = KnowledgeRef::local("test").unwrap();
1270 let summary = super::knowledge_pack_summary(&knowledge_ref, &pack);
1271
1272 assert!(summary.contains(r#"selector="local:test""#));
1273 assert!(summary.contains(r#"<related type="depends_on" target="docs/leaf.md""#));
1274 assert!(summary.contains(r#"<related type="references" target="docs/root.md""#));
1275 assert!(!summary.contains("root to leaf"));
1276 assert!(!summary.contains("reverse edge"));
1277 }
1278
1279 #[test]
1280 fn document_metadata_prompt_var_includes_related_edges() {
1281 let doc = test_doc(
1282 "root",
1283 "docs/root.md",
1284 "Root",
1285 vec![KnowledgeDocEdge {
1286 edge_type: KnowledgeDocEdgeType::DependsOn,
1287 target: "docs/leaf.md".into(),
1288 description: Some("root to leaf".into()),
1289 }],
1290 );
1291 let knowledge_ref = KnowledgeRef::local("test").unwrap();
1292 let metadata = super::doc_metadata(&knowledge_ref, &doc);
1293
1294 assert!(metadata.contains(r#"pack="local:test""#));
1295 assert!(metadata.contains(r#"selector="docs/root.md""#));
1296 assert!(metadata.contains(r#"<related type="depends_on" target="docs/leaf.md""#));
1297 assert!(!metadata.contains("root to leaf"));
1298 }
1299
1300 #[test]
1301 fn neighbor_traversal_returns_outbound_edges_with_slim_target_metadata() {
1302 let pack = test_pack();
1303 let result = pack
1304 .neighbors("root", None)
1305 .map(|neighbors| knowledge_neighbors_result("lib:test", &pack, neighbors))
1306 .expect("root neighbors");
1307 let value = serde_json::to_value(result).unwrap();
1308
1309 assert_eq!(value["document"]["selector"], "library://test/root.md");
1310 assert_eq!(value["document"]["slug"], "root");
1311 assert!(value["document"].get("id").is_none());
1312 assert!(value["document"].get("doc").is_none());
1313 assert_eq!(value["document"]["related"][0]["type"], "depends_on");
1314 assert_eq!(
1315 value["document"]["related"][0]["target"],
1316 "library://test/leaf.md"
1317 );
1318 assert_eq!(value["document"]["related"][0]["target_doc"], "leaf");
1319 assert_eq!(value["edges"].as_array().unwrap().len(), 1);
1320 assert_eq!(value["edges"][0]["type"], "depends_on");
1321 assert_eq!(
1322 value["edges"][0]["target"]["selector"],
1323 "library://test/leaf.md"
1324 );
1325 assert_eq!(value["edges"][0]["target"]["slug"], "leaf");
1326 assert_eq!(value["edges"][0]["target"]["kind"], "routing_guide");
1327 assert!(value["edges"][0]["target"].get("source_path").is_none());
1328 assert!(value["edges"][0].get("note").is_none());
1329 }
1330
1331 #[test]
1332 fn search_returns_slim_metadata_without_content() {
1333 let pack = test_pack();
1334 let value = serde_json::to_value(
1335 pack.search("Leaf", Default::default())
1336 .into_iter()
1337 .map(|hit| knowledge_search_result("lib:test", &pack, hit))
1338 .collect::<Vec<_>>(),
1339 )
1340 .unwrap();
1341
1342 assert_eq!(value[0]["document"]["selector"], "library://test/leaf.md");
1343 assert_eq!(value[0]["document"]["slug"], "leaf");
1344 assert!(value[0]["document"].get("id").is_none());
1345 assert!(value[0]["document"].get("doc").is_none());
1346 assert_eq!(value[0]["document"]["related"][0]["type"], "references");
1347 assert_eq!(
1348 value[0]["document"]["related"][0]["target"],
1349 "library://test/root.md"
1350 );
1351 assert_eq!(value[0]["document"]["related"][0]["target_doc"], "root");
1352 assert!(
1353 value[0]["matched"]
1354 .as_array()
1355 .unwrap()
1356 .contains(&json!("title"))
1357 );
1358 assert!(
1359 !value[0]["matched"]
1360 .as_array()
1361 .unwrap()
1362 .contains(&json!("content"))
1363 );
1364 assert!(value[0].get("content").is_none());
1365 assert!(value[0]["document"].get("aliases").is_none());
1366 }
1367
1368 #[test]
1369 fn read_knowledge_doc_result_keeps_full_content_explicit() {
1370 let pack = test_pack();
1371 let doc = pack.read_doc("leaf").expect("leaf doc");
1372 let value = serde_json::to_value(KnowledgeDocReadResult {
1373 document: super::knowledge_document_metadata("lib:test", &doc.manifest, Some(&pack)),
1374 content: doc.content,
1375 })
1376 .unwrap();
1377
1378 assert_eq!(value["document"]["pack"], "lib:test");
1379 assert_eq!(value["document"]["selector"], "library://test/leaf.md");
1380 assert_eq!(value["document"]["slug"], "leaf");
1381 assert!(value["document"].get("id").is_none());
1382 assert!(value["document"].get("doc").is_none());
1383 assert_eq!(
1384 value["document"]["related"][0]["target"],
1385 "library://test/root.md"
1386 );
1387 assert_eq!(value["document"]["related"][0]["target_doc"], "root");
1388 assert_eq!(value["content"], "body for Leaf");
1389 }
1390
1391 #[test]
1392 fn library_knowledge_uses_lib_template_namespace() {
1393 let knowledge_ref = KnowledgeRef::library("product-docs").unwrap();
1394 assert_eq!(knowledge_ref.selector(), "lib:product-docs");
1395 assert_eq!(knowledge_ref.prompt_prefix(), "lib.product_docs");
1396 }
1397
1398 #[test]
1399 fn pkg_knowledge_uses_package_template_namespace() {
1400 let knowledge_ref = KnowledgeRef::package("@nenjo/core").unwrap();
1401 assert_eq!(knowledge_ref.selector(), "pkg:nenjo.core");
1402 assert_eq!(knowledge_ref.prompt_prefix(), "pkg.nenjo.core");
1403 }
1404
1405 #[test]
1406 fn pkg_knowledge_document_vars_use_package_relative_paths() {
1407 let knowledge_ref = KnowledgeRef::package("nenjo.core").unwrap();
1408 let doc = KnowledgeDocManifest {
1409 id: "nenjo.resources.agents".into(),
1410 selector: "resources.agents".into(),
1411 source_path: "docs/resources/agents.md".into(),
1412 title: "Agents".into(),
1413 summary: String::new(),
1414 kind: KnowledgeDocKind::new("guide"),
1415 tags: Vec::new(),
1416 related: Vec::new(),
1417 updated_at: String::new(),
1418 };
1419
1420 assert_eq!(
1421 knowledge_document_var_key(&knowledge_ref, &doc),
1422 "pkg.nenjo.core.resources.agents"
1423 );
1424 }
1425}