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 fn static_registry_for_selector(&self, selector: &str) -> Result<&StaticKnowledgeRegistry> {
295 match KnowledgeRef::from_str(selector)? {
296 KnowledgeRef::Library { .. } => Ok(&self.library),
297 KnowledgeRef::Package { .. } => Ok(&self.package),
298 KnowledgeRef::Local { .. } => Ok(&self.local),
299 }
300 }
301}
302
303#[async_trait]
304impl KnowledgeRegistry for CompositeKnowledgeRegistry {
305 async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>> {
306 let mut packs = Vec::new();
307 packs.extend(self.library.list_packs().await?);
308 packs.extend(self.package.list_packs().await?);
309 packs.extend(self.local.list_packs().await?);
310 packs.sort_by(|a, b| a.pack.cmp(&b.pack));
311 Ok(packs)
312 }
313
314 async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>> {
315 self.static_registry_for_selector(selector)?
316 .resolve_pack(selector)
317 .await
318 }
319}
320
321#[async_trait]
322impl KnowledgeRegistry for StaticKnowledgeRegistry {
323 async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>> {
324 let mut packs = self
325 .packs
326 .iter()
327 .map(|(selector, pack)| KnowledgePackSummary::new(selector, pack.manifest()))
328 .collect::<Vec<_>>();
329 packs.sort_by(|a, b| a.pack.cmp(&b.pack));
330 Ok(packs)
331 }
332
333 async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>> {
334 self.packs
335 .get(selector)
336 .cloned()
337 .ok_or_else(|| anyhow!("unknown knowledge pack '{selector}'"))
338 }
339}
340
341#[derive(Debug, Clone, Serialize)]
342pub struct KnowledgePackSummary {
343 pub selector: String,
345 pub pack: String,
347 pub pack_id: String,
348 pub version: String,
349 pub root_uri: String,
350 pub document_count: usize,
351}
352
353impl KnowledgePackSummary {
354 pub fn new(pack: impl Into<String>, manifest: &dyn KnowledgePackManifest) -> Self {
355 let selector = pack.into();
356 Self {
357 pack: selector.clone(),
358 selector,
359 pack_id: manifest.pack_id().to_string(),
360 version: manifest.version().to_string(),
361 root_uri: manifest.root_uri().to_string(),
362 document_count: manifest.docs().len(),
363 }
364 }
365}
366
367#[derive(Debug, Clone, Deserialize)]
368pub struct KnowledgeReadArgs {
369 pub pack: String,
370 pub selector: String,
371}
372
373#[derive(Debug, Clone, Deserialize)]
374pub struct KnowledgeSearchArgs {
375 pub pack: String,
376 pub query: String,
377 #[serde(flatten)]
378 pub filter: KnowledgeFilterArgs,
379}
380
381#[derive(Debug, Clone, Deserialize)]
382pub struct KnowledgeNeighborArgs {
383 pub pack: String,
384 pub selector: String,
385 pub edge_type: Option<String>,
386}
387
388#[derive(Debug, Clone, Default, Deserialize)]
389pub struct KnowledgeFilterArgs {
390 #[serde(default)]
391 pub tags: Vec<String>,
392 pub kind: Option<String>,
393 pub selector_prefix: Option<String>,
394 pub related_to: Option<String>,
395 pub edge_type: Option<String>,
396}
397
398#[derive(Debug, Clone, Serialize)]
399pub struct KnowledgeDocMetadataResult {
400 pub pack: String,
402 pub id: String,
404 pub selector: String,
406 pub title: String,
408 pub summary: String,
410 pub kind: String,
412 pub tags: Vec<String>,
414 pub related: Vec<KnowledgeDocRelatedResult>,
416}
417
418#[derive(Debug, Clone, Serialize)]
419pub struct KnowledgeDocRelatedResult {
420 #[serde(rename = "type")]
421 pub edge_type: String,
422 pub target: String,
424}
425
426#[derive(Debug, Clone, Serialize)]
427pub struct KnowledgeDocReadResult {
428 pub document: KnowledgeDocMetadataResult,
430 pub content: String,
432}
433
434#[derive(Debug, Clone, Serialize)]
435pub struct KnowledgeDocSearchResult {
436 pub document: KnowledgeDocMetadataResult,
438 pub score: usize,
440 pub matched: Vec<String>,
442}
443
444#[derive(Debug, Clone, Serialize)]
445pub struct KnowledgeDocNeighborsResult {
446 pub document: KnowledgeDocMetadataResult,
448 pub edges: Vec<KnowledgeDocNeighborEdgeResult>,
450}
451
452#[derive(Debug, Clone, Serialize)]
453pub struct KnowledgeDocNeighborEdgeResult {
454 #[serde(rename = "type")]
455 pub edge_type: String,
456 pub target: KnowledgeDocMetadataResult,
458}
459
460pub fn knowledge_filter(filter: KnowledgeFilterArgs) -> Result<KnowledgeDocFilter> {
461 Ok(KnowledgeDocFilter {
462 tags: filter.tags,
463 kind: parse_knowledge_enum(filter.kind)?,
464 selector_prefix: filter.selector_prefix,
465 related_to: filter.related_to,
466 edge_type: parse_knowledge_enum(filter.edge_type)?,
467 })
468}
469
470pub fn parse_knowledge_enum<T>(value: Option<String>) -> Result<Option<T>>
471where
472 T: serde::de::DeserializeOwned,
473{
474 value
475 .map(|value| {
476 serde_json::from_value(serde_json::Value::String(value.to_lowercase()))
477 .with_context(|| "invalid knowledge filter value")
478 })
479 .transpose()
480}
481
482pub fn knowledge_document_metadata(
483 pack: impl Into<String>,
484 doc: &KnowledgeDocManifest,
485) -> KnowledgeDocMetadataResult {
486 KnowledgeDocMetadataResult {
487 pack: pack.into(),
488 id: doc.id.clone(),
489 selector: doc.selector.clone(),
490 title: doc.title.clone(),
491 summary: doc.summary.clone(),
492 kind: doc.kind.as_str().to_string(),
493 tags: doc.tags.clone(),
494 related: doc
495 .related
496 .iter()
497 .map(|edge| KnowledgeDocRelatedResult {
498 edge_type: edge.edge_type.as_str().to_string(),
499 target: edge.target.clone(),
500 })
501 .collect(),
502 }
503}
504
505pub fn knowledge_search_result(
506 pack: impl Into<String>,
507 hit: KnowledgeDocSearchHit,
508) -> KnowledgeDocSearchResult {
509 KnowledgeDocSearchResult {
510 document: knowledge_document_metadata(pack, &hit.document),
511 score: hit.score,
512 matched: hit.matched,
513 }
514}
515
516pub fn knowledge_neighbors_result(
517 pack: impl Into<String> + Clone,
518 neighbors: KnowledgeDocNeighbor,
519) -> KnowledgeDocNeighborsResult {
520 KnowledgeDocNeighborsResult {
521 document: knowledge_document_metadata(pack.clone(), &neighbors.document),
522 edges: neighbors
523 .edges
524 .into_iter()
525 .map(|edge| KnowledgeDocNeighborEdgeResult {
526 edge_type: edge.edge_type.as_str().to_string(),
527 target: knowledge_document_metadata(pack.clone(), &edge.target),
528 })
529 .collect(),
530 }
531}
532
533pub fn knowledge_document_metadata_vars(
534 knowledge_ref: &KnowledgeRef,
535 pack: &dyn KnowledgePack,
536) -> HashMap<String, String> {
537 let mut vars = HashMap::new();
538 for doc in pack.manifest().docs() {
539 let metadata = doc_metadata(knowledge_ref, doc);
540 vars.insert(
541 knowledge_document_var_key(knowledge_ref, doc),
542 metadata.clone(),
543 );
544 for key in knowledge_document_alias_var_keys(knowledge_ref, doc) {
545 vars.entry(key).or_insert_with(|| metadata.clone());
546 }
547 }
548 vars
549}
550
551pub fn knowledge_pack_prompt_vars(
552 knowledge_ref: &KnowledgeRef,
553 pack: &dyn KnowledgePack,
554) -> HashMap<String, String> {
555 let prefix = knowledge_ref.prompt_prefix();
556 let mut vars = HashMap::new();
557 vars.insert(prefix, knowledge_pack_summary(knowledge_ref, pack));
558 vars.extend(knowledge_document_metadata_vars(knowledge_ref, pack));
559 vars
560}
561
562pub fn knowledge_pack_summary(knowledge_ref: &KnowledgeRef, pack: &dyn KnowledgePack) -> String {
563 let manifest = pack.manifest();
564 let selector = knowledge_ref.selector();
565 let namespace = match knowledge_ref {
566 KnowledgeRef::Library { .. } => "lib",
567 KnowledgeRef::Package { .. } => "pkg",
568 KnowledgeRef::Local { .. } => "local",
569 };
570 let ctx = KnowledgePackSummaryContext {
571 selector: selector.as_str(),
572 namespace,
573 name: manifest.pack_id(),
574 root: manifest.root_uri(),
575 usage: "Use the knowledge tools to search, inspect metadata, expand graph neighbors, and read documents from this pack when relevant.",
576 docs: manifest
577 .docs()
578 .iter()
579 .map(|doc| KnowledgeDocumentSummaryContext {
580 selector: doc.selector.as_str(),
581 id: doc.id.as_str(),
582 kind: doc.kind.as_str(),
583 title: doc.title.as_str(),
584 summary: doc.summary.as_str(),
585 related: doc
586 .related
587 .iter()
588 .map(|edge| KnowledgeDocumentRelatedSummaryContext {
589 edge_type: edge.edge_type.as_str(),
590 target: edge.target.as_str(),
591 })
592 .collect(),
593 })
594 .collect(),
595 };
596
597 nenjo_xml::to_xml_pretty(&ctx, 2)
598}
599
600#[derive(Debug, Serialize)]
601#[serde(rename = "knowledge_pack")]
602struct KnowledgePackSummaryContext<'a> {
603 #[serde(rename = "@selector")]
604 selector: &'a str,
605 #[serde(rename = "@namespace")]
606 namespace: &'a str,
607 #[serde(rename = "@name")]
608 name: &'a str,
609 #[serde(rename = "@root")]
610 root: &'a str,
611 usage: &'a str,
612 #[serde(rename = "doc")]
613 docs: Vec<KnowledgeDocumentSummaryContext<'a>>,
614}
615
616#[derive(Debug, Serialize)]
617#[serde(rename = "doc")]
618struct KnowledgeDocumentSummaryContext<'a> {
619 #[serde(rename = "@selector")]
620 selector: &'a str,
621 #[serde(rename = "@id")]
622 id: &'a str,
623 #[serde(rename = "@kind")]
624 kind: &'a str,
625 title: &'a str,
626 summary: &'a str,
627 #[serde(rename = "related", skip_serializing_if = "Vec::is_empty", default)]
628 related: Vec<KnowledgeDocumentRelatedSummaryContext<'a>>,
629}
630
631#[derive(Debug, Serialize)]
632#[serde(rename = "related")]
633struct KnowledgeDocumentRelatedSummaryContext<'a> {
634 #[serde(rename = "@type")]
635 edge_type: &'a str,
636 #[serde(rename = "@target")]
637 target: &'a str,
638}
639
640pub fn knowledge_document_var_key(
641 knowledge_ref: &KnowledgeRef,
642 doc: &KnowledgeDocManifest,
643) -> String {
644 let pack_prefix = knowledge_ref.prompt_prefix();
645 let selector = prompt_doc_selector(doc);
646 let path = selector
647 .strip_suffix(".md")
648 .unwrap_or(selector.as_str())
649 .split(['.', '/'])
650 .filter(|segment| !segment.is_empty())
651 .map(normalize_var_segment)
652 .filter(|segment| !segment.is_empty())
653 .collect::<Vec<_>>()
654 .join(".");
655 if path.is_empty() {
656 pack_prefix
657 } else {
658 format!("{pack_prefix}.{path}")
659 }
660}
661
662fn knowledge_document_alias_var_keys(
663 knowledge_ref: &KnowledgeRef,
664 doc: &KnowledgeDocManifest,
665) -> Vec<String> {
666 let mut keys = Vec::new();
667 let pack_prefix = knowledge_ref.prompt_prefix();
668 let selector = prompt_doc_selector(doc);
669 let Some((parent, _leaf)) = selector
670 .strip_suffix(".md")
671 .unwrap_or(selector.as_str())
672 .rsplit_once(['.', '/'])
673 else {
674 return keys;
675 };
676 let parent = parent
677 .split(['.', '/'])
678 .filter(|segment| !segment.is_empty())
679 .map(normalize_var_segment)
680 .filter(|segment| !segment.is_empty())
681 .collect::<Vec<_>>()
682 .join(".");
683
684 if let Some(stripped) = doc.id.strip_prefix("nenjo.") {
685 let id_segments = stripped
686 .split('.')
687 .map(normalize_var_segment)
688 .filter(|segment| !segment.is_empty())
689 .collect::<Vec<_>>();
690 if id_segments.len() >= 2
691 && id_segments
692 .first()
693 .is_some_and(|segment| segment == &parent)
694 {
695 let basename = id_segments[1..].join("_");
696 keys.push(format!("{pack_prefix}.{parent}.nenjo_{basename}"));
697 }
698 }
699
700 keys
701}
702
703fn normalize_var_segment(segment: &str) -> String {
704 let mut normalized = String::new();
705 let mut last_was_underscore = false;
706 for ch in segment.chars() {
707 let ch = ch.to_ascii_lowercase();
708 if ch.is_ascii_alphanumeric() {
709 normalized.push(ch);
710 last_was_underscore = false;
711 } else if !last_was_underscore {
712 normalized.push('_');
713 last_was_underscore = true;
714 }
715 }
716 normalized.trim_matches('_').to_string()
717}
718
719#[derive(Debug, Serialize)]
720#[serde(rename = "knowledge_doc")]
721struct KnowledgeDocMetadataContext<'a> {
722 #[serde(rename = "@pack")]
723 pack: &'a str,
724 #[serde(rename = "@selector")]
725 selector: &'a str,
726 #[serde(rename = "@title")]
727 title: &'a str,
728 #[serde(rename = "@kind")]
729 kind: &'a str,
730 summary: &'a str,
731 #[serde(skip_serializing_if = "Vec::is_empty", default)]
732 tags: Vec<&'a str>,
733 #[serde(rename = "related", skip_serializing_if = "Vec::is_empty", default)]
734 related: Vec<KnowledgeDocumentRelatedSummaryContext<'a>>,
735}
736
737fn doc_metadata(knowledge_ref: &KnowledgeRef, doc: &KnowledgeDocManifest) -> String {
738 let selector = prompt_doc_selector(doc);
739 let pack = knowledge_ref.selector();
740 let ctx = KnowledgeDocMetadataContext {
741 pack: &pack,
742 selector: &selector,
743 title: &doc.title,
744 summary: &doc.summary,
745 kind: doc.kind.as_str(),
746 tags: doc.tags.iter().map(String::as_str).collect(),
747 related: doc
748 .related
749 .iter()
750 .map(|edge| KnowledgeDocumentRelatedSummaryContext {
751 edge_type: edge.edge_type.as_str(),
752 target: edge.target.as_str(),
753 })
754 .collect(),
755 };
756 nenjo_xml::to_xml_pretty(&ctx, 2)
757}
758
759fn prompt_doc_selector(doc: &KnowledgeDocManifest) -> String {
760 if doc.selector.starts_with("library://") {
761 doc.selector
762 .splitn(4, '/')
763 .nth(3)
764 .unwrap_or(&doc.selector)
765 .to_string()
766 } else {
767 doc.selector.clone()
768 }
769}
770
771fn pack_schema() -> serde_json::Value {
772 json!({
773 "type": "string",
774 "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>."
775 })
776}
777
778fn knowledge_filter_schema(
779 extra_properties: Option<serde_json::Value>,
780 required: &[&str],
781) -> serde_json::Value {
782 let mut properties = json!({
783 "pack": pack_schema(),
784 "tags": {
785 "type": "array",
786 "items": { "type": "string" },
787 "description": "Optional tags that all returned docs must have"
788 },
789 "kind": {
790 "type": "string",
791 "description": "Optional kind filter such as guide or reference"
792 },
793 "selector_prefix": {
794 "type": "string",
795 "description": "Optional virtual or pack-relative selector prefix"
796 },
797 "related_to": {
798 "type": "string",
799 "description": "Optional selector of a document this result must be related to"
800 },
801 "edge_type": {
802 "type": "string",
803 "description": "Optional relationship type used with related_to or neighbors"
804 }
805 });
806
807 if let Some(extra) = extra_properties
808 && let Some(map) = properties.as_object_mut()
809 && let Some(extra_map) = extra.as_object()
810 {
811 for (key, value) in extra_map {
812 map.insert(key.clone(), value.clone());
813 }
814 }
815
816 json!({
817 "type": "object",
818 "properties": properties,
819 "required": required,
820 "additionalProperties": false
821 })
822}
823
824fn knowledge_lookup_schema() -> serde_json::Value {
825 json!({
826 "type": "object",
827 "properties": {
828 "pack": pack_schema(),
829 "selector": {
830 "type": "string",
831 "description": "Document selector or id within the selected pack"
832 }
833 },
834 "required": ["pack", "selector"],
835 "additionalProperties": false
836 })
837}
838
839pub fn knowledge_tools() -> Vec<ToolSpec> {
840 vec![
841 ToolSpec {
842 name: "list_knowledge_packs".into(),
843 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(),
844 parameters: json!({
845 "type": "object",
846 "properties": {},
847 "additionalProperties": false
848 }),
849 category: ToolCategory::Read,
850 },
851 ToolSpec {
852 name: "read_knowledge_doc".into(),
853 description: "Read one full document body from a knowledge pack by path.".into(),
854 parameters: knowledge_lookup_schema(),
855 category: ToolCategory::Read,
856 },
857 ToolSpec {
858 name: "search_knowledge".into(),
859 description: "Search a knowledge pack and return candidate document metadata without loading document bodies.".into(),
860 parameters: knowledge_filter_schema(
861 Some(json!({
862 "query": {
863 "type": "string",
864 "description": "Search query, path, title, tag, or summary"
865 }
866 })),
867 &["pack", "query"],
868 ),
869 category: ToolCategory::Read,
870 },
871 ToolSpec {
872 name: "list_knowledge_neighbors".into(),
873 description: "List outbound graph neighbors for one document in a knowledge pack.".into(),
874 parameters: json!({
875 "type": "object",
876 "properties": {
877 "pack": pack_schema(),
878 "selector": {
879 "type": "string",
880 "description": "Document selector or id within the selected pack"
881 },
882 "edge_type": {
883 "type": "string",
884 "description": "Optional relationship type filter such as references or depends_on"
885 }
886 },
887 "required": ["pack", "selector"],
888 "additionalProperties": false
889 }),
890 category: ToolCategory::Read,
891 },
892 ]
893}
894
895pub fn knowledge_toolbelt(registry: Arc<dyn KnowledgeRegistry>) -> Vec<Arc<dyn Tool>> {
896 knowledge_tools()
897 .into_iter()
898 .map(|spec| Arc::new(KnowledgeTool::new(spec, registry.clone())) as Arc<dyn Tool>)
899 .collect()
900}
901
902struct KnowledgeTool {
903 spec: ToolSpec,
904 registry: Arc<dyn KnowledgeRegistry>,
905}
906
907impl KnowledgeTool {
908 fn new(spec: ToolSpec, registry: Arc<dyn KnowledgeRegistry>) -> Self {
909 Self { spec, registry }
910 }
911}
912
913#[async_trait]
914impl Tool for KnowledgeTool {
915 fn name(&self) -> &str {
916 &self.spec.name
917 }
918
919 fn description(&self) -> &str {
920 &self.spec.description
921 }
922
923 fn parameters_schema(&self) -> serde_json::Value {
924 self.spec.parameters.clone()
925 }
926
927 fn category(&self) -> ToolCategory {
928 self.spec.category
929 }
930
931 fn origin(&self) -> ToolOrigin {
932 ToolOrigin::Platform
933 }
934
935 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
936 let output = match self.name() {
937 "list_knowledge_packs" => serde_json::to_value(self.registry.list_packs().await?)?,
938 "read_knowledge_doc" => {
939 let args: KnowledgeReadArgs = serde_json::from_value(args)?;
940 let pack = self.registry.resolve_pack(&args.pack).await?;
941 let doc = pack.read_doc(&args.selector).ok_or_else(|| {
942 anyhow!(
943 "knowledge document '{}' not found in pack '{}'",
944 args.selector,
945 args.pack
946 )
947 })?;
948 serde_json::to_value(KnowledgeDocReadResult {
949 document: knowledge_document_metadata(args.pack, &doc.manifest),
950 content: doc.content,
951 })?
952 }
953 "search_knowledge" => {
954 let args: KnowledgeSearchArgs = serde_json::from_value(args)?;
955 let pack = self.registry.resolve_pack(&args.pack).await?;
956 let filter = knowledge_filter(args.filter)?;
957 let hits = pack
958 .search(&args.query, filter)
959 .into_iter()
960 .map(|hit| knowledge_search_result(args.pack.clone(), hit))
961 .collect::<Vec<_>>();
962 serde_json::to_value(hits)?
963 }
964 "list_knowledge_neighbors" => {
965 let args: KnowledgeNeighborArgs = serde_json::from_value(args)?;
966 let pack = self.registry.resolve_pack(&args.pack).await?;
967 let edge_type = parse_knowledge_enum(args.edge_type)?;
968 let neighbors = pack.neighbors(&args.selector, edge_type).ok_or_else(|| {
969 anyhow!(
970 "knowledge document '{}' not found in pack '{}'",
971 args.selector,
972 args.pack
973 )
974 })?;
975 serde_json::to_value(knowledge_neighbors_result(args.pack, neighbors))?
976 }
977 name => return Err(anyhow!("unknown knowledge tool '{name}'")),
978 };
979
980 Ok(ToolResult {
981 success: true,
982 output: serde_json::to_string_pretty(&output)?,
983 error: None,
984 })
985 }
986}
987
988#[cfg(test)]
989mod tests {
990 use std::borrow::Cow;
991 use std::future::Future;
992 use std::task::{Context, Poll, Waker};
993
994 use super::{
995 CompositeKnowledgeRegistry, KnowledgeDocReadResult, KnowledgePackEntry, KnowledgeRef,
996 KnowledgeRegistry, knowledge_document_var_key, knowledge_neighbors_result,
997 knowledge_search_result, knowledge_tools,
998 };
999 use crate::{
1000 KnowledgeDocEdge, KnowledgeDocEdgeType, KnowledgeDocKind, KnowledgeDocManifest,
1001 KnowledgePack, KnowledgePackManifest, KnowledgePackManifestData,
1002 };
1003 use serde_json::json;
1004
1005 struct TestPack {
1006 manifest: KnowledgePackManifestData,
1007 }
1008
1009 impl KnowledgePack for TestPack {
1010 fn manifest(&self) -> &dyn KnowledgePackManifest {
1011 &self.manifest
1012 }
1013
1014 fn doc_content(&self, manifest: &KnowledgeDocManifest) -> Option<Cow<'_, str>> {
1015 Some(Cow::Owned(format!("body for {}", manifest.title)))
1016 }
1017 }
1018
1019 fn block_on<F: Future>(future: F) -> F::Output {
1020 let waker = Waker::noop();
1021 let mut context = Context::from_waker(waker);
1022 let mut future = Box::pin(future);
1023 match future.as_mut().poll(&mut context) {
1024 Poll::Ready(output) => output,
1025 Poll::Pending => panic!("test future unexpectedly yielded"),
1026 }
1027 }
1028
1029 fn test_doc(
1030 id: &str,
1031 path: &str,
1032 title: &str,
1033 related: Vec<KnowledgeDocEdge>,
1034 ) -> KnowledgeDocManifest {
1035 KnowledgeDocManifest {
1036 id: id.into(),
1037 selector: path.into(),
1038 source_path: path.trim_start_matches("library://test/").into(),
1039 title: title.into(),
1040 summary: format!("{title} summary"),
1041 kind: KnowledgeDocKind::new("routing-guide"),
1042 tags: vec!["core".into()],
1043 related,
1044 updated_at: String::new(),
1045 }
1046 }
1047
1048 fn test_pack() -> TestPack {
1049 TestPack {
1050 manifest: KnowledgePackManifestData {
1051 pack_id: "test".into(),
1052 version: "1".into(),
1053 schema_version: 1,
1054 root_uri: "library://test/".into(),
1055 content_hash: String::new(),
1056 docs: vec![
1057 test_doc(
1058 "root",
1059 "library://test/root.md",
1060 "Root",
1061 vec![KnowledgeDocEdge {
1062 edge_type: KnowledgeDocEdgeType::DependsOn,
1063 target: "library://test/leaf.md".into(),
1064 description: Some("root to leaf".into()),
1065 }],
1066 ),
1067 test_doc(
1068 "leaf",
1069 "library://test/leaf.md",
1070 "Leaf",
1071 vec![KnowledgeDocEdge {
1072 edge_type: KnowledgeDocEdgeType::References,
1073 target: "library://test/root.md".into(),
1074 description: Some("reverse edge".into()),
1075 }],
1076 ),
1077 ],
1078 },
1079 }
1080 }
1081
1082 #[test]
1083 fn composite_registry_routes_builtin_knowledge_namespaces() {
1084 block_on(async {
1085 let registry = CompositeKnowledgeRegistry::new()
1086 .with_entry(KnowledgePackEntry::library("docs", test_pack()).unwrap())
1087 .with_entry(KnowledgePackEntry::package("nenjo/core", test_pack()).unwrap())
1088 .with_entry(KnowledgePackEntry::local("scratch", test_pack()).unwrap());
1089
1090 let packs = registry.list_packs().await.unwrap();
1091 let selectors = packs
1092 .iter()
1093 .map(|pack| pack.selector.as_str())
1094 .collect::<Vec<_>>();
1095 assert_eq!(
1096 selectors,
1097 vec!["lib:docs", "local:scratch", "pkg:nenjo.core"]
1098 );
1099
1100 assert_eq!(
1101 registry
1102 .resolve_pack("lib:docs")
1103 .await
1104 .unwrap()
1105 .manifest()
1106 .pack_id(),
1107 "test"
1108 );
1109 assert!(registry.resolve_pack("pkg:nenjo.core").await.is_ok());
1110 assert!(registry.resolve_pack("local:scratch").await.is_ok());
1111 });
1112 }
1113
1114 #[test]
1115 fn default_knowledge_tool_registry_exposes_graph_first_tools_only() {
1116 let names = knowledge_tools()
1117 .into_iter()
1118 .map(|tool| tool.name)
1119 .collect::<Vec<_>>();
1120
1121 assert_eq!(
1122 names,
1123 vec![
1124 "list_knowledge_packs",
1125 "read_knowledge_doc",
1126 "search_knowledge",
1127 "list_knowledge_neighbors",
1128 ]
1129 );
1130 }
1131
1132 #[test]
1133 fn knowledge_pack_summary_returns_selector_for_tool_calls() {
1134 let pack = test_pack();
1135 let summary = super::KnowledgePackSummary::new(
1136 "pkg:nenjo-ai.packages.knowledge.core",
1137 pack.manifest(),
1138 );
1139
1140 assert_eq!(summary.selector, "pkg:nenjo-ai.packages.knowledge.core");
1141 assert_eq!(summary.pack, summary.selector);
1142 }
1143
1144 #[test]
1145 fn pack_prompt_summary_includes_compact_related_edges() {
1146 let pack = TestPack {
1147 manifest: KnowledgePackManifestData {
1148 pack_id: "test".into(),
1149 version: "1".into(),
1150 schema_version: 1,
1151 root_uri: "file:///tmp/test/".into(),
1152 content_hash: String::new(),
1153 docs: vec![
1154 test_doc(
1155 "root",
1156 "docs/root.md",
1157 "Root",
1158 vec![KnowledgeDocEdge {
1159 edge_type: KnowledgeDocEdgeType::DependsOn,
1160 target: "docs/leaf.md".into(),
1161 description: Some("root to leaf".into()),
1162 }],
1163 ),
1164 test_doc(
1165 "leaf",
1166 "docs/leaf.md",
1167 "Leaf",
1168 vec![KnowledgeDocEdge {
1169 edge_type: KnowledgeDocEdgeType::References,
1170 target: "docs/root.md".into(),
1171 description: Some("reverse edge".into()),
1172 }],
1173 ),
1174 ],
1175 },
1176 };
1177 let knowledge_ref = KnowledgeRef::local("test").unwrap();
1178 let summary = super::knowledge_pack_summary(&knowledge_ref, &pack);
1179
1180 assert!(summary.contains(r#"selector="local:test""#));
1181 assert!(summary.contains(r#"<related type="depends_on" target="docs/leaf.md""#));
1182 assert!(summary.contains(r#"<related type="references" target="docs/root.md""#));
1183 assert!(!summary.contains("root to leaf"));
1184 assert!(!summary.contains("reverse edge"));
1185 }
1186
1187 #[test]
1188 fn document_metadata_prompt_var_includes_related_edges() {
1189 let doc = test_doc(
1190 "root",
1191 "docs/root.md",
1192 "Root",
1193 vec![KnowledgeDocEdge {
1194 edge_type: KnowledgeDocEdgeType::DependsOn,
1195 target: "docs/leaf.md".into(),
1196 description: Some("root to leaf".into()),
1197 }],
1198 );
1199 let knowledge_ref = KnowledgeRef::local("test").unwrap();
1200 let metadata = super::doc_metadata(&knowledge_ref, &doc);
1201
1202 assert!(metadata.contains(r#"pack="local:test""#));
1203 assert!(metadata.contains(r#"selector="docs/root.md""#));
1204 assert!(metadata.contains(r#"<related type="depends_on" target="docs/leaf.md""#));
1205 assert!(!metadata.contains("root to leaf"));
1206 }
1207
1208 #[test]
1209 fn neighbor_traversal_returns_outbound_edges_with_slim_target_metadata() {
1210 let pack = test_pack();
1211 let result = pack
1212 .neighbors("root", None)
1213 .map(|neighbors| knowledge_neighbors_result("lib:test", neighbors))
1214 .expect("root neighbors");
1215 let value = serde_json::to_value(result).unwrap();
1216
1217 assert_eq!(value["document"]["selector"], "library://test/root.md");
1218 assert_eq!(value["document"]["related"][0]["type"], "depends_on");
1219 assert_eq!(
1220 value["document"]["related"][0]["target"],
1221 "library://test/leaf.md"
1222 );
1223 assert_eq!(value["edges"].as_array().unwrap().len(), 1);
1224 assert_eq!(value["edges"][0]["type"], "depends_on");
1225 assert_eq!(
1226 value["edges"][0]["target"]["selector"],
1227 "library://test/leaf.md"
1228 );
1229 assert_eq!(value["edges"][0]["target"]["kind"], "routing_guide");
1230 assert!(value["edges"][0]["target"].get("source_path").is_none());
1231 assert!(value["edges"][0].get("note").is_none());
1232 }
1233
1234 #[test]
1235 fn search_returns_slim_metadata_without_content() {
1236 let pack = test_pack();
1237 let value = serde_json::to_value(
1238 pack.search("Leaf", Default::default())
1239 .into_iter()
1240 .map(|hit| knowledge_search_result("lib:test", hit))
1241 .collect::<Vec<_>>(),
1242 )
1243 .unwrap();
1244
1245 assert_eq!(value[0]["document"]["selector"], "library://test/leaf.md");
1246 assert_eq!(value[0]["document"]["related"][0]["type"], "references");
1247 assert_eq!(
1248 value[0]["document"]["related"][0]["target"],
1249 "library://test/root.md"
1250 );
1251 assert!(
1252 value[0]["matched"]
1253 .as_array()
1254 .unwrap()
1255 .contains(&json!("title"))
1256 );
1257 assert!(
1258 !value[0]["matched"]
1259 .as_array()
1260 .unwrap()
1261 .contains(&json!("content"))
1262 );
1263 assert!(value[0].get("content").is_none());
1264 assert!(value[0]["document"].get("aliases").is_none());
1265 }
1266
1267 #[test]
1268 fn read_knowledge_doc_result_keeps_full_content_explicit() {
1269 let pack = test_pack();
1270 let doc = pack.read_doc("leaf").expect("leaf doc");
1271 let value = serde_json::to_value(KnowledgeDocReadResult {
1272 document: super::knowledge_document_metadata("lib:test", &doc.manifest),
1273 content: doc.content,
1274 })
1275 .unwrap();
1276
1277 assert_eq!(value["document"]["pack"], "lib:test");
1278 assert_eq!(value["document"]["selector"], "library://test/leaf.md");
1279 assert_eq!(
1280 value["document"]["related"][0]["target"],
1281 "library://test/root.md"
1282 );
1283 assert_eq!(value["content"], "body for Leaf");
1284 }
1285
1286 #[test]
1287 fn library_knowledge_uses_lib_template_namespace() {
1288 let knowledge_ref = KnowledgeRef::library("product-docs").unwrap();
1289 assert_eq!(knowledge_ref.selector(), "lib:product-docs");
1290 assert_eq!(knowledge_ref.prompt_prefix(), "lib.product_docs");
1291 }
1292
1293 #[test]
1294 fn pkg_knowledge_uses_package_template_namespace() {
1295 let knowledge_ref = KnowledgeRef::package("@nenjo/core").unwrap();
1296 assert_eq!(knowledge_ref.selector(), "pkg:nenjo.core");
1297 assert_eq!(knowledge_ref.prompt_prefix(), "pkg.nenjo.core");
1298 }
1299
1300 #[test]
1301 fn pkg_knowledge_document_vars_use_package_relative_paths() {
1302 let knowledge_ref = KnowledgeRef::package("nenjo.core").unwrap();
1303 let doc = KnowledgeDocManifest {
1304 id: "nenjo.resources.agents".into(),
1305 selector: "resources.agents".into(),
1306 source_path: "docs/resources/agents.md".into(),
1307 title: "Agents".into(),
1308 summary: String::new(),
1309 kind: KnowledgeDocKind::new("guide"),
1310 tags: Vec::new(),
1311 related: Vec::new(),
1312 updated_at: String::new(),
1313 };
1314
1315 assert_eq!(
1316 knowledge_document_var_key(&knowledge_ref, &doc),
1317 "pkg.nenjo.core.resources.agents"
1318 );
1319 }
1320}