1use 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 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 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 pub heading: &'static str,
379 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}