use std::borrow::Cow;
use std::path::Path;
use std::sync::Mutex;
use edgecrab_tools::edit_contract::{MAX_MUTATION_PAYLOAD_BYTES, MAX_MUTATION_PAYLOAD_KIB};
use edgecrab_types::Platform;
struct SkillsCacheEntry {
summary: Option<String>,
disabled_at_build: Vec<String>,
built_at: std::time::Instant,
}
type SkillsCacheMap = std::collections::HashMap<std::path::PathBuf, SkillsCacheEntry>;
static SKILLS_CACHE: Mutex<Option<SkillsCacheMap>> = Mutex::new(None);
const SKILLS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
pub fn invalidate_skills_cache() {
if let Ok(mut guard) = SKILLS_CACHE.lock() {
*guard = None;
}
}
const DEFAULT_IDENTITY: &str = "\
You are EdgeCrab, an intelligent AI agent built with Rust for speed and safety. \
You are helpful, knowledgeable, and direct. You assist users with a wide range of \
tasks including answering questions, writing and debugging code, code review, \
architecture design, analysing information, creative work, and executing actions \
via your tools. You communicate clearly, admit uncertainty when appropriate, and \
prioritise being genuinely useful over being verbose unless otherwise directed. \
Be targeted and efficient in your exploration and investigations.";
const CLI_HINT: &str = "\
You are a CLI AI Agent. Use markdown formatting with code blocks where helpful. \
ANSI colors are supported.";
const TELEGRAM_HINT: &str = "\
You are on a text messaging communication platform, Telegram. \
Please do not use markdown as it does not render. \
You can send media files natively: to deliver a file to the user, \
include MEDIA:/absolute/path/to/file in your response. Images \
(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice \
bubbles, and videos (.mp4) play inline. You can also include image \
URLs in markdown format  and they will be sent as native photos.";
const DISCORD_HINT: &str = "\
You are in a Discord server or group chat communicating with your user. \
You can send media files natively: include MEDIA:/absolute/path/to/file \
in your response. Images (.png, .jpg, .webp) are sent as photo \
attachments, audio as file attachments. You can also include image URLs \
in markdown format  and they will be sent as attachments.";
const WHATSAPP_HINT: &str = "\
You are on a text messaging communication platform, WhatsApp. \
Please do not use markdown as it does not render. \
You can send media files natively: to deliver a file to the user, \
include MEDIA:/absolute/path/to/file in your response. The file \
will be sent as a native WhatsApp attachment — images (.jpg, .png, \
.webp) appear as photos, videos (.mp4, .mov) play inline, and other \
files arrive as downloadable documents. You can also include image \
URLs in markdown format  and they will be sent as photos.";
const SLACK_HINT: &str = "\
You are in a Slack workspace communicating with your user. \
You can send media files natively: include MEDIA:/absolute/path/to/file \
in your response. Images (.png, .jpg, .webp) are uploaded as photo \
attachments, audio as file attachments. You can also include image URLs \
in markdown format  and they will be uploaded as attachments.";
const FEISHU_HINT: &str = "\
You are in a Feishu or Lark workspace communicating with your user. \
Prefer plain text over heavy markdown. Keep formatting simple and clear, \
and avoid tables unless the content really requires them.";
const WECOM_HINT: &str = "\
You are in a WeCom workspace communicating with your user. \
Keep responses concise and readable in chat. Prefer plain text with light \
structure over complex markdown or deeply nested formatting.";
const SIGNAL_HINT: &str = "\
You are on a text messaging communication platform, Signal. \
Please do not use markdown as it does not render. \
You can send media files natively: to deliver a file to the user, \
include MEDIA:/absolute/path/to/file in your response. Images \
(.png, .jpg, .webp) appear as photos, audio as attachments, and other \
files arrive as downloadable documents. You can also include image \
URLs in markdown format  and they will be sent as photos.";
const EMAIL_HINT: &str = "\
You are communicating via email. Write clear, well-structured responses \
suitable for email. Use plain text formatting (no markdown). \
Keep responses concise but complete. You can send file attachments — \
include MEDIA:/absolute/path/to/file in your response. The subject line \
is preserved for threading. Do not include greetings or sign-offs unless \
contextually appropriate.";
const SMS_HINT: &str = "\
You are communicating via SMS. Keep responses concise and use plain text \
only — no markdown, no formatting. SMS messages are limited to ~1600 \
characters, so be brief and direct.";
const WEBHOOK_HINT: &str = "\
You are running via Webhook integration. Return structured JSON-friendly \
responses. Keep responses concise and machine-parseable when possible.";
const API_HINT: &str = "\
You are running via API. Return well-structured responses suitable for \
programmatic consumption. Use markdown for formatting.";
const CRON_HINT: &str = "\
You are running as a scheduled cron job. There is no user present — you \
cannot ask questions, request clarification, or wait for follow-up. Execute \
the task fully and autonomously, making reasonable decisions where needed. \
Your final response is automatically delivered to the job's configured \
destination — put the primary content directly in your response.";
const MEMORY_GUIDANCE: &str = "\
You have persistent memory across sessions. Save durable facts using the memory_write \
tool: user preferences, environment details, tool quirks, and stable conventions. \
Memory is injected into every session, so keep it compact and focused on facts that \
will still matter later. Prioritise what reduces future user steering — the most \
valuable memory is one that prevents the user from having to correct or remind you \
again. User preferences and recurring corrections matter more than procedural task \
details. Do NOT save task progress, session outcomes, completed-work logs, or \
temporary TODO state to memory; use session_search to recall those from past \
transcripts. If you've discovered a new non-trivial workflow, save it as a skill \
with skill_manage.";
const SESSION_SEARCH_GUIDANCE: &str = "\
When the user references something from a past conversation or you suspect relevant \
cross-session context exists, use session_search to recall it before asking them to \
repeat themselves.";
const SCHEDULING_GUIDANCE: &str = "\
Use manage_cron_jobs for ALL cron job operations — never edit ~/.edgecrab/cron/jobs.json \
directly via terminal.\n\
\n\
Action→intent mapping:\n\
create — user wants to schedule a new task: 'every morning', 'remind me daily',\n\
'check every 2 hours', 'run this each weekday at 9am'.\n\
list — user wants to see scheduled jobs: 'show my cron jobs', 'what's scheduled',\n\
'list automations', 'what jobs are running'.\n\
pause — user wants to stop/suppress a job: 'pause the daily briefing',\n\
'suppress all cron jobs', 'disable the weather check', 'stop the reminder'.\n\
resume — user wants to re-enable a paused job: 'restart', 'resume', 're-enable'.\n\
remove — user wants to delete permanently: 'delete', 'remove', 'cancel the job'.\n\
status — user wants a summary count / next-run time: 'cron status', 'when does it run'.\n\
update — user wants to change schedule/prompt/delivery of an existing job.\n\
\n\
Workflow for 'suppress all cron jobs':\n\
1. manage_cron_jobs(action='list') ← get all job_ids\n\
2. manage_cron_jobs(action='pause', job_id='<id>') ← pause each one\n\
\n\
The cron prompt must be fully self-contained — include all specifics (URLs, \
credentials, servers, what to check) since the job runs in a fresh session with \
no access to chat history.\n\
\n\
Delivery — map the user's words to the deliver= parameter:\n\
'send me on Telegram' / 'notify via Telegram' → deliver='telegram'\n\
'send to Discord' / 'post in Discord' → deliver='discord'\n\
'notify me on Slack' → deliver='slack'\n\
'send via WhatsApp' → deliver='whatsapp'\n\
'email me the results' → deliver='email'\n\
'send me on Signal' → deliver='signal'\n\
'notify me here' / 'reply in this chat' → deliver='origin'\n\
'keep local' / no delivery preference mentioned → deliver='local'\n\
'telegram chat -100123456' → deliver='telegram:-100123456'\n\
Default: deliver='local' on CLI unless the user specifies a platform. \
For delivery back to the user in this chat, use deliver='origin'.";
const MESSAGE_DELIVERY_GUIDANCE: &str = "\
Use send_message only when the user explicitly wants content delivered to a different \
platform, contact, channel, or thread than the current reply path.\n\
\n\
Rules:\n\
- Normal reply in the current chat unless the user asks to send elsewhere.\n\
- If the user gives a clear imperative to send and provides the destination and content, send it directly instead of asking for redundant confirmation.\n\
- If the user asks to send to Telegram, WhatsApp, Discord, Slack, Signal, email, SMS, or another target, use send_message.\n\
- If the user asks for a draft, suggested wording, or a message to review, do NOT send it.\n\
- If the user names only a platform, use that platform's home channel.\n\
- If the user names a specific channel/person and the target is ambiguous, call send_message(action='list') first.\n\
- Do not claim you cannot send messages when send_message is available.";
const MOA_GUIDANCE: &str = "\
## Mixture-of-Agents
When the user asks for MoA, mixture-of-agents, multiple experts, cross-model consensus, \
or wants several models compared and then synthesized, call the `moa` tool directly.
Rules:
- Use `moa` when the request is specifically about multi-model comparison or synthesis.
- Do not claim the feature is unavailable when `moa` is in the tool list.
- The canonical tool name is `moa`. `mixture_of_agents` is a legacy alias.";
const LSP_GUIDANCE: &str = "\
## Language Server Usage
When working on supported source files, prefer LSP tools over plain text search for semantic code tasks.
Use LSP first for:
- definition / implementation lookup
- references and symbol discovery
- hover, signature help, semantic tokens, inlay hints
- call hierarchy and type hierarchy
- diagnostics, workspace type-error scans, and diagnostic enrichment
- code actions, rename, and formatting
Operational rules:
- Prefer lsp_document_symbols / lsp_workspace_symbols over search_files when the user asks about symbols, functions, methods, classes, or types.
- Prefer lsp_goto_definition, lsp_find_references, and lsp_goto_implementation for navigation instead of guessing from grep matches.
- Prefer lsp_code_actions, lsp_apply_code_action, lsp_rename, lsp_format_document, and lsp_format_range for code mutations that the server can perform semantically.
- Use lsp_diagnostics_pull and lsp_workspace_type_errors before making claims about compiler or type errors when an LSP server is available.
- Use search_files or read_file as fallback when the file type is unsupported, no server is configured, or the task is purely textual rather than semantic.
EdgeCrab's LSP surface exceeds the common 9-operation baseline: it includes navigation plus code actions, rename, formatting, inlay hints, semantic tokens, signature help, type hierarchy, diagnostics pull, linked editing, LLM-enriched diagnostics, guided action selection, and workspace-wide type-error scans.";
fn code_editing_guidance() -> String {
format!(
"\
## Code Editing Execution
When the user asks for a concrete code or file change and the necessary tools are available, inspect the relevant files and apply the edit in the same turn.
Rules:
- Do not stop at a plan, draft diff, or 'ready for a patch?' unless the user explicitly asked for a plan/options or the requirements are materially ambiguous.
- Use read/search/LSP tools to gather the minimum context needed, then mutate files with apply_patch or write_file.
- Create new files directly when the request requires them, but keep the first write small when the file will be substantial.
- The file-mutation contract is hard-bounded: each write_file content payload and each apply_patch patch payload must stay at or under {MAX_MUTATION_PAYLOAD_BYTES} bytes ({MAX_MUTATION_PAYLOAD_KIB} KiB) per call.
- For large files, full game engines, long scripts, or other substantial code artifacts, do NOT attempt a single giant write_file or execute_code payload. Create a minimal scaffold first, then add the implementation with focused patch/apply_patch steps.
- Do not call execute_code as a placeholder plan. Only call it when you already have a concrete code payload to run.
- Once the requested edit or artifact is complete, stop expanding scope. Do not add bonus summary files, quick-reference docs, start-here files, or repeated verification passes unless the user explicitly asked for them.
- After editing, report what changed and any verification you ran.
- Ask before destructive changes outside the user's stated scope."
)
}
const SKILLS_GUIDANCE: &str = "\
After completing a complex task (5+ tool calls), fixing a tricky error, or discovering \
a non-trivial workflow, save the approach as a skill with skill_manage so you can reuse \
it next time. When using a skill and finding it outdated, incomplete, or wrong, patch it \
immediately with skill_manage(action='edit') — don't wait to be asked. Skills that \
aren't maintained become liabilities.";
const VISION_GUIDANCE: &str = "\
## Image Analysis — Tool Selection Rules
You have TWO vision tools. Use EXACTLY ONE per attached image:
- **vision_analyze** — analyzes a local image FILE or an HTTP(S) image URL.
Use this when:
* The user pastes or attaches an image (clipboard paste gives a file path such
as ~/.edgecrab/images/clipboard_*.png).
* The user provides any local file path ending in .png, .jpg, .jpeg, .gif,
.webp, .bmp, .tiff, .avif, or .ico.
* The user provides an https:// image URL.
* The prompt contains an *** ATTACHED IMAGES block.
* Inside execute_code scripts: `from edgecrab_tools import vision_analyze`.
- **browser_vision** — captures a LIVE SCREENSHOT of the current browser page.
Use this ONLY when you need to visually inspect a web page that is currently
open in the browser. It does NOT accept file paths. It cannot analyze local
files or clipboard images.
Decision rule (apply literally, no exceptions):
file path or *** ATTACHED IMAGES block present → vision_analyze (once)
inspecting the live browser page → browser_vision (once)
CRITICAL — ONE CALL RULE:
After vision_analyze returns a result, respond to the user immediately.
Do NOT call browser_vision as a second step, confirmation, or fallback.
Do NOT call browser_vision after vision_analyze for the same request.
Calling both tools for one image is always wrong.
NEVER call browser_vision when a local image file path is given.";
const CONTEXT_FILE_MAX_CHARS: usize = 20_000;
const TRUNCATION_HEAD_RATIO: f64 = 0.70;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThreatSeverity {
Low,
Medium,
High,
}
#[derive(Debug, Clone, PartialEq)]
pub struct InjectionThreat {
pub pattern_name: String,
pub severity: ThreatSeverity,
}
const INVISIBLE_CHARS: &[char] = &[
'\u{200B}', '\u{200C}', '\u{200D}', '\u{2060}', '\u{FEFF}', '\u{2028}', '\u{2029}', ];
const HOMOGLYPH_RANGES: &[(char, char)] = &[
('\u{0400}', '\u{04FF}'), ('\u{0370}', '\u{03FF}'), ('\u{FF01}', '\u{FF5E}'), ];
pub fn scan_for_injection(text: &str) -> Vec<InjectionThreat> {
let mut threats = Vec::new();
let lower = text.to_lowercase();
let text_patterns: &[(&str, &str, ThreatSeverity)] = &[
("ignore previous", "ignore_previous", ThreatSeverity::High),
(
"ignore all instructions",
"ignore_all_instructions",
ThreatSeverity::High,
),
("disregard", "disregard", ThreatSeverity::Medium),
("override system", "override_system", ThreatSeverity::High),
("you are now", "you_are_now", ThreatSeverity::High),
(
"forget everything",
"forget_everything",
ThreatSeverity::High,
),
(
"new instructions:",
"new_instructions",
ThreatSeverity::High,
),
(
"system prompt:",
"system_prompt_leak",
ThreatSeverity::Medium,
),
];
for &(pattern, name, severity) in text_patterns {
if lower.contains(pattern) {
threats.push(InjectionThreat {
pattern_name: name.to_string(),
severity,
});
}
}
if text.chars().any(|c| INVISIBLE_CHARS.contains(&c)) {
threats.push(InjectionThreat {
pattern_name: "invisible_unicode".to_string(),
severity: ThreatSeverity::High,
});
}
let has_homoglyphs = text.chars().any(|c| {
HOMOGLYPH_RANGES
.iter()
.any(|&(start, end)| c >= start && c <= end)
});
if has_homoglyphs {
threats.push(InjectionThreat {
pattern_name: "homoglyph_characters".to_string(),
severity: ThreatSeverity::Medium,
});
}
threats
}
pub fn strip_yaml_frontmatter(text: &str) -> &str {
let trimmed = text.trim_start();
if !trimmed.starts_with("---") {
return text;
}
let after_first = &trimmed[3..];
if let Some(end_pos) = after_first.find("\n---") {
let remainder = &after_first[end_pos + 4..];
remainder.trim_start_matches('\n').trim_start_matches('\r')
} else {
text
}
}
pub fn truncate_context_file(text: &str) -> Cow<'_, str> {
if text.len() <= CONTEXT_FILE_MAX_CHARS {
return Cow::Borrowed(text);
}
let head_len = (CONTEXT_FILE_MAX_CHARS as f64 * TRUNCATION_HEAD_RATIO) as usize;
let tail_len = CONTEXT_FILE_MAX_CHARS - head_len;
let head = crate::safe_truncate(text, head_len);
let tail_start = crate::safe_char_start(text, text.len() - tail_len);
let tail = &text[tail_start..];
let omitted = text.len() - CONTEXT_FILE_MAX_CHARS;
Cow::Owned(format!(
"{head}\n\n... [{omitted} characters omitted] ...\n\n{tail}"
))
}
pub struct PromptBuilder {
platform: Platform,
skip_context_files: bool,
execution_environment_guidance: Option<String>,
available_tools: Option<Vec<String>>,
}
impl PromptBuilder {
pub fn new(platform: Platform) -> Self {
Self {
platform,
skip_context_files: false,
execution_environment_guidance: None,
available_tools: None,
}
}
pub fn skip_context_files(mut self, skip: bool) -> Self {
self.skip_context_files = skip;
self
}
pub fn execution_environment_guidance(mut self, guidance: Option<String>) -> Self {
self.execution_environment_guidance = guidance.filter(|s| !s.trim().is_empty());
self
}
pub fn available_tools(mut self, tools: Vec<String>) -> Self {
self.available_tools = Some(tools);
self
}
fn has_tool(&self, tool: &str) -> bool {
match &self.available_tools {
None => true,
Some(tools) => tools.iter().any(|t| t == tool),
}
}
fn has_any_tool(&self, tools: &[&str]) -> bool {
tools.iter().any(|tool| self.has_tool(tool))
}
pub fn build(
&self,
override_identity: Option<&str>,
cwd: Option<&Path>,
memory_sections: &[String],
skill_prompt: Option<&str>,
) -> String {
let mut sections: Vec<Cow<'_, str>> = Vec::with_capacity(12);
sections.push(Cow::Borrowed(override_identity.unwrap_or(DEFAULT_IDENTITY)));
if let Some(hint) = platform_hint(&self.platform) {
sections.push(Cow::Borrowed(hint));
}
sections.push(Cow::Owned(format!(
"Current date/time: {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S %Z")
)));
if let Some(ref guidance) = self.execution_environment_guidance {
sections.push(Cow::Borrowed(guidance.as_str()));
}
if !self.skip_context_files {
if let Some(dir) = cwd {
let context_files = discover_context_files(dir);
if !context_files.is_empty() {
let mut context_parts: Vec<String> = Vec::new();
for (name, content) in context_files {
let threats = scan_for_injection(&content);
let critical: Vec<_> = threats
.iter()
.filter(|t| matches!(t.severity, ThreatSeverity::High))
.collect();
if !critical.is_empty() {
let kinds: Vec<&str> =
critical.iter().map(|t| t.pattern_name.as_str()).collect();
tracing::warn!(
file = %name,
threats = ?kinds,
"Prompt injection detected in context file — blocking injection"
);
context_parts.push(format!(
"[BLOCKED: {name} contained potential prompt injection ({kinds}). Content skipped for security.]",
kinds = kinds.join(", ")
));
continue;
}
let stripped = strip_yaml_frontmatter(&content);
let truncated = truncate_context_file(stripped);
context_parts.push(format!("## {name}\n\n{}", truncated.trim()));
}
if !context_parts.is_empty() {
let block = format!(
"# Project Context\n\nThe following project context files have been loaded and should be followed:\n\n{}",
context_parts.join("\n\n")
);
sections.push(Cow::Owned(block));
}
}
}
}
if !memory_sections.is_empty() {
if self.has_tool("memory_write") {
sections.push(Cow::Borrowed(MEMORY_GUIDANCE));
}
for s in memory_sections {
sections.push(Cow::Borrowed(s.as_str()));
}
}
if self.has_tool("session_search") {
sections.push(Cow::Borrowed(SESSION_SEARCH_GUIDANCE));
}
if self.has_tool("skill_manage") {
sections.push(Cow::Borrowed(SKILLS_GUIDANCE));
}
if self.platform != Platform::Cron && self.has_tool("manage_cron_jobs") {
sections.push(Cow::Borrowed(SCHEDULING_GUIDANCE));
}
if self.has_tool("send_message") {
sections.push(Cow::Borrowed(MESSAGE_DELIVERY_GUIDANCE));
}
if self.has_tool("moa") {
sections.push(Cow::Borrowed(MOA_GUIDANCE));
}
if self.has_tool("vision_analyze") {
sections.push(Cow::Borrowed(VISION_GUIDANCE));
}
if self.has_any_tool(&[
"lsp_goto_definition",
"lsp_workspace_symbols",
"lsp_workspace_type_errors",
]) {
sections.push(Cow::Borrowed(LSP_GUIDANCE));
}
if self.has_any_tool(&["apply_patch", "write_file"]) {
sections.push(Cow::Owned(code_editing_guidance()));
}
if let Some(sp) = skill_prompt {
if !sp.is_empty() {
sections.push(Cow::Borrowed(sp));
}
}
sections.join("\n\n")
}
}
fn platform_hint(platform: &Platform) -> Option<&'static str> {
match platform {
Platform::Cli => Some(CLI_HINT),
Platform::Telegram => Some(TELEGRAM_HINT),
Platform::Discord => Some(DISCORD_HINT),
Platform::Whatsapp => Some(WHATSAPP_HINT),
Platform::Slack => Some(SLACK_HINT),
Platform::Feishu => Some(FEISHU_HINT),
Platform::Wecom => Some(WECOM_HINT),
Platform::Signal => Some(SIGNAL_HINT),
Platform::Email => Some(EMAIL_HINT),
Platform::Sms => Some(SMS_HINT),
Platform::Webhook => Some(WEBHOOK_HINT),
Platform::Api => Some(API_HINT),
Platform::Cron => Some(CRON_HINT),
_ => None,
}
}
fn discover_context_files(cwd: &Path) -> Vec<(String, String)> {
if let Some(item) = walk_to_git_root_for_file(
cwd,
&[".hermes.md", "HERMES.md", ".edgecrab.md", "EDGECRAB.md"],
) {
return vec![item];
}
let agents_files = collect_agents_md_files(cwd);
if !agents_files.is_empty() {
return agents_files;
}
if let Some(item) = load_cwd_file(cwd, "CLAUDE.md") {
return vec![item];
}
let mut cursor_items = Vec::new();
if let Some(item) = load_cwd_file(cwd, ".cursorrules") {
cursor_items.push(item);
}
cursor_items.extend(load_cursor_mdc_rules(cwd));
if !cursor_items.is_empty() {
return cursor_items;
}
Vec::new()
}
fn load_cwd_file(cwd: &Path, name: &str) -> Option<(String, String)> {
let path = cwd.join(name);
let content = std::fs::read_to_string(&path).ok()?;
if content.trim().is_empty() {
return None;
}
Some((name.to_string(), content))
}
fn walk_to_git_root_for_file(start: &Path, candidates: &[&str]) -> Option<(String, String)> {
let mut dir = Some(start);
while let Some(d) = dir {
for name in candidates {
let path = d.join(name);
if let Ok(content) = std::fs::read_to_string(&path) {
if !content.trim().is_empty() {
return Some((name.to_string(), content));
}
}
}
if d.join(".git").exists() {
break;
}
dir = d.parent();
}
None
}
fn collect_agents_md_files(cwd: &Path) -> Vec<(String, String)> {
let mut files: Vec<(std::path::PathBuf, String)> = Vec::new(); collect_agents_md_recursive(cwd, cwd, &mut files);
if files.is_empty() {
return Vec::new();
}
files.sort_by_key(|(p, _)| (p.components().count(), p.to_path_buf()));
files
.into_iter()
.map(|(abs_path, content)| {
let rel = abs_path.strip_prefix(cwd).unwrap_or(&abs_path);
let display = rel.to_string_lossy().into_owned();
let display = if display.is_empty() {
"AGENTS.md".to_string()
} else {
display
};
(display, content)
})
.collect()
}
fn collect_agents_md_recursive(
dir: &Path,
_cwd: &Path,
files: &mut Vec<(std::path::PathBuf, String)>,
) {
let agents_path = dir.join("AGENTS.md");
if let Ok(content) = std::fs::read_to_string(&agents_path) {
if !content.trim().is_empty() {
files.push((agents_path, content));
}
}
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
if name.starts_with('.') {
continue;
}
if matches!(
name,
"node_modules" | "__pycache__" | "venv" | ".venv" | "target"
) {
continue;
}
collect_agents_md_recursive(&path, _cwd, files);
}
}
fn load_cursor_mdc_rules(cwd: &Path) -> Vec<(String, String)> {
let cursor_rules_dir = cwd.join(".cursor").join("rules");
if !cursor_rules_dir.is_dir() {
return Vec::new();
}
let entries = match std::fs::read_dir(&cursor_rules_dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
let mut mdc_files: Vec<_> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "mdc"))
.collect();
mdc_files.sort_by_key(|e| e.file_name());
let mut result = Vec::new();
for entry in mdc_files {
let path = entry.path();
if let Ok(content) = std::fs::read_to_string(&path) {
if !content.trim().is_empty() {
let display_name = format!(".cursor/rules/{}", entry.file_name().to_string_lossy());
result.push((display_name, content));
}
}
}
result
}
const MEMORY_MAX_CHARS: usize = 2200;
const USER_MAX_CHARS: usize = 1375;
fn load_memory_file(
mem_dir: &std::path::Path,
filename: &str,
title: &str,
max_chars: usize,
) -> Option<String> {
let content = std::fs::read_to_string(mem_dir.join(filename)).ok()?;
let trimmed = content.trim();
if trimmed.is_empty() {
return None;
}
let truncated = crate::safe_truncate(trimmed, max_chars);
let pct = (trimmed.len() * 100) / max_chars;
let sep = "═".repeat(46);
Some(format!(
"{sep}\n{title} [{pct}% — {}/{max_chars} chars]\n{sep}\n{truncated}",
trimmed.len()
))
}
const DEFAULT_SOUL_MD: &str = "\
You are EdgeCrab — a precise, efficient, and trustworthy AI agent built with Rust. \
You care about correctness, clear explanations, and getting things done. \
You prefer direct answers over verbosity, but never sacrifice clarity for brevity. \
When you're uncertain, you say so. When you make an error, you acknowledge it and fix it. \
You treat users as capable adults and provide the depth they need.";
pub fn load_global_soul(edgecrab_home: &Path) -> Option<String> {
seed_global_soul(edgecrab_home);
let soul_path = edgecrab_home.join("SOUL.md");
let content = std::fs::read_to_string(&soul_path).ok()?;
let trimmed = content.trim();
if trimmed.is_empty() {
return None;
}
let threats = scan_for_injection(trimmed);
let critical: Vec<_> = threats
.iter()
.filter(|t| matches!(t.severity, ThreatSeverity::High))
.collect();
if !critical.is_empty() {
let kinds: Vec<&str> = critical.iter().map(|t| t.pattern_name.as_str()).collect();
tracing::warn!(
threats = ?kinds,
"Prompt injection detected in SOUL.md — using default identity"
);
return None;
}
let truncated = truncate_context_file(trimmed);
Some(truncated.into_owned())
}
fn seed_global_soul(edgecrab_home: &Path) {
let soul_path = edgecrab_home.join("SOUL.md");
if soul_path.exists() {
return;
}
if let Err(e) = std::fs::create_dir_all(edgecrab_home) {
tracing::warn!("Cannot create edgecrab home for SOUL.md seeding: {e}");
return;
}
if let Err(e) = std::fs::write(&soul_path, DEFAULT_SOUL_MD) {
tracing::warn!("Cannot seed SOUL.md: {e}");
} else {
tracing::info!("Seeded default SOUL.md at {}", soul_path.display());
}
}
pub fn load_memory_sections(edgecrab_home: &Path) -> Vec<String> {
let mut sections = Vec::new();
let mem_dir = edgecrab_home.join("memories");
if let Some(s) = load_memory_file(
&mem_dir,
"MEMORY.md",
"MEMORY (your personal notes)",
MEMORY_MAX_CHARS,
) {
sections.push(s);
}
if let Some(s) = load_memory_file(&mem_dir, "USER.md", "USER PROFILE", USER_MAX_CHARS) {
sections.push(s);
}
if let Some(honcho_section) = edgecrab_tools::tools::honcho::load_honcho_user_context() {
sections.push(honcho_section);
}
sections
}
pub fn load_preloaded_skills(edgecrab_home: &Path, skill_names: &[String]) -> String {
if skill_names.is_empty() {
return String::new();
}
let skills_dir = edgecrab_home.join("skills");
let mut parts: Vec<String> = Vec::new();
for name in skill_names {
let candidates = [
skills_dir.join(name).join("SKILL.md"),
edgecrab_home
.join("optional-skills")
.join(name)
.join("SKILL.md"),
];
let mut loaded = false;
for path in &candidates {
if let Ok(content) = std::fs::read_to_string(path) {
let stripped = strip_yaml_frontmatter(content.trim()).trim().to_string();
if !stripped.is_empty() {
parts.push(format!("## Skill: {name}\n\n{stripped}"));
loaded = true;
break;
}
}
}
if !loaded {
tracing::debug!("preloaded skill '{name}' not found in skills directories");
}
}
if parts.is_empty() {
return String::new();
}
format!(
"# Preloaded Skills\n\nThe following skills are preloaded and active:\n\n{}",
parts.join("\n\n---\n\n")
)
}
pub fn load_skill_summary(
edgecrab_home: &Path,
disabled_skills: &[String],
available_tools: Option<&[String]>,
available_toolsets: Option<&[String]>,
) -> Option<String> {
let can_cache = available_tools.is_none() && available_toolsets.is_none();
let cache_key = edgecrab_home.to_path_buf();
if can_cache {
if let Ok(guard) = SKILLS_CACHE.lock() {
if let Some(ref map) = *guard {
if let Some(entry) = map.get(&cache_key) {
if entry.built_at.elapsed() < SKILLS_CACHE_TTL
&& entry.disabled_at_build == disabled_skills
{
tracing::trace!("skills cache hit");
return entry.summary.clone();
}
}
}
}
}
let result = load_skill_summary_inner(
edgecrab_home,
disabled_skills,
available_tools,
available_toolsets,
);
if can_cache {
if let Ok(mut guard) = SKILLS_CACHE.lock() {
let map = guard.get_or_insert_with(std::collections::HashMap::new);
map.insert(
cache_key,
SkillsCacheEntry {
summary: result.clone(),
disabled_at_build: disabled_skills.to_vec(),
built_at: std::time::Instant::now(),
},
);
}
}
result
}
fn load_skill_summary_inner(
edgecrab_home: &Path,
disabled_skills: &[String],
available_tools: Option<&[String]>,
available_toolsets: Option<&[String]>,
) -> Option<String> {
let skills_dir = edgecrab_home.join("skills");
if !skills_dir.is_dir() {
return None;
}
let current_platform = std::env::consts::OS;
let disabled_set: std::collections::HashSet<&str> =
disabled_skills.iter().map(|s| s.as_str()).collect();
let mut skills: Vec<(Option<String>, String, String)> = Vec::new();
fn scan_dir(
dir: &Path,
category: Option<&str>,
current_platform: &str,
disabled_set: &std::collections::HashSet<&str>,
available_tools: Option<&[String]>,
available_toolsets: Option<&[String]>,
skills: &mut Vec<(Option<String>, String, String)>,
) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if name.starts_with('.') {
continue;
}
let skill_md = path.join("SKILL.md");
if skill_md.is_file() {
let content = std::fs::read_to_string(&skill_md).unwrap_or_default();
if !skill_matches_platform(&content, current_platform) {
continue;
}
let frontmatter_name = extract_frontmatter_name(&content);
let display_name = frontmatter_name.as_deref().unwrap_or(&name);
if disabled_set.contains(name.as_str()) || disabled_set.contains(display_name) {
continue;
}
if available_tools.is_some() || available_toolsets.is_some() {
let conditions = extract_skill_conditions(&content);
if !skill_should_show(&conditions, available_tools, available_toolsets) {
continue;
}
}
let description = extract_skill_description(&content)
.or_else(|| {
let desc_md = path.join("DESCRIPTION.md");
std::fs::read_to_string(&desc_md).ok().and_then(|d| {
extract_skill_description(&d).or_else(|| {
d.lines()
.map(|l| l.trim())
.find(|l| !l.is_empty() && !l.starts_with('#'))
.map(|l| {
if l.len() > 200 {
format!("{}…", crate::safe_truncate(l, 197))
} else {
l.to_string()
}
})
})
})
})
.unwrap_or_default();
skills.push((category.map(|s| s.to_string()), name, description));
} else {
let nested_cat = match category {
Some(cat) => format!("{cat}/{name}"),
None => name.clone(),
};
scan_dir(
&path,
Some(&nested_cat),
current_platform,
disabled_set,
available_tools,
available_toolsets,
skills,
);
}
}
}
scan_dir(
&skills_dir,
None,
current_platform,
&disabled_set,
available_tools,
available_toolsets,
&mut skills,
);
if skills.is_empty() {
return None;
}
skills.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let mut output = String::from(
"## Skills (mandatory)\n\
Before replying, scan the skills below. If one specifically matches your task's architecture, platform, and task shape, \
load it with skill_view(name) and follow its instructions. \
Do not load a vaguely related skill when the codebase shape or implementation style differs materially. \
If a skill has issues, fix it with skill_manage(action='edit') — don't wait to be asked.\n\
After difficult/iterative tasks, save the approach as a skill. \
If a skill you loaded was missing steps, had wrong commands, or needed \
pitfalls you discovered, update it before finishing.\n\n\
<available_skills>\n",
);
let mut current_category: Option<&str> = None;
for (cat, name, desc) in &skills {
let cat_str = cat.as_deref();
if cat_str != current_category {
if let Some(c) = cat_str {
output.push_str(&format!("### {c}\n"));
}
current_category = cat_str;
}
if desc.is_empty() {
output.push_str(&format!("- **{name}**\n"));
} else {
output.push_str(&format!("- **{name}**: {desc}\n"));
}
}
output.push_str(
"</available_skills>\n\nIf none match, proceed normally without loading a skill.",
);
Some(output)
}
fn skill_matches_platform(skill_md_content: &str, current_os: &str) -> bool {
let trimmed = skill_md_content.trim_start();
if !trimmed.starts_with("---") {
return true; }
let after_first = &trimmed[3..];
let end_pos = match after_first.find("\n---") {
Some(p) => p,
None => return true,
};
let frontmatter = &after_first[..end_pos];
let mut platforms: Vec<String> = Vec::new();
let mut in_platforms_block = false;
for line in frontmatter.lines() {
let trimmed_line = line.trim();
if let Some(rest) = trimmed_line.strip_prefix("platforms:") {
in_platforms_block = true;
let rest = rest.trim().trim_start_matches('[').trim_end_matches(']');
if !rest.is_empty() {
for item in rest.split(',') {
let v = item
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_lowercase();
if !v.is_empty() {
platforms.push(v);
}
}
in_platforms_block = false; }
} else if in_platforms_block {
if let Some(item) = trimmed_line.strip_prefix("- ") {
let v = item
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_lowercase();
if !v.is_empty() {
platforms.push(v);
}
} else if !trimmed_line.is_empty() && !trimmed_line.starts_with('#') {
in_platforms_block = false; }
}
}
if platforms.is_empty() {
return true; }
let canonical_os = current_os;
platforms.iter().any(|p| p == canonical_os)
}
pub fn extract_skill_description(content: &str) -> Option<String> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let after_first = &trimmed[3..];
let end_pos = after_first.find("\n---")?;
let frontmatter = &after_first[..end_pos];
for line in frontmatter.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("description:") {
let desc = rest.trim().trim_matches('"').trim_matches('\'');
if !desc.is_empty() {
return Some(if desc.len() > 200 {
format!("{}…", crate::safe_truncate(desc, 197))
} else {
desc.to_string()
});
}
}
}
None
}
pub fn extract_frontmatter_name(content: &str) -> Option<String> {
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return None;
}
let after_first = &trimmed[3..];
let end_pos = after_first.find("\n---")?;
let frontmatter = &after_first[..end_pos];
for line in frontmatter.lines() {
let line = line.trim();
if let Some(rest) = line.strip_prefix("name:") {
let n = rest.trim().trim_matches('"').trim_matches('\'');
if !n.is_empty() {
return Some(n.to_string());
}
}
}
None
}
#[derive(Debug, Default)]
pub struct SkillConditions {
pub requires_tools: Vec<String>,
pub requires_toolsets: Vec<String>,
pub fallback_for_tools: Vec<String>,
pub fallback_for_toolsets: Vec<String>,
}
fn extract_skill_conditions(content: &str) -> SkillConditions {
let mut cond = SkillConditions::default();
let trimmed = content.trim_start();
if !trimmed.starts_with("---") {
return cond;
}
let after_first = &trimmed[3..];
let end_pos = match after_first.find("\n---") {
Some(p) => p,
None => return cond,
};
let frontmatter = &after_first[..end_pos];
fn parse_yaml_list(frontmatter: &str, key: &str) -> Vec<String> {
let mut result = Vec::new();
let mut in_block = false;
for line in frontmatter.lines() {
let tl = line.trim();
if let Some(rest) = tl.strip_prefix(key) {
in_block = true;
let rest = rest.trim().trim_start_matches('[').trim_end_matches(']');
if !rest.is_empty() {
for item in rest.split(',') {
let v = item.trim().trim_matches('"').trim_matches('\'').to_string();
if !v.is_empty() {
result.push(v);
}
}
in_block = false;
}
} else if in_block {
if let Some(item) = tl.strip_prefix("- ") {
let v = item.trim().trim_matches('"').trim_matches('\'').to_string();
if !v.is_empty() {
result.push(v);
}
} else if !tl.is_empty() && !tl.starts_with('#') {
in_block = false;
}
}
}
result
}
cond.requires_tools = parse_yaml_list(frontmatter, "requires_tools:");
cond.requires_toolsets = parse_yaml_list(frontmatter, "requires_toolsets:");
cond.fallback_for_tools = parse_yaml_list(frontmatter, "fallback_for_tools:");
cond.fallback_for_toolsets = parse_yaml_list(frontmatter, "fallback_for_toolsets:");
cond
}
fn skill_should_show(
conditions: &SkillConditions,
available_tools: Option<&[String]>,
available_toolsets: Option<&[String]>,
) -> bool {
if let Some(at) = available_tools {
for t in &conditions.fallback_for_tools {
if at.iter().any(|a| a == t) {
return false;
}
}
}
if let Some(ats) = available_toolsets {
for ts in &conditions.fallback_for_toolsets {
if ats.iter().any(|a| a == ts) {
return false;
}
}
}
if let Some(at) = available_tools {
for t in &conditions.requires_tools {
if !at.iter().any(|a| a == t) {
return false;
}
}
}
if let Some(ats) = available_toolsets {
for ts in &conditions.requires_toolsets {
if !ats.iter().any(|a| a == ts) {
return false;
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_prompt_contains_identity() {
let builder = PromptBuilder::new(Platform::Cli);
let prompt = builder.build(None, None, &[], None);
assert!(prompt.contains("EdgeCrab"));
assert!(prompt.contains("Current date/time"));
}
#[test]
fn override_identity() {
let builder = PromptBuilder::new(Platform::Cli);
let prompt = builder.build(Some("You are TestBot."), None, &[], None);
assert!(prompt.starts_with("You are TestBot."));
assert!(!prompt.contains(DEFAULT_IDENTITY));
}
#[test]
fn platform_hint_injected() {
let builder = PromptBuilder::new(Platform::Telegram);
let prompt = builder.build(None, None, &[], None);
assert!(
prompt.contains("MEDIA:"),
"Telegram hint should mention MEDIA:// protocol"
);
assert!(
prompt.contains("Telegram"),
"Telegram hint should mention platform name"
);
}
#[test]
fn cli_hint_injected() {
let builder = PromptBuilder::new(Platform::Cli);
let prompt = builder.build(None, None, &[], None);
assert!(prompt.contains("ANSI colors"));
}
#[test]
fn execution_environment_guidance_is_included() {
let builder = PromptBuilder::new(Platform::Cli).execution_environment_guidance(Some(
"## Execution Filesystem\n\nworkspace info".into(),
));
let prompt = builder.build(None, None, &[], None);
assert!(prompt.contains("## Execution Filesystem"));
assert!(prompt.contains("workspace info"));
}
#[test]
fn memory_sections_included() {
let builder = PromptBuilder::new(Platform::Cli);
let mem = vec!["USER.md content here".into()];
let prompt = builder.build(None, None, &mem, None);
assert!(prompt.contains("persistent memory"));
assert!(prompt.contains("USER.md content here"));
}
#[test]
fn skill_prompt_included() {
let builder = PromptBuilder::new(Platform::Cli);
let prompt = builder.build(None, None, &[], Some("Use skill X for Y."));
assert!(prompt.contains("Use skill X for Y."));
}
#[test]
fn empty_skill_prompt_excluded() {
let builder = PromptBuilder::new(Platform::Cli);
let prompt = builder.build(None, None, &[], Some(""));
let prompt_without_datetime = prompt
.lines()
.filter(|l| !l.starts_with("Current date/time"))
.collect::<Vec<_>>()
.join("\n");
assert!(!prompt_without_datetime.contains("\n\n\n\n"));
}
#[test]
fn skip_context_files_flag() {
let builder = PromptBuilder::new(Platform::Cli).skip_context_files(true);
let tmp = std::env::temp_dir();
let prompt = builder.build(None, Some(&tmp), &[], None);
assert!(!prompt.contains("--- SOUL.md ---"));
}
#[test]
fn context_file_discovery_with_temp_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("AGENTS.md"), "# My agents\ntest content").expect("write");
let builder = PromptBuilder::new(Platform::Cli);
let prompt = builder.build(None, Some(tmp.path()), &[], None);
assert!(prompt.contains("AGENTS.md"));
assert!(prompt.contains("test content"));
}
#[test]
fn soul_file_not_in_context_files() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("SOUL.md"), "soul content").expect("write");
std::fs::write(tmp.path().join(".SOUL.md"), "dot soul content").expect("write");
let files = discover_context_files(tmp.path());
let soul = files
.iter()
.find(|(name, _)| name.to_lowercase().contains("soul"));
assert!(
soul.is_none(),
"SOUL.md should not appear in context files: {soul:?}"
);
}
#[test]
fn hermes_md_wins_over_agents_md() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join(".hermes.md"), "hermes instructions").expect("write");
std::fs::write(tmp.path().join("AGENTS.md"), "agents instructions").expect("write");
let files = discover_context_files(tmp.path());
assert_eq!(files.len(), 1);
assert!(files[0].1.contains("hermes instructions"));
assert!(!files[0].1.contains("agents instructions"));
}
#[test]
fn agents_md_hierarchical_walk() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("AGENTS.md"), "root agents").expect("write");
let subdir = tmp.path().join("subdir");
std::fs::create_dir(&subdir).expect("mkdir");
std::fs::write(subdir.join("AGENTS.md"), "sub agents").expect("write");
let files = discover_context_files(tmp.path());
assert_eq!(files.len(), 2, "should find both AGENTS.md files");
let combined: String = files
.iter()
.map(|(_, c)| c.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(combined.contains("root agents"));
assert!(combined.contains("sub agents"));
}
#[test]
fn agents_md_skips_hidden_dirs() {
let tmp = tempfile::tempdir().expect("tempdir");
std::fs::write(tmp.path().join("AGENTS.md"), "root agents").expect("write");
let hidden = tmp.path().join(".hidden");
std::fs::create_dir(&hidden).expect("mkdir");
std::fs::write(hidden.join("AGENTS.md"), "hidden agents").expect("write");
let files = discover_context_files(tmp.path());
let combined: String = files
.iter()
.map(|(_, c)| c.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(
!combined.contains("hidden agents"),
"content from hidden dirs should be skipped"
);
}
#[test]
fn load_skill_summary_flat_skills() {
let tmp = tempfile::tempdir().expect("tempdir");
let skill_dir = tmp.path().join("skills").join("my-skill");
std::fs::create_dir_all(&skill_dir).expect("mkdir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\ndescription: Does something useful\n---\n# My Skill",
)
.expect("write");
let summary = load_skill_summary(tmp.path(), &[], None, None).expect("Some");
assert!(summary.contains("my-skill"), "skill name missing");
assert!(
summary.contains("Does something useful"),
"description missing"
);
assert!(
!summary.contains("###"),
"unexpected category header for flat skill"
);
}
#[test]
fn load_skill_summary_nested_categories() {
let tmp = tempfile::tempdir().expect("tempdir");
let formatter = tmp.path().join("skills").join("coding").join("formatter");
std::fs::create_dir_all(&formatter).expect("mkdir");
std::fs::write(
formatter.join("SKILL.md"),
"---\ndescription: Formats code\n---\n# Formatter",
)
.expect("write formatter");
let proofreader = tmp
.path()
.join("skills")
.join("writing")
.join("proofreader");
std::fs::create_dir_all(&proofreader).expect("mkdir");
std::fs::write(
proofreader.join("SKILL.md"),
"---\ndescription: Checks grammar\n---\n# Proofreader",
)
.expect("write proofreader");
let summary = load_skill_summary(tmp.path(), &[], None, None).expect("Some");
assert!(
summary.contains("### coding"),
"missing coding header in:\n{summary}"
);
assert!(
summary.contains("### writing"),
"missing writing header in:\n{summary}"
);
assert!(
summary.contains("formatter"),
"missing formatter in:\n{summary}"
);
assert!(
summary.contains("proofreader"),
"missing proofreader in:\n{summary}"
);
assert!(summary.contains("Formats code"), "missing formatter desc");
assert!(
summary.contains("Checks grammar"),
"missing proofreader desc"
);
let coding_pos = summary.find("coding").expect("coding pos");
let writing_pos = summary.find("writing").expect("writing pos");
assert!(
coding_pos < writing_pos,
"coding should sort before writing"
);
}
#[test]
fn load_skill_summary_deeply_nested() {
let tmp = tempfile::tempdir().expect("tempdir");
let axolotl = tmp
.path()
.join("skills")
.join("mlops")
.join("training")
.join("axolotl");
std::fs::create_dir_all(&axolotl).expect("mkdir");
std::fs::write(
axolotl.join("SKILL.md"),
"---\ndescription: Fine-tuning tool\n---\n# Axolotl",
)
.expect("write");
let summary = load_skill_summary(tmp.path(), &[], None, None).expect("Some");
assert!(
summary.contains("axolotl"),
"missing axolotl in:\n{summary}"
);
assert!(
summary.contains("Fine-tuning tool"),
"missing description in:\n{summary}"
);
assert!(
summary.contains("mlops/training"),
"missing nested category in:\n{summary}"
);
}
#[test]
fn load_skill_summary_returns_none_when_no_skills_dir() {
let tmp = tempfile::tempdir().expect("tempdir");
assert!(load_skill_summary(tmp.path(), &[], None, None).is_none());
}
#[test]
fn load_skill_summary_description_md_fallback() {
let tmp = tempfile::tempdir().expect("tempdir");
let skill_dir = tmp.path().join("skills").join("no-desc-skill");
std::fs::create_dir_all(&skill_dir).expect("mkdir");
std::fs::write(
skill_dir.join("SKILL.md"),
"---\nname: no-desc-skill\n---\n# No Desc Skill",
)
.expect("write SKILL.md");
std::fs::write(
skill_dir.join("DESCRIPTION.md"),
"---\ndescription: Fallback description from DESCRIPTION.md\n---\n",
)
.expect("write DESCRIPTION.md");
let summary = load_skill_summary(tmp.path(), &[], None, None).expect("Some");
assert!(summary.contains("no-desc-skill"), "skill name missing");
assert!(
summary.contains("Fallback description from DESCRIPTION.md"),
"DESCRIPTION.md fallback not used:\n{summary}"
);
}
#[test]
fn skill_matches_platform_no_restriction_returns_true() {
assert!(skill_matches_platform(
"---\nname: my-skill\n---\n",
"macos"
));
assert!(skill_matches_platform(
"---\nname: my-skill\n---\n",
"linux"
));
assert!(skill_matches_platform("no frontmatter at all", "windows"));
}
#[test]
fn skill_matches_platform_inline_list() {
let content = "---\nplatforms: [macos, linux]\n---\n";
assert!(skill_matches_platform(content, "macos"));
assert!(skill_matches_platform(content, "linux"));
assert!(!skill_matches_platform(content, "windows"));
}
#[test]
fn skill_matches_platform_block_list() {
let content = "---\nplatforms:\n - linux\n - windows\n---\n";
assert!(skill_matches_platform(content, "linux"));
assert!(skill_matches_platform(content, "windows"));
assert!(!skill_matches_platform(content, "macos"));
}
#[test]
fn load_skill_summary_platform_filtering() {
let tmp = tempfile::tempdir().expect("tempdir");
let skill_all = tmp.path().join("skills").join("all-platforms");
std::fs::create_dir_all(&skill_all).expect("mkdir");
std::fs::write(
skill_all.join("SKILL.md"),
"---\ndescription: Works everywhere\n---\n",
)
.expect("write");
let skill_restricted = tmp.path().join("skills").join("macos-only");
std::fs::create_dir_all(&skill_restricted).expect("mkdir");
std::fs::write(
skill_restricted.join("SKILL.md"),
"---\nplatforms: [no-such-os]\ndescription: Never shown\n---\n",
)
.expect("write");
let summary = load_skill_summary(tmp.path(), &[], None, None).expect("Some");
assert!(
summary.contains("all-platforms"),
"all-platform skill should appear"
);
assert!(
!summary.contains("macos-only"),
"platform-restricted skill should not appear"
);
assert!(
!summary.contains("Never shown"),
"restricted skill desc should not appear"
);
}
#[test]
fn load_skill_summary_disabled_skill_is_hidden() {
let tmp = tempfile::tempdir().expect("tempdir");
for skill in ["active-skill", "disabled-skill"] {
let dir = tmp.path().join("skills").join(skill);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("SKILL.md"),
format!("---\ndescription: Description of {skill}\n---\n"),
)
.expect("write");
}
let disabled = vec!["disabled-skill".to_string()];
let summary = load_skill_summary(tmp.path(), &disabled, None, None).expect("Some");
assert!(
summary.contains("active-skill"),
"active skill should appear"
);
assert!(
!summary.contains("disabled-skill"),
"disabled skill should be hidden"
);
assert!(
!summary.contains("Description of disabled-skill"),
"disabled skill desc should be hidden"
);
}
#[test]
fn load_skill_summary_multiple_disabled_skills() {
let tmp = tempfile::tempdir().expect("tempdir");
for skill in ["skill-a", "skill-b", "skill-c"] {
let dir = tmp.path().join("skills").join(skill);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("SKILL.md"),
format!("---\ndescription: Desc of {skill}\n---\n"),
)
.expect("write");
}
let disabled = vec!["skill-a".to_string(), "skill-c".to_string()];
let summary = load_skill_summary(tmp.path(), &disabled, None, None).expect("Some");
assert!(summary.contains("skill-b"), "skill-b should appear");
assert!(!summary.contains("skill-a"), "skill-a should be hidden");
assert!(!summary.contains("skill-c"), "skill-c should be hidden");
}
#[test]
fn load_skill_summary_empty_disabled_shows_all() {
let tmp = tempfile::tempdir().expect("tempdir");
for skill in ["skill-x", "skill-y"] {
let dir = tmp.path().join("skills").join(skill);
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("SKILL.md"),
format!("---\ndescription: Desc of {skill}\n---\n"),
)
.expect("write");
}
let summary = load_skill_summary(tmp.path(), &[], None, None).expect("Some");
assert!(summary.contains("skill-x"), "skill-x should appear");
assert!(summary.contains("skill-y"), "skill-y should appear");
}
#[test]
fn load_skill_summary_hides_requires_tools_when_tool_list_is_known_empty() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("skills").join("terminal-only");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("SKILL.md"),
"---\nrequires_tools: [terminal]\ndescription: Terminal helper\n---\n",
)
.expect("write");
let summary = load_skill_summary(tmp.path(), &[], Some(&[]), None);
assert!(
summary.is_none(),
"no skills should remain when the only skill requires an unavailable tool"
);
}
#[test]
fn load_skill_summary_filters_by_available_toolsets() {
let tmp = tempfile::tempdir().expect("tempdir");
let dir = tmp.path().join("skills").join("browser-helper");
std::fs::create_dir_all(&dir).expect("mkdir");
std::fs::write(
dir.join("SKILL.md"),
"---\nrequires_toolsets: [browser]\ndescription: Browser helper\n---\n",
)
.expect("write");
let file_only = ["file".to_string()];
let browser_only = ["browser".to_string()];
let hidden = load_skill_summary(tmp.path(), &[], None, Some(&file_only));
let visible =
load_skill_summary(tmp.path(), &[], None, Some(&browser_only)).expect("Some visible");
assert!(
hidden.is_none(),
"skills gated on unavailable toolsets must be fully omitted"
);
assert!(
visible.contains("browser-helper"),
"skills gated on available toolsets must be shown"
);
}
#[test]
fn skills_cache_different_homes_are_independent() {
let tmp1 = tempfile::tempdir().expect("tempdir1");
let tmp2 = tempfile::tempdir().expect("tempdir2");
let dir1 = tmp1.path().join("skills").join("home1-skill");
std::fs::create_dir_all(&dir1).expect("mkdir");
std::fs::write(dir1.join("SKILL.md"), "---\ndescription: Home1 only\n---\n")
.expect("write");
let dir2 = tmp2.path().join("skills").join("home2-skill");
std::fs::create_dir_all(&dir2).expect("mkdir");
std::fs::write(dir2.join("SKILL.md"), "---\ndescription: Home2 only\n---\n")
.expect("write");
let summary1 = load_skill_summary(tmp1.path(), &[], None, None).expect("Some from tmp1");
let summary2 = load_skill_summary(tmp2.path(), &[], None, None).expect("Some from tmp2");
assert!(
summary1.contains("home1-skill"),
"tmp1 should see home1-skill"
);
assert!(
!summary1.contains("home2-skill"),
"tmp1 should not see home2-skill"
);
assert!(
summary2.contains("home2-skill"),
"tmp2 should see home2-skill"
);
assert!(
!summary2.contains("home1-skill"),
"tmp2 should not see home1-skill"
);
}
#[test]
fn scheduling_guidance_present_for_cli() {
let builder = PromptBuilder::new(Platform::Cli).skip_context_files(true);
let prompt = builder.build(None, None, &[], None);
assert!(
prompt.contains("manage_cron_jobs"),
"CLI system prompt must include scheduling guidance with manage_cron_jobs reference"
);
assert!(
prompt.contains("every morning") || prompt.contains("schedule"),
"scheduling guidance should mention natural scheduling examples"
);
}
#[test]
fn scheduling_guidance_absent_for_cron_platform() {
let builder = PromptBuilder::new(Platform::Cron).skip_context_files(true);
let prompt = builder.build(None, None, &[], None);
assert!(
prompt.contains("scheduled cron job"),
"cron system prompt must include CRON_HINT"
);
assert!(
!prompt.contains("manage_cron_jobs(action='create')"),
"cron system prompt must NOT include scheduling guidance"
);
}
#[test]
fn message_delivery_guidance_present_when_send_message_available() {
let builder = PromptBuilder::new(Platform::Whatsapp)
.skip_context_files(true)
.available_tools(vec!["send_message".to_string()]);
let prompt = builder.build(None, None, &[], None);
assert!(
prompt
.contains("Use send_message only when the user explicitly wants content delivered"),
"message delivery guidance must explain when cross-platform delivery should happen"
);
assert!(
prompt.contains("Do not claim you cannot send messages when send_message is available"),
"message delivery guidance must explicitly block false inability claims"
);
assert!(
prompt.contains("redundant confirmation"),
"message delivery guidance must discourage unnecessary send confirmations"
);
}
#[test]
fn message_delivery_guidance_absent_when_send_message_unavailable() {
let builder = PromptBuilder::new(Platform::Cli)
.skip_context_files(true)
.available_tools(vec!["read_file".to_string()]);
let prompt = builder.build(None, None, &[], None);
assert!(
!prompt
.contains("Use send_message only when the user explicitly wants content delivered"),
"message delivery guidance must not appear when send_message is unavailable"
);
}
#[test]
fn moa_guidance_present_when_moa_available() {
let builder = PromptBuilder::new(Platform::Cli)
.skip_context_files(true)
.available_tools(vec!["moa".to_string()]);
let prompt = builder.build(None, None, &[], None);
assert!(
prompt.contains("call the `moa` tool directly"),
"moa guidance must explicitly tell the model to call the canonical tool"
);
assert!(
prompt.contains("Do not claim the feature is unavailable"),
"moa guidance must block false unavailability claims"
);
}
#[test]
fn moa_guidance_absent_when_moa_unavailable() {
let builder = PromptBuilder::new(Platform::Cli)
.skip_context_files(true)
.available_tools(vec!["read_file".to_string()]);
let prompt = builder.build(None, None, &[], None);
assert!(
!prompt.contains("call the `moa` tool directly"),
"moa guidance must not appear when moa is unavailable"
);
}
#[test]
fn vision_guidance_present_when_vision_analyze_available() {
let builder = PromptBuilder::new(Platform::Cli)
.skip_context_files(true)
.available_tools(vec![
"vision_analyze".to_string(),
"browser_vision".to_string(),
]);
let prompt = builder.build(None, None, &[], None);
assert!(
prompt.contains("NEVER call browser_vision"),
"vision guidance must include unambiguous rule against browser_vision for local files"
);
assert!(
prompt.contains("vision_analyze"),
"vision guidance must name vision_analyze as the correct tool"
);
assert!(
prompt.contains("ATTACHED IMAGES"),
"vision guidance must reference the ATTACHED IMAGES block marker"
);
}
#[test]
fn vision_guidance_absent_when_vision_analyze_not_available() {
let builder = PromptBuilder::new(Platform::Cli)
.skip_context_files(true)
.available_tools(vec!["read_file".to_string(), "write_file".to_string()]);
let prompt = builder.build(None, None, &[], None);
assert!(
!prompt.contains("NEVER call browser_vision"),
"vision guidance must NOT appear when vision_analyze is not in tool list"
);
}
#[test]
fn lsp_guidance_present_when_lsp_tools_are_available() {
let builder = PromptBuilder::new(Platform::Cli)
.skip_context_files(true)
.available_tools(vec![
"read_file".to_string(),
"lsp_goto_definition".to_string(),
"lsp_workspace_type_errors".to_string(),
]);
let prompt = builder.build(None, None, &[], None);
assert!(
prompt.contains("## Language Server Usage"),
"LSP guidance must be injected when LSP tools are available"
);
assert!(
prompt.contains("exceeds the common 9-operation baseline"),
"LSP guidance should make the richer EdgeCrab surface explicit to the model"
);
}
#[test]
fn lsp_guidance_absent_when_lsp_tools_are_unavailable() {
let builder = PromptBuilder::new(Platform::Cli)
.skip_context_files(true)
.available_tools(vec!["read_file".to_string(), "search_files".to_string()]);
let prompt = builder.build(None, None, &[], None);
assert!(
!prompt.contains("## Language Server Usage"),
"LSP guidance must not be injected when no LSP tools are available"
);
}
#[test]
fn code_editing_guidance_present_when_mutation_tools_are_available() {
let builder = PromptBuilder::new(Platform::Cli)
.skip_context_files(true)
.available_tools(vec![
"read_file".to_string(),
"apply_patch".to_string(),
"write_file".to_string(),
]);
let prompt = builder.build(None, None, &[], None);
assert!(
prompt.contains("## Code Editing Execution"),
"code-editing guidance must be injected when mutation tools are available"
);
assert!(
prompt.contains("ready for a patch?"),
"guidance must explicitly prohibit waiting for patch approval when not requested"
);
assert!(
prompt.contains("Do not add bonus summary files"),
"guidance must explicitly forbid scope creep after the requested artifact is complete"
);
assert!(
prompt.contains("do NOT attempt a single giant write_file or execute_code payload"),
"guidance must forbid monolithic payload writes for large artifacts"
);
assert!(
prompt.contains("32768 bytes (32 KiB)"),
"guidance must surface the hard mutation payload limit"
);
}
#[test]
fn code_editing_guidance_absent_without_mutation_tools() {
let builder = PromptBuilder::new(Platform::Cli)
.skip_context_files(true)
.available_tools(vec!["read_file".to_string(), "search_files".to_string()]);
let prompt = builder.build(None, None, &[], None);
assert!(
!prompt.contains("## Code Editing Execution"),
"code-editing guidance must not appear without mutation tools"
);
}
#[test]
fn skill_conditions_hide_requires_when_known_toolset_is_empty() {
let conditions = SkillConditions {
requires_tools: vec!["terminal".to_string()],
..Default::default()
};
assert!(
!skill_should_show(&conditions, Some(&[]), None),
"requires_tools must hide skills when tool availability is known and empty"
);
assert!(
skill_should_show(&conditions, None, None),
"requires_tools must remain permissive when tool availability is unknown"
);
}
#[test]
fn skill_conditions_hide_requires_when_known_toolset_missing() {
let conditions = SkillConditions {
requires_toolsets: vec!["browser".to_string()],
..Default::default()
};
assert!(
!skill_should_show(
&conditions,
None,
Some(&["file".to_string(), "terminal".to_string()])
),
"requires_toolsets must hide skills when the required toolset is absent"
);
}
}