Skip to main content

thndrs_lib/core/
internals.rs

1//! Compact self-knowledge snapshot for model and startup inspection.
2//!
3//! This module owns the small, stable shape that describes what `thndrs`
4//! knows about itself for the current run.
5
6use crate::agent::ProviderKind;
7use crate::cli::WebSearchMode;
8use crate::context::ContextSource;
9use crate::prompt::PromptBundle;
10use crate::skills::SkillMetadata;
11use crate::utils;
12use thndrs_agent::context::render_model_dashboard;
13
14pub const RENDERER_MODE: &str = "direct-inline";
15
16const DOCUMENTATION_MAP: &[DocumentationEntry] = &[
17    DocumentationEntry { topic: "CLI", path: "docs/src/content/docs/docs/reference/cli.md" },
18    DocumentationEntry { topic: "configuration", path: "docs/src/content/docs/docs/reference/configuration.md" },
19    DocumentationEntry { topic: "sessions", path: "docs/src/content/docs/docs/reference/session-format.md" },
20    DocumentationEntry { topic: "tool boundary", path: "docs/src/content/docs/docs/concepts/tool-boundary.md" },
21    DocumentationEntry { topic: "tools", path: "docs/src/content/docs/docs/reference/tools.md" },
22    DocumentationEntry { topic: "web search and URL reading", path: "docs/src/content/docs/docs/usage/web-search.md" },
23    DocumentationEntry { topic: "prompt assembly", path: "docs/src/content/docs/docs/concepts/prompt-assembly.md" },
24    DocumentationEntry { topic: "project context", path: "docs/src/content/docs/docs/usage/project-context.md" },
25    DocumentationEntry { topic: "skills", path: "docs/src/content/docs/docs/usage/skills.md" },
26    DocumentationEntry { topic: "Umans provider", path: "docs/src/content/docs/docs/providers/umans.md" },
27    DocumentationEntry { topic: "OpenCode Go provider", path: "docs/src/content/docs/docs/providers/opencode-go.md" },
28    DocumentationEntry { topic: "OpenCode Zen provider", path: "docs/src/content/docs/docs/providers/opencode-zen.md" },
29    DocumentationEntry { topic: "ChatGPT Codex provider", path: "docs/src/content/docs/docs/providers/chatgpt.md" },
30    DocumentationEntry { topic: "renderer", path: "docs/src/content/docs/docs/usage/tui.md" },
31    DocumentationEntry { topic: "development workflow", path: "docs/src/content/docs/docs/development/workflow.md" },
32];
33
34const CAPABILITIES: &[&str] = &[
35    "structured prompt bundle",
36    "bounded workspace file tools",
37    "provider-native tool schemas",
38    "agent skills metadata",
39    "append-only JSONL sessions",
40    "application-owned web search backends",
41    "URL/article reading",
42    "direct inline terminal renderer",
43];
44
45#[derive(Clone, Copy, Debug, Eq, PartialEq)]
46pub struct DocumentationEntry {
47    pub topic: &'static str,
48    pub path: &'static str,
49}
50
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct ContextSnapshot {
53    pub path: String,
54    pub scope: String,
55    pub content_hash: u64,
56    pub truncated: bool,
57    pub byte_count: usize,
58}
59
60impl ContextSnapshot {
61    fn from_source(source: &ContextSource) -> Self {
62        Self {
63            path: source.path.display().to_string(),
64            scope: source.scope.clone(),
65            content_hash: source.content_hash,
66            truncated: source.truncated,
67            byte_count: source.byte_count,
68        }
69    }
70}
71
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct SkillSnapshot {
74    pub name: String,
75    pub path: String,
76    pub source: String,
77}
78
79impl SkillSnapshot {
80    fn from_metadata(skill: &SkillMetadata) -> Self {
81        Self {
82            name: skill.name.clone(),
83            path: skill.path.display().to_string(),
84            source: skill.source.label().to_string(),
85        }
86    }
87}
88
89#[derive(Clone, Debug, Eq, PartialEq)]
90pub struct AppIdentitySnapshot {
91    pub app_name: &'static str,
92    pub app_version: &'static str,
93    pub capabilities: Vec<&'static str>,
94}
95
96impl Default for AppIdentitySnapshot {
97    fn default() -> Self {
98        Self { app_name: "thndrs", app_version: env!("CARGO_PKG_VERSION"), capabilities: CAPABILITIES.to_vec() }
99    }
100}
101
102#[derive(Clone, Debug, Eq, PartialEq)]
103pub struct SearchSnapshot {
104    pub mode: String,
105    pub backend: String,
106    pub local_search: String,
107    pub url_reader: String,
108}
109
110impl From<WebSearchMode> for SearchSnapshot {
111    fn from(mode: WebSearchMode) -> Self {
112        Self {
113            mode: mode.label().to_string(),
114            backend: match mode {
115                WebSearchMode::DuckDuckGo => "duckduckgo: DuckDuckGo HTML search".to_string(),
116                WebSearchMode::Searxng => "searxng: configured SearXNG JSON search".to_string(),
117                WebSearchMode::None => "none: application-owned web search disabled".to_string(),
118            },
119            local_search: "web_search normalizes results and fetches public pages".to_string(),
120            url_reader: "read_url fetches public HTTP(S) and extracts HTML with Lectito".to_string(),
121        }
122    }
123}
124
125#[derive(Clone, Debug, Eq, PartialEq)]
126pub struct ProviderSnapshot {
127    pub provider: String,
128    pub model: String,
129    pub search: SearchSnapshot,
130}
131
132impl ProviderSnapshot {
133    pub fn new(provider: impl Into<String>, model: impl Into<String>, search_mode: WebSearchMode) -> Self {
134        Self { provider: provider.into(), model: model.into(), search: search_mode.into() }
135    }
136}
137
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub struct RuntimeSnapshot {
140    pub provider: ProviderSnapshot,
141    pub workspace: String,
142    pub renderer_mode: String,
143    pub tools: Vec<String>,
144}
145
146impl RuntimeSnapshot {
147    pub fn new(
148        provider: ProviderSnapshot, ws: impl Into<String>, rmode: impl Into<String>, tools: Vec<String>,
149    ) -> Self {
150        Self { provider, workspace: ws.into(), renderer_mode: rmode.into(), tools }
151    }
152}
153
154#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct PromptContextSnapshot {
156    pub prompt_fragments: Vec<String>,
157    pub context_sources: Vec<ContextSnapshot>,
158}
159
160impl PromptContextSnapshot {
161    pub fn new(fragments: Vec<String>, ctx: &[ContextSource]) -> Self {
162        Self { prompt_fragments: fragments, context_sources: ctx.iter().map(ContextSnapshot::from_source).collect() }
163    }
164}
165
166#[derive(Clone, Debug, Eq, PartialEq)]
167pub struct ReferenceSnapshot {
168    pub docs: Vec<DocumentationEntry>,
169    pub skills: Vec<SkillSnapshot>,
170}
171
172impl ReferenceSnapshot {
173    pub fn from_skills(skills: &[SkillMetadata]) -> Self {
174        Self { docs: DOCUMENTATION_MAP.to_vec(), skills: skills.iter().map(SkillSnapshot::from_metadata).collect() }
175    }
176}
177
178#[derive(Clone, Debug, Eq, PartialEq)]
179pub struct KnowledgeInventorySnapshot {
180    pub references: ReferenceSnapshot,
181    pub prompt_context: PromptContextSnapshot,
182}
183
184impl KnowledgeInventorySnapshot {
185    pub fn new(refs: ReferenceSnapshot, ctx: PromptContextSnapshot) -> Self {
186        Self { references: refs, prompt_context: ctx }
187    }
188}
189
190#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct SelfKnowledgeSnapshot {
192    pub identity: AppIdentitySnapshot,
193    pub runtime: RuntimeSnapshot,
194    pub inventory: KnowledgeInventorySnapshot,
195    pub diagnostics: Vec<String>,
196    /// Compact context dashboard from the context selection ledger,
197    /// when a projection is attached to the prompt bundle.
198    pub context_dashboard: Option<String>,
199}
200
201impl From<&PromptBundle> for SelfKnowledgeSnapshot {
202    fn from(bundle: &PromptBundle) -> SelfKnowledgeSnapshot {
203        let provider = ProviderSnapshot::new(
204            ProviderKind::for_model(&bundle.environment.model).label(),
205            &bundle.environment.model,
206            bundle.environment.search_mode,
207        );
208        let runtime = RuntimeSnapshot::new(
209            provider,
210            bundle.environment.cwd.clone(),
211            RENDERER_MODE,
212            bundle.tool_catalog.iter().map(|tool| tool.name.to_string()).collect(),
213        );
214        let references = ReferenceSnapshot::from_skills(&bundle.available_skills);
215        let prompt_context = PromptContextSnapshot::new(
216            bundle
217                .fragments
218                .iter()
219                .map(|fragment| fragment.name.to_string())
220                .collect(),
221            &bundle.project_context,
222        );
223        let inventory = KnowledgeInventorySnapshot::new(references, prompt_context);
224        let context_dashboard = bundle.context_ledger.as_ref().map(render_model_dashboard);
225        let snapshot = SelfKnowledgeSnapshot::new(AppIdentitySnapshot::default(), runtime, inventory, Vec::new());
226        if let Some(dashboard) = context_dashboard {
227            snapshot.with_context_dashboard(dashboard)
228        } else {
229            snapshot
230        }
231    }
232}
233
234impl SelfKnowledgeSnapshot {
235    pub fn new(
236        identity: AppIdentitySnapshot, runtime: RuntimeSnapshot, inventory: KnowledgeInventorySnapshot,
237        diagnostics: Vec<String>,
238    ) -> Self {
239        Self { identity, runtime, inventory, diagnostics, context_dashboard: None }
240    }
241
242    /// Attach a compact context dashboard string rendered from the context selection ledger.
243    pub fn with_context_dashboard(mut self, dashboard: impl Into<String>) -> Self {
244        self.context_dashboard = Some(dashboard.into());
245        self
246    }
247
248    pub fn render_model_visible(&self) -> String {
249        let mut out = String::new();
250        out.push_str("<thndrs_self_knowledge>\n");
251        out.push_str("  <self_description>\n");
252        element(&mut out, 4, "name", self.identity.app_name);
253        element(&mut out, 4, "version", self.identity.app_version);
254        out.push_str("    <capabilities>\n");
255        for capability in &self.identity.capabilities {
256            element(&mut out, 6, "capability", capability);
257        }
258        out.push_str("    </capabilities>\n");
259        out.push_str("  </self_description>\n");
260
261        out.push_str("  <docs_map>\n");
262        for doc in &self.inventory.references.docs {
263            out.push_str("    <doc>\n");
264            element(&mut out, 6, "topic", doc.topic);
265            element(&mut out, 6, "path", doc.path);
266            out.push_str("    </doc>\n");
267        }
268        out.push_str("  </docs_map>\n");
269
270        out.push_str("  <runtime_state>\n");
271        element(&mut out, 4, "workspace", &self.runtime.workspace);
272        element(&mut out, 4, "renderer_mode", &self.runtime.renderer_mode);
273        out.push_str("    <provider>\n");
274        element(&mut out, 6, "name", &self.runtime.provider.provider);
275        element(&mut out, 6, "model", &self.runtime.provider.model);
276        out.push_str("      <search>\n");
277        element(&mut out, 8, "mode", &self.runtime.provider.search.mode);
278        element(&mut out, 8, "backend", &self.runtime.provider.search.backend);
279        element(&mut out, 8, "local_search", &self.runtime.provider.search.local_search);
280        element(&mut out, 8, "url_reader", &self.runtime.provider.search.url_reader);
281        out.push_str("      </search>\n");
282        out.push_str("    </provider>\n");
283
284        out.push_str("    <tools>\n");
285        for tool in &self.runtime.tools {
286            element(&mut out, 6, "tool", tool);
287        }
288        out.push_str("    </tools>\n");
289
290        out.push_str("    <prompt_fragments>\n");
291        for fragment in &self.inventory.prompt_context.prompt_fragments {
292            element(&mut out, 6, "fragment", fragment);
293        }
294        out.push_str("    </prompt_fragments>\n");
295
296        out.push_str("    <project_context>\n");
297        for source in &self.inventory.prompt_context.context_sources {
298            out.push_str("      <source>\n");
299            element(&mut out, 8, "path", &source.path);
300            element(&mut out, 8, "scope", &source.scope);
301            element(&mut out, 8, "hash", &source.content_hash.to_string());
302            element(&mut out, 8, "truncated", &source.truncated.to_string());
303            element(&mut out, 8, "byte_count", &source.byte_count.to_string());
304            out.push_str("      </source>\n");
305        }
306        out.push_str("    </project_context>\n");
307
308        out.push_str("    <skills>\n");
309        for skill in &self.inventory.references.skills {
310            out.push_str("      <skill>\n");
311            element(&mut out, 8, "name", &skill.name);
312            element(&mut out, 8, "source", &skill.source);
313            element(&mut out, 8, "path", &skill.path);
314            out.push_str("      </skill>\n");
315        }
316        out.push_str("    </skills>\n");
317
318        if let Some(dashboard) = &self.context_dashboard {
319            out.push_str("    ");
320            out.push_str(dashboard);
321            out.push('\n');
322        }
323
324        out.push_str("    <diagnostics>\n");
325        for diagnostic in &self.diagnostics {
326            element(&mut out, 6, "diagnostic", diagnostic);
327        }
328        out.push_str("    </diagnostics>\n");
329        out.push_str("  </runtime_state>\n");
330        out.push_str("</thndrs_self_knowledge>");
331        out
332    }
333
334    pub fn startup_sections(&self) -> Vec<StartupSection> {
335        vec![
336            StartupSection::new(
337                "Runtime",
338                vec![
339                    format!("provider = \"{}\"", self.runtime.provider.provider),
340                    format!("model = \"{}\"", self.runtime.provider.model),
341                    format!("search = \"{}\"", self.runtime.provider.search.mode),
342                ],
343            ),
344            StartupSection::new(
345                "Context",
346                context_startup_lines(&self.inventory.prompt_context.context_sources),
347            ),
348            StartupSection::new(
349                "Search",
350                vec![format!(
351                    "{}; {}; {}",
352                    self.runtime.provider.search.backend,
353                    self.runtime.provider.search.local_search,
354                    self.runtime.provider.search.url_reader
355                )],
356            ),
357            StartupSection::new("Skills", {
358                let names = self
359                    .inventory
360                    .references
361                    .skills
362                    .iter()
363                    .map(|skill| skill.name.as_str())
364                    .collect::<Vec<&str>>();
365                vec![if names.is_empty() { "(none)".to_string() } else { names.join(", ") }]
366            }),
367            StartupSection::new(
368                "Diagnostics",
369                if self.diagnostics.is_empty() { vec!["(none)".to_string()] } else { self.diagnostics.clone() },
370            ),
371        ]
372    }
373}
374
375#[derive(Clone, Debug, Eq, PartialEq)]
376pub struct StartupSection {
377    /// TOML-style section heading shown in the startup banner.
378    pub heading: &'static str,
379    /// Preformatted display lines shown under the section heading.
380    pub lines: Vec<String>,
381}
382
383impl StartupSection {
384    fn new(heading: &'static str, lines: Vec<String>) -> Self {
385        Self { heading, lines }
386    }
387}
388
389fn context_startup_lines(sources: &[ContextSnapshot]) -> Vec<String> {
390    if sources.is_empty() {
391        vec!["(none)".to_string()]
392    } else {
393        sources
394            .iter()
395            .map(|source| match source.truncated {
396                true => format!("{} (truncated, {} bytes)", source.path, source.byte_count),
397                false => source.path.clone(),
398            })
399            .collect()
400    }
401}
402
403fn element(out: &mut String, indent: usize, name: &str, value: &str) {
404    out.push_str(&" ".repeat(indent));
405    out.push('<');
406    out.push_str(name);
407    out.push('>');
408    out.push_str(&utils::escape_xml(value));
409    out.push_str("</");
410    out.push_str(name);
411    out.push_str(">\n");
412}
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use crate::skills::{SkillDiagnostic, SkillSource};
418    use crate::tools::ToolDefinition;
419
420    fn test_skill() -> SkillMetadata {
421        SkillMetadata {
422            name: "inspect".to_string(),
423            description: "Inspect project state.".to_string(),
424            path: "/repo/.thndrs/skills/inspect/SKILL.md".into(),
425            root: "/repo/.thndrs/skills/inspect".into(),
426            content_hash: 7,
427            byte_count: 100,
428            source: SkillSource::Project,
429            allowed_tools: Vec::new(),
430            license: None,
431            compatibility: None,
432            metadata: None,
433            references: Vec::new(),
434        }
435    }
436
437    fn test_snapshot(
438        model: &str, search_mode: WebSearchMode, prompt_fragments: Vec<String>, context_sources: &[ContextSource],
439        tools: &[ToolDefinition], skills: &[SkillMetadata], diagnostics: &[SkillDiagnostic],
440    ) -> SelfKnowledgeSnapshot {
441        let provider = ProviderSnapshot::new("umans", model, search_mode);
442        let runtime = RuntimeSnapshot::new(
443            provider,
444            "/repo",
445            RENDERER_MODE,
446            tools.iter().map(|tool| tool.name.to_string()).collect(),
447        );
448        let references = ReferenceSnapshot::from_skills(skills);
449        let prompt_context = PromptContextSnapshot::new(prompt_fragments, context_sources);
450        let inventory = KnowledgeInventorySnapshot::new(references, prompt_context);
451        let diagnostics = diagnostics.iter().map(SkillDiagnostic::summary).collect();
452        SelfKnowledgeSnapshot::new(AppIdentitySnapshot::default(), runtime, inventory, diagnostics)
453    }
454
455    #[test]
456    fn model_visible_snapshot_contains_docs_and_runtime_state() {
457        let source = ContextSource {
458            path: "/repo/AGENTS.md".into(),
459            scope: ".".to_string(),
460            content: "# Project".to_string(),
461            content_hash: 42,
462            truncated: false,
463            byte_count: 9,
464        };
465        let diagnostic = SkillDiagnostic { path: "/repo/bad/SKILL.md".into(), message: "invalid".to_string() };
466        let snapshot = test_snapshot(
467            "test-model",
468            WebSearchMode::DuckDuckGo,
469            vec!["base_identity".to_string(), "self_knowledge".to_string()],
470            &[source],
471            &crate::tools::tool_definitions(),
472            &[test_skill()],
473            &[diagnostic],
474        );
475        let rendered = snapshot.render_model_visible();
476
477        assert!(rendered.contains("<thndrs_self_knowledge>"));
478        assert!(rendered.contains("<name>umans</name>"));
479        assert!(rendered.contains("<renderer_mode>direct-inline</renderer_mode>"));
480        assert!(rendered.contains("docs/src/content/docs/docs/reference/cli.md"));
481        assert!(rendered.contains("<fragment>base_identity</fragment>"));
482        assert!(rendered.contains("<tool>read_file_range</tool>"));
483        assert!(rendered.contains("<name>inspect</name>"));
484        assert!(rendered.contains("skill diagnostic"));
485        assert!(
486            !rendered.contains("# Project"),
487            "snapshot must not include AGENTS.md content"
488        );
489    }
490
491    #[test]
492    fn startup_sections_use_compact_labels() {
493        let snapshot = test_snapshot(
494            "test-model",
495            WebSearchMode::None,
496            vec!["base_identity".to_string()],
497            &[],
498            &[],
499            &[],
500            &[],
501        );
502        let sections = snapshot.startup_sections();
503        assert!(sections.iter().any(|section| section.heading == "Runtime"));
504        let context = sections
505            .iter()
506            .find(|section| section.heading == "Context")
507            .expect("Context section should exist");
508        assert!(
509            context.lines.iter().any(|line| line == "(none)"),
510            "Context with no sources should show (none): {:?}",
511            context.lines
512        );
513
514        let runtime = sections
515            .iter()
516            .find(|section| section.heading == "Runtime")
517            .expect("Runtime section should exist");
518        assert!(
519            runtime.lines.iter().any(|line| line.starts_with("provider =")),
520            "Runtime section should have provider = ... line: {:?}",
521            runtime.lines
522        );
523    }
524}