Skip to main content

nenjo_knowledge/
tools.rs

1//! Generic knowledge tool contracts and response shaping.
2//!
3//! Runtime-specific crates provide pack discovery and resolution. The SDK owns
4//! the stable tool schemas and result payloads so builtin, project, and remote
5//! packs present the same API to agents.
6
7use 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    /// Canonical pack selector to pass as the `pack` argument to knowledge tools.
350    pub selector: String,
351    /// Backwards-compatible alias for `selector`.
352    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    /// Canonical pack selector to pass as the `pack` argument to knowledge tools.
425    pub pack: String,
426    /// Stable document slug to pass as `slug` to update/delete library document tools.
427    pub slug: String,
428    /// Agent-visible selector used for lookup and traversal.
429    pub selector: String,
430    /// Human-readable title.
431    pub title: String,
432    /// Short summary used for selection.
433    pub summary: String,
434    /// Open-ended document category.
435    pub kind: String,
436    /// Lightweight classification labels.
437    pub tags: Vec<String>,
438    /// Outbound graph edge pointers. Call `list_knowledge_neighbors` to expand them.
439    pub related: Vec<KnowledgeDocRelatedResult>,
440}
441
442#[derive(Debug, Clone, Serialize)]
443pub struct KnowledgeDocRelatedResult {
444    #[serde(rename = "type")]
445    pub edge_type: String,
446    /// Target document selector/path used for traversal.
447    pub target: String,
448    /// Stable document slug to pass as related.target_doc in library write tools.
449    pub target_doc: String,
450}
451
452#[derive(Debug, Clone, Serialize)]
453pub struct KnowledgeDocReadResult {
454    /// Slim document metadata.
455    pub document: KnowledgeDocMetadataResult,
456    /// Full document body.
457    pub content: String,
458}
459
460#[derive(Debug, Clone, Serialize)]
461pub struct KnowledgeDocSearchResult {
462    /// Slim document metadata for the matched document.
463    pub document: KnowledgeDocMetadataResult,
464    /// Simple relevance score derived from metadata matches.
465    pub score: usize,
466    /// Metadata fields that matched the query.
467    pub matched: Vec<String>,
468}
469
470#[derive(Debug, Clone, Serialize)]
471pub struct KnowledgeDocNeighborsResult {
472    /// Source document metadata.
473    pub document: KnowledgeDocMetadataResult,
474    /// Resolved outbound neighbor edges.
475    pub edges: Vec<KnowledgeDocNeighborEdgeResult>,
476}
477
478#[derive(Debug, Clone, Serialize)]
479pub struct KnowledgeDocNeighborEdgeResult {
480    #[serde(rename = "type")]
481    pub edge_type: String,
482    /// Resolved target document metadata.
483    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
948/// Discovery tool for locally available knowledge packs. Always safe to expose,
949/// including when the registry has no packs yet.
950pub 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
957/// Read and traversal tools that require at least one registered knowledge pack.
958pub 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}