1use std::collections::HashMap;
8use std::sync::Arc;
9
10use anyhow::{Context, Result, anyhow};
11use async_trait::async_trait;
12use nenjo_tool_api::{Tool, ToolCategory, ToolResult, ToolSpec};
13use serde::{Deserialize, Serialize};
14use serde_json::json;
15
16use crate::{
17 KnowledgeDocAuthority, KnowledgeDocFilter, KnowledgeDocKind, KnowledgeDocManifest,
18 KnowledgeDocSearchHit, KnowledgeDocStatus, KnowledgePack, KnowledgePackManifest,
19};
20
21#[async_trait]
22pub trait KnowledgeRegistry: Send + Sync {
23 async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>>;
24 async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>>;
25}
26
27#[derive(Clone)]
28pub struct KnowledgePackEntry {
29 selector: String,
30 pack: Arc<dyn KnowledgePack>,
31}
32
33impl KnowledgePackEntry {
34 pub fn new(selector: impl Into<String>, pack: impl KnowledgePack + 'static) -> Self {
35 Self {
36 selector: selector.into(),
37 pack: Arc::new(pack),
38 }
39 }
40
41 pub fn selector(&self) -> &str {
42 &self.selector
43 }
44
45 pub fn pack(&self) -> &Arc<dyn KnowledgePack> {
46 &self.pack
47 }
48
49 fn into_parts(self) -> (String, Arc<dyn KnowledgePack>) {
50 (self.selector, self.pack)
51 }
52}
53
54impl<P> From<(&str, P)> for KnowledgePackEntry
55where
56 P: KnowledgePack + 'static,
57{
58 fn from((selector, pack): (&str, P)) -> Self {
59 Self::new(selector, pack)
60 }
61}
62
63impl<P> From<(String, P)> for KnowledgePackEntry
64where
65 P: KnowledgePack + 'static,
66{
67 fn from((selector, pack): (String, P)) -> Self {
68 Self::new(selector, pack)
69 }
70}
71
72#[derive(Clone, Default)]
73pub struct StaticKnowledgeRegistry {
74 packs: Arc<HashMap<String, Arc<dyn KnowledgePack>>>,
75}
76
77impl StaticKnowledgeRegistry {
78 pub fn new() -> Self {
79 Self::default()
80 }
81
82 pub fn with_pack(mut self, selector: impl Into<String>, pack: Arc<dyn KnowledgePack>) -> Self {
83 Arc::make_mut(&mut self.packs).insert(selector.into(), pack);
84 self
85 }
86
87 pub fn with_entry(self, entry: KnowledgePackEntry) -> Self {
88 let (selector, pack) = entry.into_parts();
89 self.with_pack(selector, pack)
90 }
91
92 pub fn with_entries(mut self, entries: impl IntoIterator<Item = KnowledgePackEntry>) -> Self {
93 for entry in entries {
94 self = self.with_entry(entry);
95 }
96 self
97 }
98
99 pub fn is_empty(&self) -> bool {
100 self.packs.is_empty()
101 }
102}
103
104#[async_trait]
105impl KnowledgeRegistry for StaticKnowledgeRegistry {
106 async fn list_packs(&self) -> Result<Vec<KnowledgePackSummary>> {
107 let mut packs = self
108 .packs
109 .iter()
110 .map(|(selector, pack)| KnowledgePackSummary::new(selector, pack.manifest()))
111 .collect::<Vec<_>>();
112 packs.sort_by(|a, b| a.pack.cmp(&b.pack));
113 Ok(packs)
114 }
115
116 async fn resolve_pack(&self, selector: &str) -> Result<Arc<dyn KnowledgePack>> {
117 self.packs
118 .get(selector)
119 .cloned()
120 .ok_or_else(|| anyhow!("unknown knowledge pack '{selector}'"))
121 }
122}
123
124#[derive(Debug, Clone, Serialize)]
125pub struct KnowledgePackSummary {
126 pub pack: String,
127 pub pack_id: String,
128 pub pack_version: String,
129 pub root_uri: String,
130 pub document_count: usize,
131}
132
133impl KnowledgePackSummary {
134 pub fn new(pack: impl Into<String>, manifest: &dyn KnowledgePackManifest) -> Self {
135 Self {
136 pack: pack.into(),
137 pack_id: manifest.pack_id().to_string(),
138 pack_version: manifest.pack_version().to_string(),
139 root_uri: manifest.root_uri().to_string(),
140 document_count: manifest.docs().len(),
141 }
142 }
143}
144
145#[derive(Debug, Clone, Deserialize)]
146pub struct KnowledgeListArgs {
147 pub pack: String,
148 #[serde(flatten)]
149 pub filter: KnowledgeFilterArgs,
150}
151
152#[derive(Debug, Clone, Deserialize)]
153pub struct KnowledgeReadArgs {
154 pub pack: String,
155 pub path: String,
156}
157
158#[derive(Debug, Clone, Deserialize)]
159pub struct KnowledgeSearchArgs {
160 pub pack: String,
161 pub query: String,
162 #[serde(flatten)]
163 pub filter: KnowledgeFilterArgs,
164}
165
166#[derive(Debug, Clone, Deserialize)]
167pub struct KnowledgeTreeArgs {
168 pub pack: String,
169 pub prefix: Option<String>,
170}
171
172#[derive(Debug, Clone, Deserialize)]
173pub struct KnowledgeNeighborArgs {
174 pub pack: String,
175 pub path: String,
176 pub edge_type: Option<String>,
177}
178
179#[derive(Debug, Clone, Default, Deserialize)]
180pub struct KnowledgeFilterArgs {
181 #[serde(default)]
182 pub tags: Vec<String>,
183 pub kind: Option<String>,
184 pub authority: Option<String>,
185 pub status: Option<String>,
186 pub path_prefix: Option<String>,
187 pub related_to: Option<String>,
188 pub edge_type: Option<String>,
189}
190
191#[derive(Debug, Clone, Serialize)]
192pub struct KnowledgeDocManifestResult {
193 pub id: String,
194 pub pack: String,
195 pub virtual_path: String,
196 pub source_path: String,
197 pub title: String,
198 pub summary: String,
199 #[serde(skip_serializing_if = "Option::is_none")]
200 pub description: Option<String>,
201 pub kind: String,
202 pub authority: String,
203 pub status: String,
204 pub tags: Vec<String>,
205 pub aliases: Vec<String>,
206 pub keywords: Vec<String>,
207}
208
209#[derive(Debug, Clone, Serialize)]
210pub struct KnowledgeDocReadResult {
211 pub manifest: KnowledgeDocManifestResult,
212 pub content: String,
213}
214
215#[derive(Debug, Clone, Serialize)]
216pub struct KnowledgeDocSearchResult {
217 pub id: String,
218 pub pack: String,
219 pub virtual_path: String,
220 pub title: String,
221 pub summary: String,
222 pub kind: String,
223 pub authority: String,
224 pub tags: Vec<String>,
225 pub score: usize,
226 pub matched: Vec<String>,
227 #[serde(skip_serializing_if = "Option::is_none")]
228 pub content: Option<String>,
229}
230
231pub fn knowledge_filter(filter: KnowledgeFilterArgs) -> Result<KnowledgeDocFilter> {
232 Ok(KnowledgeDocFilter {
233 tags: filter.tags,
234 kind: parse_knowledge_enum(filter.kind)?,
235 authority: parse_knowledge_enum(filter.authority)?,
236 status: parse_knowledge_enum(filter.status)?,
237 path_prefix: filter.path_prefix,
238 related_to: filter.related_to,
239 edge_type: parse_knowledge_enum(filter.edge_type)?,
240 })
241}
242
243pub fn parse_knowledge_enum<T>(value: Option<String>) -> Result<Option<T>>
244where
245 T: serde::de::DeserializeOwned,
246{
247 value
248 .map(|value| {
249 serde_json::from_value(serde_json::Value::String(value.to_lowercase()))
250 .with_context(|| "invalid knowledge filter value")
251 })
252 .transpose()
253}
254
255pub fn knowledge_manifest_result(
256 pack: &str,
257 doc: &KnowledgeDocManifest,
258) -> KnowledgeDocManifestResult {
259 KnowledgeDocManifestResult {
260 id: doc.id.clone(),
261 pack: pack.to_string(),
262 virtual_path: doc.virtual_path.clone(),
263 source_path: doc.source_path.clone(),
264 title: doc.title.clone(),
265 summary: doc.summary.clone(),
266 description: doc.description.clone(),
267 kind: doc.kind.as_str().to_string(),
268 authority: doc.authority.as_str().to_string(),
269 status: doc.status.as_str().to_string(),
270 tags: doc.tags.clone(),
271 aliases: doc.aliases.clone(),
272 keywords: doc.keywords.clone(),
273 }
274}
275
276pub fn knowledge_search_result(pack: &str, hit: KnowledgeDocSearchHit) -> KnowledgeDocSearchResult {
277 KnowledgeDocSearchResult {
278 id: hit.id,
279 pack: pack.to_string(),
280 virtual_path: hit.virtual_path,
281 title: hit.title,
282 summary: hit.summary,
283 kind: hit.kind.as_str().to_string(),
284 authority: hit.authority.as_str().to_string(),
285 tags: hit.tags,
286 score: hit.score,
287 matched: hit.matched,
288 content: hit.content,
289 }
290}
291
292pub fn knowledge_document_metadata_vars(
293 pack_prefix: &str,
294 pack: &dyn KnowledgePack,
295) -> HashMap<String, String> {
296 let mut vars = HashMap::new();
297 for doc in pack.manifest().docs() {
298 let metadata = doc_metadata(doc);
299 vars.insert(
300 knowledge_document_var_key(pack_prefix, doc),
301 metadata.clone(),
302 );
303 for key in knowledge_document_alias_var_keys(pack_prefix, doc) {
304 vars.entry(key).or_insert_with(|| metadata.clone());
305 }
306 }
307 vars
308}
309
310pub fn knowledge_pack_prompt_vars(
311 selector: &str,
312 pack: &dyn KnowledgePack,
313) -> HashMap<String, String> {
314 let prefix = knowledge_pack_var_prefix(selector);
315 let mut vars = HashMap::new();
316 vars.insert(prefix.clone(), knowledge_pack_summary(selector, pack));
317 vars.extend(knowledge_document_metadata_vars(&prefix, pack));
318 vars
319}
320
321pub fn knowledge_pack_var_prefix(selector: &str) -> String {
322 if let Some(slug) = selector.strip_prefix("lib:") {
323 format!("lib.{}", normalize_var_segment(slug))
324 } else if selector == "lib" {
325 "lib".to_string()
326 } else if let Some(git_selector) = selector.strip_prefix("git://") {
327 let segments = git_selector
328 .split('/')
329 .map(normalize_var_segment)
330 .filter(|segment| !segment.is_empty())
331 .collect::<Vec<_>>();
332 if segments.is_empty() {
333 "git".to_string()
334 } else {
335 format!("git.{}", segments.join("."))
336 }
337 } else {
338 selector.replace(':', ".").replace('-', "_")
339 }
340}
341
342pub fn knowledge_pack_summary(selector: &str, pack: &dyn KnowledgePack) -> String {
343 let manifest = pack.manifest();
344 let mut source_name = selector.splitn(2, ':');
345 let source = source_name.next().unwrap_or(selector);
346 let name = source_name.next().unwrap_or(manifest.pack_id());
347 let ctx = KnowledgePackSummaryContext {
348 source,
349 name,
350 root: manifest.root_uri(),
351 usage: "Use the knowledge tools to search, inspect metadata, expand graph neighbors, and read documents from this pack when relevant.",
352 docs: manifest
353 .docs()
354 .iter()
355 .map(|doc| KnowledgeDocumentSummaryContext {
356 path: doc.virtual_path.as_str(),
357 id: doc.id.as_str(),
358 kind: doc.kind.as_str(),
359 title: doc.title.as_str(),
360 summary: doc.summary.as_str(),
361 })
362 .collect(),
363 };
364
365 nenjo_xml::to_xml_pretty(&ctx, 2)
366}
367
368#[derive(Debug, Serialize)]
369#[serde(rename = "knowledge_pack")]
370struct KnowledgePackSummaryContext<'a> {
371 #[serde(rename = "@source")]
372 source: &'a str,
373 #[serde(rename = "@name")]
374 name: &'a str,
375 #[serde(rename = "@root")]
376 root: &'a str,
377 usage: &'a str,
378 #[serde(rename = "doc")]
379 docs: Vec<KnowledgeDocumentSummaryContext<'a>>,
380}
381
382#[derive(Debug, Serialize)]
383#[serde(rename = "doc")]
384struct KnowledgeDocumentSummaryContext<'a> {
385 #[serde(rename = "@path")]
386 path: &'a str,
387 #[serde(rename = "@id")]
388 id: &'a str,
389 #[serde(rename = "@kind")]
390 kind: &'a str,
391 title: &'a str,
392 summary: &'a str,
393}
394
395pub fn knowledge_document_var_key(pack_prefix: &str, doc: &KnowledgeDocManifest) -> String {
396 let relative = pack_relative_path(pack_prefix, doc)
397 .unwrap_or(doc.virtual_path.as_str())
398 .trim_matches('/');
399 let path = relative
400 .strip_suffix(".md")
401 .unwrap_or(relative)
402 .split('/')
403 .filter(|segment| !segment.is_empty())
404 .map(normalize_var_segment)
405 .filter(|segment| !segment.is_empty())
406 .collect::<Vec<_>>()
407 .join(".");
408 if path.is_empty() {
409 pack_prefix.to_string()
410 } else {
411 format!("{pack_prefix}.{path}")
412 }
413}
414
415fn knowledge_document_alias_var_keys(pack_prefix: &str, doc: &KnowledgeDocManifest) -> Vec<String> {
416 let mut keys = Vec::new();
417 let Some(relative) = pack_relative_path(pack_prefix, doc) else {
418 return keys;
419 };
420 let Some((parent, _leaf)) = relative
421 .strip_suffix(".md")
422 .unwrap_or(relative)
423 .rsplit_once('/')
424 else {
425 return keys;
426 };
427 let parent = parent
428 .split('/')
429 .filter(|segment| !segment.is_empty())
430 .map(normalize_var_segment)
431 .filter(|segment| !segment.is_empty())
432 .collect::<Vec<_>>()
433 .join(".");
434
435 if let Some(stripped) = doc.id.strip_prefix("nenjo.") {
436 let id_segments = stripped
437 .split('.')
438 .map(normalize_var_segment)
439 .filter(|segment| !segment.is_empty())
440 .collect::<Vec<_>>();
441 if id_segments.len() >= 2
442 && id_segments
443 .first()
444 .is_some_and(|segment| segment == &parent)
445 {
446 let basename = id_segments[1..].join("_");
447 keys.push(format!("{pack_prefix}.{parent}.nenjo_{basename}"));
448 }
449 }
450
451 keys
452}
453
454fn pack_relative_path<'a>(pack_prefix: &str, doc: &'a KnowledgeDocManifest) -> Option<&'a str> {
455 match pack_prefix {
456 "lib" => doc
457 .virtual_path
458 .strip_prefix("library://")
459 .and_then(|rest| rest.split_once('/').map(|(_, path)| path)),
460 _ if pack_prefix.starts_with("lib.") => {
461 let slug = pack_prefix.trim_start_matches("lib.");
462 let prefix = format!("library://{slug}/");
463 doc.virtual_path.strip_prefix(&prefix)
464 }
465 _ if pack_prefix.starts_with("git.") => {
466 let mut path = doc.virtual_path.strip_prefix("git://")?;
467 for expected in pack_prefix.trim_start_matches("git.").split('.') {
468 let (segment, rest) = path.split_once('/').unwrap_or((path, ""));
469 if normalize_var_segment(segment) != expected {
470 return None;
471 }
472 path = rest;
473 }
474 Some(path)
475 }
476 _ => None,
477 }
478}
479
480fn normalize_var_segment(segment: &str) -> String {
481 let mut normalized = String::new();
482 let mut last_was_underscore = false;
483 for ch in segment.chars() {
484 let ch = ch.to_ascii_lowercase();
485 if ch.is_ascii_alphanumeric() {
486 normalized.push(ch);
487 last_was_underscore = false;
488 } else if !last_was_underscore {
489 normalized.push('_');
490 last_was_underscore = true;
491 }
492 }
493 normalized.trim_matches('_').to_string()
494}
495
496#[derive(Debug, Serialize)]
497#[serde(rename = "knowledge_doc")]
498struct KnowledgeDocMetadataContext<'a> {
499 #[serde(rename = "@path")]
500 path: &'a str,
501 #[serde(rename = "@title")]
502 title: &'a str,
503 #[serde(rename = "@kind")]
504 kind: KnowledgeDocKind,
505 #[serde(rename = "@authority")]
506 authority: KnowledgeDocAuthority,
507 #[serde(rename = "@status")]
508 status: KnowledgeDocStatus,
509 summary: &'a str,
510 #[serde(skip_serializing_if = "Option::is_none")]
511 description: Option<&'a str>,
512 #[serde(skip_serializing_if = "Vec::is_empty", default)]
513 tags: Vec<&'a str>,
514 #[serde(skip_serializing_if = "Vec::is_empty", default)]
515 aliases: Vec<&'a str>,
516 #[serde(skip_serializing_if = "Vec::is_empty", default)]
517 keywords: Vec<&'a str>,
518}
519
520fn doc_metadata(doc: &KnowledgeDocManifest) -> String {
521 let path = prompt_doc_path(doc);
522 let ctx = KnowledgeDocMetadataContext {
523 path: &path,
524 title: &doc.title,
525 summary: &doc.summary,
526 description: doc.description.as_deref(),
527 kind: doc.kind,
528 authority: doc.authority,
529 status: doc.status,
530 tags: doc.tags.iter().map(String::as_str).collect(),
531 aliases: doc.aliases.iter().map(String::as_str).collect(),
532 keywords: doc.keywords.iter().map(String::as_str).collect(),
533 };
534 nenjo_xml::to_xml_pretty(&ctx, 2)
535}
536
537fn prompt_doc_path(doc: &KnowledgeDocManifest) -> String {
538 if doc.virtual_path.starts_with("library://") {
539 doc.virtual_path
540 .splitn(4, '/')
541 .nth(3)
542 .unwrap_or(&doc.virtual_path)
543 .to_string()
544 } else {
545 doc.virtual_path.clone()
546 }
547}
548
549fn pack_schema() -> serde_json::Value {
550 json!({
551 "type": "string",
552 "description": "Knowledge pack selector such as lib:<pack_slug> or git://owner/repo/package."
553 })
554}
555
556fn knowledge_filter_schema(
557 extra_properties: Option<serde_json::Value>,
558 required: &[&str],
559) -> serde_json::Value {
560 let mut properties = json!({
561 "pack": pack_schema(),
562 "tags": {
563 "type": "array",
564 "items": { "type": "string" },
565 "description": "Optional tags that all returned docs must have"
566 },
567 "kind": {
568 "type": "string",
569 "description": "Optional kind filter such as guide or reference"
570 },
571 "authority": {
572 "type": "string",
573 "description": "Optional authority filter such as canonical, reference, or advisory"
574 },
575 "status": {
576 "type": "string",
577 "description": "Optional status filter such as stable, draft, or deprecated"
578 },
579 "path_prefix": {
580 "type": "string",
581 "description": "Optional virtual or pack-relative path prefix"
582 },
583 "related_to": {
584 "type": "string",
585 "description": "Optional path of a document this result must be related to"
586 },
587 "edge_type": {
588 "type": "string",
589 "description": "Optional relationship type used with related_to or neighbors"
590 }
591 });
592
593 if let Some(extra) = extra_properties
594 && let Some(map) = properties.as_object_mut()
595 && let Some(extra_map) = extra.as_object()
596 {
597 for (key, value) in extra_map {
598 map.insert(key.clone(), value.clone());
599 }
600 }
601
602 json!({
603 "type": "object",
604 "properties": properties,
605 "required": required,
606 "additionalProperties": false
607 })
608}
609
610fn knowledge_lookup_schema() -> serde_json::Value {
611 json!({
612 "type": "object",
613 "properties": {
614 "pack": pack_schema(),
615 "path": {
616 "type": "string",
617 "description": "Document path, id, alias, or virtual path within the selected pack"
618 }
619 },
620 "required": ["pack", "path"],
621 "additionalProperties": false
622 })
623}
624
625pub fn knowledge_tools() -> Vec<ToolSpec> {
626 vec![
627 ToolSpec {
628 name: "list_knowledge_packs".into(),
629 description: "List locally available knowledge packs. Use this before reading or searching knowledge when you need to discover available sources.".into(),
630 parameters: json!({
631 "type": "object",
632 "properties": {},
633 "additionalProperties": false
634 }),
635 category: ToolCategory::Read,
636 },
637 ToolSpec {
638 name: "list_knowledge_docs".into(),
639 description: "List compact document metadata from one knowledge pack without loading document bodies.".into(),
640 parameters: knowledge_filter_schema(None, &["pack"]),
641 category: ToolCategory::Read,
642 },
643 ToolSpec {
644 name: "read_knowledge_doc".into(),
645 description: "Read one full document body from a knowledge pack by path.".into(),
646 parameters: knowledge_lookup_schema(),
647 category: ToolCategory::Read,
648 },
649 ToolSpec {
650 name: "read_knowledge_doc_manifest".into(),
651 description: "Read one document's metadata from a knowledge pack by path without loading the body.".into(),
652 parameters: knowledge_lookup_schema(),
653 category: ToolCategory::Read,
654 },
655 ToolSpec {
656 name: "search_knowledge".into(),
657 description: "Search a knowledge pack and return matches with body content. Use this when you need to inspect or quote matching text.".into(),
658 parameters: knowledge_filter_schema(
659 Some(json!({
660 "query": {
661 "type": "string",
662 "description": "Search query, path, title, tag, summary, or body text"
663 }
664 })),
665 &["pack", "query"],
666 ),
667 category: ToolCategory::Read,
668 },
669 ToolSpec {
670 name: "search_knowledge_paths".into(),
671 description: "Search a knowledge pack using metadata only and return compact results without body content.".into(),
672 parameters: knowledge_filter_schema(
673 Some(json!({
674 "query": {
675 "type": "string",
676 "description": "Search query, path, title, tag, or summary"
677 }
678 })),
679 &["pack", "query"],
680 ),
681 category: ToolCategory::Read,
682 },
683 ToolSpec {
684 name: "list_knowledge_tree".into(),
685 description: "List the document tree for a knowledge pack, optionally under a prefix.".into(),
686 parameters: json!({
687 "type": "object",
688 "properties": {
689 "pack": pack_schema(),
690 "prefix": {
691 "type": "string",
692 "description": "Optional virtual or pack-relative path prefix"
693 }
694 },
695 "required": ["pack"],
696 "additionalProperties": false
697 }),
698 category: ToolCategory::Read,
699 },
700 ToolSpec {
701 name: "list_knowledge_neighbors".into(),
702 description: "List graph neighbors for one document in a knowledge pack.".into(),
703 parameters: json!({
704 "type": "object",
705 "properties": {
706 "pack": pack_schema(),
707 "path": {
708 "type": "string",
709 "description": "Document path, id, alias, or virtual path within the selected pack"
710 },
711 "edge_type": {
712 "type": "string",
713 "description": "Optional relationship type filter such as references or depends_on"
714 }
715 },
716 "required": ["pack", "path"],
717 "additionalProperties": false
718 }),
719 category: ToolCategory::Read,
720 },
721 ]
722}
723
724pub fn knowledge_toolbelt(registry: Arc<dyn KnowledgeRegistry>) -> Vec<Arc<dyn Tool>> {
725 knowledge_tools()
726 .into_iter()
727 .map(|spec| Arc::new(KnowledgeTool::new(spec, registry.clone())) as Arc<dyn Tool>)
728 .collect()
729}
730
731struct KnowledgeTool {
732 spec: ToolSpec,
733 registry: Arc<dyn KnowledgeRegistry>,
734}
735
736impl KnowledgeTool {
737 fn new(spec: ToolSpec, registry: Arc<dyn KnowledgeRegistry>) -> Self {
738 Self { spec, registry }
739 }
740}
741
742#[async_trait]
743impl Tool for KnowledgeTool {
744 fn name(&self) -> &str {
745 &self.spec.name
746 }
747
748 fn description(&self) -> &str {
749 &self.spec.description
750 }
751
752 fn parameters_schema(&self) -> serde_json::Value {
753 self.spec.parameters.clone()
754 }
755
756 fn category(&self) -> ToolCategory {
757 self.spec.category
758 }
759
760 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
761 let output = match self.name() {
762 "list_knowledge_packs" => serde_json::to_value(self.registry.list_packs().await?)?,
763 "list_knowledge_docs" => {
764 let args: KnowledgeListArgs = serde_json::from_value(args)?;
765 let pack = self.registry.resolve_pack(&args.pack).await?;
766 let filter = knowledge_filter(args.filter)?;
767 let docs = pack
768 .list_docs(filter)
769 .into_iter()
770 .map(|doc| knowledge_manifest_result(&args.pack, doc))
771 .collect::<Vec<_>>();
772 serde_json::to_value(docs)?
773 }
774 "read_knowledge_doc" => {
775 let args: KnowledgeReadArgs = serde_json::from_value(args)?;
776 let pack = self.registry.resolve_pack(&args.pack).await?;
777 let doc = pack.read_doc(&args.path).ok_or_else(|| {
778 anyhow!(
779 "knowledge document '{}' not found in pack '{}'",
780 args.path,
781 args.pack
782 )
783 })?;
784 serde_json::to_value(KnowledgeDocReadResult {
785 manifest: knowledge_manifest_result(&args.pack, &doc.manifest),
786 content: doc.content,
787 })?
788 }
789 "read_knowledge_doc_manifest" => {
790 let args: KnowledgeReadArgs = serde_json::from_value(args)?;
791 let pack = self.registry.resolve_pack(&args.pack).await?;
792 let doc = pack.read_manifest(&args.path).ok_or_else(|| {
793 anyhow!(
794 "knowledge document '{}' not found in pack '{}'",
795 args.path,
796 args.pack
797 )
798 })?;
799 serde_json::to_value(knowledge_manifest_result(&args.pack, doc))?
800 }
801 "search_knowledge" => {
802 let args: KnowledgeSearchArgs = serde_json::from_value(args)?;
803 let pack = self.registry.resolve_pack(&args.pack).await?;
804 let filter = knowledge_filter(args.filter)?;
805 let hits = pack
806 .search_docs(&args.query, filter)
807 .into_iter()
808 .map(|hit| knowledge_search_result(&args.pack, hit))
809 .collect::<Vec<_>>();
810 serde_json::to_value(hits)?
811 }
812 "search_knowledge_paths" => {
813 let args: KnowledgeSearchArgs = serde_json::from_value(args)?;
814 let pack = self.registry.resolve_pack(&args.pack).await?;
815 let filter = knowledge_filter(args.filter)?;
816 let hits = pack
817 .search_paths(&args.query, filter)
818 .into_iter()
819 .map(|hit| knowledge_search_result(&args.pack, hit))
820 .collect::<Vec<_>>();
821 serde_json::to_value(hits)?
822 }
823 "list_knowledge_tree" => {
824 let args: KnowledgeTreeArgs = serde_json::from_value(args)?;
825 let pack = self.registry.resolve_pack(&args.pack).await?;
826 serde_json::to_value(pack.list_tree(args.prefix.as_deref()))?
827 }
828 "list_knowledge_neighbors" => {
829 let args: KnowledgeNeighborArgs = serde_json::from_value(args)?;
830 let pack = self.registry.resolve_pack(&args.pack).await?;
831 let edge_type = parse_knowledge_enum(args.edge_type)?;
832 serde_json::to_value(pack.neighbors(&args.path, edge_type))?
833 }
834 name => return Err(anyhow!("unknown knowledge tool '{name}'")),
835 };
836
837 Ok(ToolResult {
838 success: true,
839 output: serde_json::to_string_pretty(&output)?,
840 error: None,
841 })
842 }
843}
844
845#[cfg(test)]
846mod tests {
847 use super::{knowledge_document_var_key, knowledge_pack_var_prefix};
848 use crate::{
849 KnowledgeDocAuthority, KnowledgeDocKind, KnowledgeDocManifest, KnowledgeDocStatus,
850 };
851
852 #[test]
853 fn library_knowledge_uses_lib_template_namespace() {
854 assert_eq!(
855 knowledge_pack_var_prefix("lib:Product Docs"),
856 "lib.product_docs"
857 );
858 assert_eq!(knowledge_pack_var_prefix("lib"), "lib");
859 }
860
861 #[test]
862 fn git_knowledge_uses_owner_qualified_template_namespace() {
863 assert_eq!(
864 knowledge_pack_var_prefix("git://nenjo-ai/packages/nenjo/platform"),
865 "git.nenjo_ai.packages.nenjo.platform"
866 );
867 assert_eq!(
868 knowledge_pack_var_prefix("git://trailofbits/skills-curated/x-research"),
869 "git.trailofbits.skills_curated.x_research"
870 );
871 }
872
873 #[test]
874 fn git_knowledge_document_vars_use_pack_relative_paths() {
875 let doc = KnowledgeDocManifest {
876 id: "nenjo.guide.agents".into(),
877 virtual_path: "git://nenjo-ai/packages/nenjo/platform/guide/agents.md".into(),
878 source_path: "docs/guide/agents.md".into(),
879 title: "Agents".into(),
880 summary: String::new(),
881 description: None,
882 kind: KnowledgeDocKind::Guide,
883 authority: KnowledgeDocAuthority::Canonical,
884 status: KnowledgeDocStatus::Stable,
885 tags: Vec::new(),
886 aliases: Vec::new(),
887 keywords: Vec::new(),
888 related: Vec::new(),
889 size_bytes: 0,
890 updated_at: String::new(),
891 };
892
893 assert_eq!(
894 knowledge_document_var_key("git.nenjo_ai.packages.nenjo.platform", &doc),
895 "git.nenjo_ai.packages.nenjo.platform.guide.agents"
896 );
897 }
898}