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("workspace:") {
323 format!("lib.{}", normalize_var_segment(slug))
324 } else if selector == "workspace" {
325 "lib".to_string()
326 } else {
327 selector.replace(':', ".").replace('-', "_")
328 }
329}
330
331pub fn knowledge_pack_summary(selector: &str, pack: &dyn KnowledgePack) -> String {
332 let manifest = pack.manifest();
333 let mut source_name = selector.splitn(2, ':');
334 let source = source_name.next().unwrap_or(selector);
335 let name = source_name.next().unwrap_or(manifest.pack_id());
336 let ctx = KnowledgePackSummaryContext {
337 source,
338 name,
339 root: manifest.root_uri(),
340 usage: "Use the knowledge tools to search, inspect metadata, expand graph neighbors, and read documents from this pack when relevant.",
341 docs: manifest
342 .docs()
343 .iter()
344 .map(|doc| KnowledgeDocumentSummaryContext {
345 path: doc.virtual_path.as_str(),
346 id: doc.id.as_str(),
347 kind: doc.kind.as_str(),
348 title: doc.title.as_str(),
349 summary: doc.summary.as_str(),
350 })
351 .collect(),
352 };
353
354 nenjo_xml::to_xml_pretty(&ctx, 2)
355}
356
357#[derive(Debug, Serialize)]
358#[serde(rename = "knowledge_pack")]
359struct KnowledgePackSummaryContext<'a> {
360 #[serde(rename = "@source")]
361 source: &'a str,
362 #[serde(rename = "@name")]
363 name: &'a str,
364 #[serde(rename = "@root")]
365 root: &'a str,
366 usage: &'a str,
367 #[serde(rename = "doc")]
368 docs: Vec<KnowledgeDocumentSummaryContext<'a>>,
369}
370
371#[derive(Debug, Serialize)]
372#[serde(rename = "doc")]
373struct KnowledgeDocumentSummaryContext<'a> {
374 #[serde(rename = "@path")]
375 path: &'a str,
376 #[serde(rename = "@id")]
377 id: &'a str,
378 #[serde(rename = "@kind")]
379 kind: &'a str,
380 title: &'a str,
381 summary: &'a str,
382}
383
384pub fn knowledge_document_var_key(pack_prefix: &str, doc: &KnowledgeDocManifest) -> String {
385 let relative = pack_relative_path(pack_prefix, doc)
386 .unwrap_or(doc.virtual_path.as_str())
387 .trim_matches('/');
388 let path = relative
389 .strip_suffix(".md")
390 .unwrap_or(relative)
391 .split('/')
392 .filter(|segment| !segment.is_empty())
393 .map(normalize_var_segment)
394 .filter(|segment| !segment.is_empty())
395 .collect::<Vec<_>>()
396 .join(".");
397 if path.is_empty() {
398 pack_prefix.to_string()
399 } else {
400 format!("{pack_prefix}.{path}")
401 }
402}
403
404fn knowledge_document_alias_var_keys(pack_prefix: &str, doc: &KnowledgeDocManifest) -> Vec<String> {
405 let mut keys = Vec::new();
406 let Some(relative) = pack_relative_path(pack_prefix, doc) else {
407 return keys;
408 };
409 let Some((parent, _leaf)) = relative
410 .strip_suffix(".md")
411 .unwrap_or(relative)
412 .rsplit_once('/')
413 else {
414 return keys;
415 };
416 let parent = parent
417 .split('/')
418 .filter(|segment| !segment.is_empty())
419 .map(normalize_var_segment)
420 .filter(|segment| !segment.is_empty())
421 .collect::<Vec<_>>()
422 .join(".");
423
424 if let Some(stripped) = doc.id.strip_prefix("nenjo.") {
425 let id_segments = stripped
426 .split('.')
427 .map(normalize_var_segment)
428 .filter(|segment| !segment.is_empty())
429 .collect::<Vec<_>>();
430 if id_segments.len() >= 2
431 && id_segments
432 .first()
433 .is_some_and(|segment| segment == &parent)
434 {
435 let basename = id_segments[1..].join("_");
436 keys.push(format!("{pack_prefix}.{parent}.nenjo_{basename}"));
437 }
438 }
439
440 keys
441}
442
443fn pack_relative_path<'a>(pack_prefix: &str, doc: &'a KnowledgeDocManifest) -> Option<&'a str> {
444 match pack_prefix {
445 "builtin.nenjo" => doc.virtual_path.strip_prefix("builtin://nenjo/"),
446 "lib" => doc
447 .virtual_path
448 .strip_prefix("library://")
449 .and_then(|rest| rest.split_once('/').map(|(_, path)| path)),
450 _ if pack_prefix.starts_with("lib.") => {
451 let slug = pack_prefix.trim_start_matches("lib.");
452 let prefix = format!("library://{slug}/");
453 doc.virtual_path.strip_prefix(&prefix)
454 }
455 _ => None,
456 }
457}
458
459fn normalize_var_segment(segment: &str) -> String {
460 let mut normalized = String::new();
461 let mut last_was_underscore = false;
462 for ch in segment.chars() {
463 let ch = ch.to_ascii_lowercase();
464 if ch.is_ascii_alphanumeric() {
465 normalized.push(ch);
466 last_was_underscore = false;
467 } else if !last_was_underscore {
468 normalized.push('_');
469 last_was_underscore = true;
470 }
471 }
472 normalized.trim_matches('_').to_string()
473}
474
475#[derive(Debug, Serialize)]
476#[serde(rename = "knowledge_doc")]
477struct KnowledgeDocMetadataContext<'a> {
478 #[serde(rename = "@path")]
479 path: &'a str,
480 #[serde(rename = "@title")]
481 title: &'a str,
482 #[serde(rename = "@kind")]
483 kind: KnowledgeDocKind,
484 #[serde(rename = "@authority")]
485 authority: KnowledgeDocAuthority,
486 #[serde(rename = "@status")]
487 status: KnowledgeDocStatus,
488 summary: &'a str,
489 #[serde(skip_serializing_if = "Option::is_none")]
490 description: Option<&'a str>,
491 #[serde(skip_serializing_if = "Vec::is_empty", default)]
492 tags: Vec<&'a str>,
493 #[serde(skip_serializing_if = "Vec::is_empty", default)]
494 aliases: Vec<&'a str>,
495 #[serde(skip_serializing_if = "Vec::is_empty", default)]
496 keywords: Vec<&'a str>,
497}
498
499fn doc_metadata(doc: &KnowledgeDocManifest) -> String {
500 let path = prompt_doc_path(doc);
501 let ctx = KnowledgeDocMetadataContext {
502 path: &path,
503 title: &doc.title,
504 summary: &doc.summary,
505 description: doc.description.as_deref(),
506 kind: doc.kind,
507 authority: doc.authority,
508 status: doc.status,
509 tags: doc.tags.iter().map(String::as_str).collect(),
510 aliases: doc.aliases.iter().map(String::as_str).collect(),
511 keywords: doc.keywords.iter().map(String::as_str).collect(),
512 };
513 nenjo_xml::to_xml_pretty(&ctx, 2)
514}
515
516fn prompt_doc_path(doc: &KnowledgeDocManifest) -> String {
517 if doc.virtual_path.starts_with("library://") {
518 doc.virtual_path
519 .splitn(4, '/')
520 .nth(3)
521 .unwrap_or(&doc.virtual_path)
522 .to_string()
523 } else {
524 doc.virtual_path.clone()
525 }
526}
527
528fn pack_schema() -> serde_json::Value {
529 json!({
530 "type": "string",
531 "description": "Knowledge pack selector such as builtin:nenjo, workspace:<pack_slug>, or remote:<pack_id>."
532 })
533}
534
535fn knowledge_filter_schema(
536 extra_properties: Option<serde_json::Value>,
537 required: &[&str],
538) -> serde_json::Value {
539 let mut properties = json!({
540 "pack": pack_schema(),
541 "tags": {
542 "type": "array",
543 "items": { "type": "string" },
544 "description": "Optional tags that all returned docs must have"
545 },
546 "kind": {
547 "type": "string",
548 "description": "Optional kind filter such as guide or reference"
549 },
550 "authority": {
551 "type": "string",
552 "description": "Optional authority filter such as canonical, reference, or advisory"
553 },
554 "status": {
555 "type": "string",
556 "description": "Optional status filter such as stable, draft, or deprecated"
557 },
558 "path_prefix": {
559 "type": "string",
560 "description": "Optional virtual or pack-relative path prefix"
561 },
562 "related_to": {
563 "type": "string",
564 "description": "Optional path of a document this result must be related to"
565 },
566 "edge_type": {
567 "type": "string",
568 "description": "Optional relationship type used with related_to or neighbors"
569 }
570 });
571
572 if let Some(extra) = extra_properties
573 && let Some(map) = properties.as_object_mut()
574 && let Some(extra_map) = extra.as_object()
575 {
576 for (key, value) in extra_map {
577 map.insert(key.clone(), value.clone());
578 }
579 }
580
581 json!({
582 "type": "object",
583 "properties": properties,
584 "required": required,
585 "additionalProperties": false
586 })
587}
588
589fn knowledge_lookup_schema() -> serde_json::Value {
590 json!({
591 "type": "object",
592 "properties": {
593 "pack": pack_schema(),
594 "path": {
595 "type": "string",
596 "description": "Document path, id, alias, or virtual path within the selected pack"
597 }
598 },
599 "required": ["pack", "path"],
600 "additionalProperties": false
601 })
602}
603
604pub fn knowledge_tools() -> Vec<ToolSpec> {
605 vec![
606 ToolSpec {
607 name: "list_knowledge_packs".into(),
608 description: "List locally available knowledge packs. Use this before reading or searching knowledge when you need to discover available sources.".into(),
609 parameters: json!({
610 "type": "object",
611 "properties": {},
612 "additionalProperties": false
613 }),
614 category: ToolCategory::Read,
615 },
616 ToolSpec {
617 name: "list_knowledge_docs".into(),
618 description: "List compact document metadata from one knowledge pack without loading document bodies.".into(),
619 parameters: knowledge_filter_schema(None, &["pack"]),
620 category: ToolCategory::Read,
621 },
622 ToolSpec {
623 name: "read_knowledge_doc".into(),
624 description: "Read one full document body from a knowledge pack by path.".into(),
625 parameters: knowledge_lookup_schema(),
626 category: ToolCategory::Read,
627 },
628 ToolSpec {
629 name: "read_knowledge_doc_manifest".into(),
630 description: "Read one document's metadata from a knowledge pack by path without loading the body.".into(),
631 parameters: knowledge_lookup_schema(),
632 category: ToolCategory::Read,
633 },
634 ToolSpec {
635 name: "search_knowledge".into(),
636 description: "Search a knowledge pack and return matches with body content. Use this when you need to inspect or quote matching text.".into(),
637 parameters: knowledge_filter_schema(
638 Some(json!({
639 "query": {
640 "type": "string",
641 "description": "Search query, path, title, tag, summary, or body text"
642 }
643 })),
644 &["pack", "query"],
645 ),
646 category: ToolCategory::Read,
647 },
648 ToolSpec {
649 name: "search_knowledge_paths".into(),
650 description: "Search a knowledge pack using metadata only and return compact results without body content.".into(),
651 parameters: knowledge_filter_schema(
652 Some(json!({
653 "query": {
654 "type": "string",
655 "description": "Search query, path, title, tag, or summary"
656 }
657 })),
658 &["pack", "query"],
659 ),
660 category: ToolCategory::Read,
661 },
662 ToolSpec {
663 name: "list_knowledge_tree".into(),
664 description: "List the document tree for a knowledge pack, optionally under a prefix.".into(),
665 parameters: json!({
666 "type": "object",
667 "properties": {
668 "pack": pack_schema(),
669 "prefix": {
670 "type": "string",
671 "description": "Optional virtual or pack-relative path prefix"
672 }
673 },
674 "required": ["pack"],
675 "additionalProperties": false
676 }),
677 category: ToolCategory::Read,
678 },
679 ToolSpec {
680 name: "list_knowledge_neighbors".into(),
681 description: "List graph neighbors for one document in a knowledge pack.".into(),
682 parameters: json!({
683 "type": "object",
684 "properties": {
685 "pack": pack_schema(),
686 "path": {
687 "type": "string",
688 "description": "Document path, id, alias, or virtual path within the selected pack"
689 },
690 "edge_type": {
691 "type": "string",
692 "description": "Optional relationship type filter such as references or depends_on"
693 }
694 },
695 "required": ["pack", "path"],
696 "additionalProperties": false
697 }),
698 category: ToolCategory::Read,
699 },
700 ]
701}
702
703pub fn knowledge_toolbelt(registry: Arc<dyn KnowledgeRegistry>) -> Vec<Arc<dyn Tool>> {
704 knowledge_tools()
705 .into_iter()
706 .map(|spec| Arc::new(KnowledgeTool::new(spec, registry.clone())) as Arc<dyn Tool>)
707 .collect()
708}
709
710struct KnowledgeTool {
711 spec: ToolSpec,
712 registry: Arc<dyn KnowledgeRegistry>,
713}
714
715impl KnowledgeTool {
716 fn new(spec: ToolSpec, registry: Arc<dyn KnowledgeRegistry>) -> Self {
717 Self { spec, registry }
718 }
719}
720
721#[async_trait]
722impl Tool for KnowledgeTool {
723 fn name(&self) -> &str {
724 &self.spec.name
725 }
726
727 fn description(&self) -> &str {
728 &self.spec.description
729 }
730
731 fn parameters_schema(&self) -> serde_json::Value {
732 self.spec.parameters.clone()
733 }
734
735 fn category(&self) -> ToolCategory {
736 self.spec.category
737 }
738
739 async fn execute(&self, args: serde_json::Value) -> Result<ToolResult> {
740 let output = match self.name() {
741 "list_knowledge_packs" => serde_json::to_value(self.registry.list_packs().await?)?,
742 "list_knowledge_docs" => {
743 let args: KnowledgeListArgs = serde_json::from_value(args)?;
744 let pack = self.registry.resolve_pack(&args.pack).await?;
745 let filter = knowledge_filter(args.filter)?;
746 let docs = pack
747 .list_docs(filter)
748 .into_iter()
749 .map(|doc| knowledge_manifest_result(&args.pack, doc))
750 .collect::<Vec<_>>();
751 serde_json::to_value(docs)?
752 }
753 "read_knowledge_doc" => {
754 let args: KnowledgeReadArgs = serde_json::from_value(args)?;
755 let pack = self.registry.resolve_pack(&args.pack).await?;
756 let doc = pack.read_doc(&args.path).ok_or_else(|| {
757 anyhow!(
758 "knowledge document '{}' not found in pack '{}'",
759 args.path,
760 args.pack
761 )
762 })?;
763 serde_json::to_value(KnowledgeDocReadResult {
764 manifest: knowledge_manifest_result(&args.pack, &doc.manifest),
765 content: doc.content,
766 })?
767 }
768 "read_knowledge_doc_manifest" => {
769 let args: KnowledgeReadArgs = serde_json::from_value(args)?;
770 let pack = self.registry.resolve_pack(&args.pack).await?;
771 let doc = pack.read_manifest(&args.path).ok_or_else(|| {
772 anyhow!(
773 "knowledge document '{}' not found in pack '{}'",
774 args.path,
775 args.pack
776 )
777 })?;
778 serde_json::to_value(knowledge_manifest_result(&args.pack, doc))?
779 }
780 "search_knowledge" => {
781 let args: KnowledgeSearchArgs = serde_json::from_value(args)?;
782 let pack = self.registry.resolve_pack(&args.pack).await?;
783 let filter = knowledge_filter(args.filter)?;
784 let hits = pack
785 .search_docs(&args.query, filter)
786 .into_iter()
787 .map(|hit| knowledge_search_result(&args.pack, hit))
788 .collect::<Vec<_>>();
789 serde_json::to_value(hits)?
790 }
791 "search_knowledge_paths" => {
792 let args: KnowledgeSearchArgs = serde_json::from_value(args)?;
793 let pack = self.registry.resolve_pack(&args.pack).await?;
794 let filter = knowledge_filter(args.filter)?;
795 let hits = pack
796 .search_paths(&args.query, filter)
797 .into_iter()
798 .map(|hit| knowledge_search_result(&args.pack, hit))
799 .collect::<Vec<_>>();
800 serde_json::to_value(hits)?
801 }
802 "list_knowledge_tree" => {
803 let args: KnowledgeTreeArgs = serde_json::from_value(args)?;
804 let pack = self.registry.resolve_pack(&args.pack).await?;
805 serde_json::to_value(pack.list_tree(args.prefix.as_deref()))?
806 }
807 "list_knowledge_neighbors" => {
808 let args: KnowledgeNeighborArgs = serde_json::from_value(args)?;
809 let pack = self.registry.resolve_pack(&args.pack).await?;
810 let edge_type = parse_knowledge_enum(args.edge_type)?;
811 serde_json::to_value(pack.neighbors(&args.path, edge_type))?
812 }
813 name => return Err(anyhow!("unknown knowledge tool '{name}'")),
814 };
815
816 Ok(ToolResult {
817 success: true,
818 output: serde_json::to_string_pretty(&output)?,
819 error: None,
820 })
821 }
822}
823
824#[cfg(test)]
825mod tests {
826 use super::knowledge_pack_var_prefix;
827
828 #[test]
829 fn workspace_knowledge_uses_lib_template_namespace() {
830 assert_eq!(
831 knowledge_pack_var_prefix("workspace:Product Docs"),
832 "lib.product_docs"
833 );
834 assert_eq!(knowledge_pack_var_prefix("workspace"), "lib");
835 }
836}