- Up to three chats side by side (#/s/a+b+c), "Open beside" in the sidebar menu
- File manager tabs of a chat stored per user on the server (/sessions/{id}/tabs)
- Personal modes for every user (Settings → My modes, /account/modes)
- Budget meters (total, provider, images, opencode Go windows) with push warnings
for admins at the warning share and 100 %, shown in the status overlay
- scripts/move-server.sh: move to another Docker host with bind mounts (Unraid);
scripts/smoke-test.sh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
422 lines
20 KiB
Rust
422 lines
20 KiB
Rust
//! Agents (modes), system prompts, project instructions, slash commands and skills.
|
||
//!
|
||
//! Compatible with Claude Code / opencode layouts: AGENTS.md, CLAUDE.md,
|
||
//! .claude/{agents,commands,skills}, .codecraft/{agents,commands,skills}.
|
||
|
||
use crate::app::App;
|
||
use crate::tools::{Tool, ToolCtx, ToolOutput, arg_str};
|
||
use anyhow::Result;
|
||
use async_trait::async_trait;
|
||
use serde::Serialize;
|
||
use serde_json::{Value, json};
|
||
use std::collections::BTreeMap;
|
||
use std::path::{Path, PathBuf};
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct Agent {
|
||
pub name: String,
|
||
pub description: String,
|
||
/// "primary" (selectable mode) or "subagent" (task tool only).
|
||
pub mode: String,
|
||
#[serde(skip)]
|
||
pub prompt: String,
|
||
/// Allowed tools; None = all.
|
||
pub tools: Option<Vec<String>>,
|
||
pub read_only: bool,
|
||
/// "coding" or "chat" (plain conversation prompt).
|
||
pub style: String,
|
||
pub model: Option<String>,
|
||
/// "builtin", "config" or "file".
|
||
pub source: String,
|
||
/// Max. instances running at the same time.
|
||
pub max_parallel: Option<usize>,
|
||
/// Review loop after each turn (None/0 rounds = off).
|
||
pub refine: Option<crate::config::RefineConfig>,
|
||
/// Source file of Markdown agents.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub path: Option<String>,
|
||
}
|
||
|
||
pub use codecraft_core::prompts::*;
|
||
|
||
|
||
pub fn builtin_agents(clean_room: bool) -> Vec<Agent> {
|
||
let craft = craft_prompt(clean_room);
|
||
vec![
|
||
Agent { name: "build".into(), description: "Full access: implement, edit, run".into(), mode: "primary".into(), prompt: BUILD_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||
Agent { name: "plan".into(), description: "Read-only: analyse and propose a plan".into(), mode: "primary".into(), prompt: PLAN_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||
Agent { name: "chat".into(), description: "Just talk: answers, explanations, web search – no files or shell".into(), mode: "primary".into(), prompt: CHAT_PROMPT.into(), tools: Some(vec!["web_search".into(), "web_fetch".into()]), read_only: true, style: "chat".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||
Agent { name: "craft".into(), description: "Study a target, build your own version, check and refine".into(), mode: "primary".into(), prompt: craft, tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||
Agent { name: "general".into(), description: "General-purpose subagent for multi-step tasks".into(), mode: "subagent".into(), prompt: GENERAL_PROMPT.into(), tools: None, read_only: false, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||
Agent { name: "explore".into(), description: "Read-only subagent for fast codebase searches".into(), mode: "subagent".into(), prompt: EXPLORE_PROMPT.into(), tools: None, read_only: true, style: "coding".into(), model: None, source: "builtin".into(), max_parallel: None, refine: None, path: None },
|
||
]
|
||
}
|
||
|
||
/// Parse "---\nkey: value\n---\nbody" frontmatter (flat keys only).
|
||
pub fn frontmatter(text: &str) -> (BTreeMap<String, String>, String) {
|
||
let mut meta = BTreeMap::new();
|
||
let t = text.trim_start_matches('\u{feff}');
|
||
if let Some(rest) = t.strip_prefix("---") {
|
||
if let Some(end) = rest.find("\n---") {
|
||
for line in rest[..end].lines() {
|
||
if let Some((k, v)) = line.split_once(':') {
|
||
meta.insert(k.trim().to_string(), v.trim().trim_matches('"').trim_matches('\'').to_string());
|
||
}
|
||
}
|
||
let body = rest[end + 4..].trim_start_matches(['-', '\n', '\r']).to_string();
|
||
return (meta, body);
|
||
}
|
||
}
|
||
(meta, t.to_string())
|
||
}
|
||
|
||
/// Resource directories, lowest priority first: data dir, workspace, project.
|
||
/// Skills and commands shipped with CodeCraft (read-only for agents).
|
||
pub fn builtin_resources() -> PathBuf {
|
||
if let Ok(p) = std::env::var("CODECRAFT_RESOURCES") {
|
||
return PathBuf::from(p);
|
||
}
|
||
["/app/resources", "./resources", "./server/resources"].iter().map(PathBuf::from).find(|p| p.is_dir()).unwrap_or_else(|| PathBuf::from("/app/resources"))
|
||
}
|
||
|
||
/// Resource directories, lowest priority first: built-in, data dir, workspace, project.
|
||
fn resource_dirs(app: &App, root: &Path, cwd: &Path, kind: &str) -> Vec<PathBuf> {
|
||
let mut dirs = vec![builtin_resources().join(kind), app.data_dir().join(kind)];
|
||
let ws = root.to_path_buf();
|
||
let mut chain = vec![ws.clone()];
|
||
if cwd != ws {
|
||
chain.push(cwd.to_path_buf());
|
||
}
|
||
for base in chain {
|
||
dirs.push(base.join(".claude").join(kind));
|
||
dirs.push(base.join(".codecraft").join(kind));
|
||
}
|
||
dirs
|
||
}
|
||
|
||
pub fn agents(app: &App, root: &Path, cwd: &Path) -> Vec<Agent> {
|
||
let cfg = app.config();
|
||
let mut map: BTreeMap<String, Agent> =
|
||
builtin_agents(cfg.craft.clean_room).into_iter().map(|a| (a.name.clone(), a)).collect();
|
||
// Markdown agents (Claude Code format).
|
||
for dir in resource_dirs(app, root, cwd, "agents") {
|
||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||
for e in entries.flatten() {
|
||
let p = e.path();
|
||
if p.extension().and_then(|x| x.to_str()) != Some("md") {
|
||
continue;
|
||
}
|
||
let Ok(text) = std::fs::read_to_string(&p) else { continue };
|
||
let (meta, body) = frontmatter(&text);
|
||
let name = meta.get("name").cloned().unwrap_or_else(|| p.file_stem().unwrap().to_string_lossy().into());
|
||
let tools = meta.get("tools").map(|t| t.split(',').map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()).collect());
|
||
map.insert(name.clone(), Agent {
|
||
name,
|
||
description: meta.get("description").cloned().unwrap_or_default(),
|
||
mode: meta.get("mode").cloned().unwrap_or_else(|| "subagent".into()),
|
||
prompt: body,
|
||
tools,
|
||
read_only: meta.get("read_only").is_some_and(|v| v == "true"),
|
||
style: meta.get("style").filter(|v| *v == "chat").cloned().unwrap_or_else(|| "coding".into()),
|
||
model: meta.get("model").filter(|m| m.contains('/')).cloned(),
|
||
source: "file".into(),
|
||
max_parallel: meta.get("max_parallel").and_then(|v| v.parse().ok()),
|
||
refine: meta.get("refine_rounds").and_then(|v| v.parse().ok()).map(|rounds| crate::config::RefineConfig { rounds, model: meta.get("refine_model").cloned(), criteria: None }),
|
||
path: Some(p.display().to_string()),
|
||
});
|
||
}
|
||
}
|
||
// Config agents override everything.
|
||
for (name, a) in &cfg.agents {
|
||
let base = map.get(name).cloned();
|
||
map.insert(name.clone(), Agent {
|
||
name: name.clone(),
|
||
description: a.description.clone().or(base.as_ref().map(|b| b.description.clone())).unwrap_or_default(),
|
||
mode: a.mode.clone().or(base.as_ref().map(|b| b.mode.clone())).unwrap_or_else(|| "subagent".into()),
|
||
prompt: a.prompt.clone().or(base.as_ref().map(|b| b.prompt.clone())).unwrap_or_default(),
|
||
tools: a.tools.clone().or(base.as_ref().and_then(|b| b.tools.clone())),
|
||
read_only: a.read_only.unwrap_or_else(|| base.as_ref().is_some_and(|b| b.read_only)),
|
||
style: a.style.clone().filter(|s| s == "chat" || s == "coding").or(base.as_ref().map(|b| b.style.clone())).unwrap_or_else(|| "coding".into()),
|
||
model: a.model.clone().filter(|m| !m.is_empty()).or(base.as_ref().and_then(|b| b.model.clone())),
|
||
source: "config".into(),
|
||
max_parallel: a.max_parallel.or(base.as_ref().and_then(|b| b.max_parallel)),
|
||
refine: a.refine.clone().or(base.as_ref().and_then(|b| b.refine.clone())),
|
||
path: None,
|
||
});
|
||
}
|
||
map.into_values().collect()
|
||
}
|
||
|
||
/// Personal modes a user created for themselves (user setting "modes").
|
||
pub fn personal_modes(app: &App, user_id: &str) -> BTreeMap<String, crate::config::AgentConfig> {
|
||
app.store.user_setting(user_id, "modes").unwrap_or_default()
|
||
}
|
||
|
||
/// All agents plus the user's personal modes. Personal modes never replace built-in,
|
||
/// admin or project ones of the same name.
|
||
pub fn agents_for(app: &App, root: &Path, cwd: &Path, user_id: Option<&str>) -> Vec<Agent> {
|
||
let mut list = agents(app, root, cwd);
|
||
let Some(uid) = user_id else { return list };
|
||
for (name, a) in personal_modes(app, uid) {
|
||
if list.iter().any(|x| x.name == name) {
|
||
continue;
|
||
}
|
||
list.push(Agent {
|
||
name,
|
||
description: a.description.unwrap_or_default(),
|
||
mode: "primary".into(),
|
||
prompt: a.prompt.unwrap_or_default(),
|
||
tools: a.tools,
|
||
read_only: a.read_only.unwrap_or(false),
|
||
style: a.style.filter(|s| s == "chat").unwrap_or_else(|| "coding".into()),
|
||
model: a.model.filter(|m| !m.is_empty()),
|
||
source: "personal".into(),
|
||
max_parallel: None,
|
||
refine: a.refine,
|
||
path: None,
|
||
});
|
||
}
|
||
list
|
||
}
|
||
|
||
pub fn find_agent_for(app: &App, root: &Path, cwd: &Path, name: &str, user_id: Option<&str>) -> Agent {
|
||
agents_for(app, root, cwd, user_id)
|
||
.into_iter()
|
||
.find(|a| a.name == name)
|
||
.unwrap_or_else(|| builtin_agents(true).remove(0))
|
||
}
|
||
|
||
/// AGENTS.md / CLAUDE.md files from the data dir, workspace root down to cwd.
|
||
fn instruction_files(app: &App, root: &Path, cwd: &Path) -> Vec<(PathBuf, String)> {
|
||
let mut dirs = vec![app.data_dir()];
|
||
let ws = root.to_path_buf();
|
||
let mut d = cwd.to_path_buf();
|
||
let mut chain = Vec::new();
|
||
loop {
|
||
chain.push(d.clone());
|
||
if d == ws || !d.starts_with(&ws) || !d.pop() {
|
||
break;
|
||
}
|
||
}
|
||
chain.reverse();
|
||
dirs.extend(chain);
|
||
let mut out = Vec::new();
|
||
for dir in dirs {
|
||
for name in ["AGENTS.md", "CLAUDE.md", ".claude/CLAUDE.md"] {
|
||
let p = dir.join(name);
|
||
if let Ok(text) = std::fs::read_to_string(&p) {
|
||
if !text.trim().is_empty() {
|
||
out.push((p, text));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The plain chat mode: no coding instructions, project files or memory – only the
|
||
/// date, the model and what the user keeps in their personal memory.
|
||
fn chat_prompt(app: &App, agent: &Agent, model: &str, owner: Option<&crate::store::User>) -> String {
|
||
let mut s = agent.prompt.clone();
|
||
s.push_str(&format!("\n\n# Context\n- Date: {}\n- Model: {model}\n", chrono::Local::now().format("%A, %Y-%m-%d")));
|
||
if let Some(u) = owner {
|
||
let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default();
|
||
if !memory.trim().is_empty() {
|
||
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
|
||
}
|
||
}
|
||
s
|
||
}
|
||
|
||
pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &str, permission_mode: &str, owner: Option<&crate::store::User>) -> String {
|
||
if agent.style == "chat" {
|
||
return chat_prompt(app, agent, model, owner);
|
||
}
|
||
let mut s = String::from(BASE_PROMPT);
|
||
s.push_str("\n\n");
|
||
s.push_str(&agent.prompt);
|
||
if std::env::var("HOME").is_ok_and(|h| h.starts_with("/data")) {
|
||
s.push_str("\n\n# Installing tools\nYou run without root (no apt/sudo). Install what you need in user space – it persists: Python packages with `pip install <pkg>` (user site) or a venv, CLI tools with `pipx install`, Rust with `curl https://sh.rustup.rs -sSf | sh -s -- -y` (then `~/.cargo/bin` is on PATH), Node packages with `npm i -g`, and other binaries (e.g. Godot) by downloading them to `~/.local/bin`. Check with `which`/`--version` first whether a tool is already installed.");
|
||
}
|
||
if permission_mode == "auto" && !agent.read_only {
|
||
s.push_str(AUTO_MODE_PROMPT);
|
||
}
|
||
let git = cwd.join(".git").exists();
|
||
s.push_str(&format!(
|
||
"\n\n# Environment\n- Working directory: {}\n- Project root: {}\n- Git repository: {}\n- Platform: {} ({})\n- Model: {model}\n- Date: {}\n",
|
||
cwd.display(),
|
||
root.display(),
|
||
if git { "yes" } else { "no" },
|
||
std::env::consts::OS,
|
||
std::env::consts::ARCH,
|
||
chrono::Local::now().format("%Y-%m-%d"),
|
||
));
|
||
if !agent.read_only || agent.name == "plan" {
|
||
let subs: Vec<Agent> = agents(app, root, cwd).into_iter().filter(|a| a.mode != "primary").collect();
|
||
if !subs.is_empty() {
|
||
s.push_str("\n# Subagents\nAvailable for the task tool (subagent_type). Delegate work that fits a subagent's description – especially writing, summarizing and translating – and use background=true for tasks whose result you do not need right away.\n");
|
||
for a in &subs {
|
||
let model = a.model.as_deref().map(|m| format!(" [model: {m}]")).unwrap_or_default();
|
||
let ro = if a.read_only { " (read-only)" } else { "" };
|
||
s.push_str(&format!("- {}{ro}{model}: {}\n", a.name, a.description));
|
||
}
|
||
}
|
||
}
|
||
let deferred = app.mcp.deferred_summary(&app.config(), owner);
|
||
if !deferred.is_empty() {
|
||
s.push_str("\n# On-demand MCP servers\nThese servers have many tools that are not listed directly. Find tools with mcp_search, then call them with mcp_call:\n");
|
||
for (server, n, names) in &deferred {
|
||
s.push_str(&format!("- {server} ({n} tools, e.g. {})\n", names.join(", ")));
|
||
}
|
||
}
|
||
let skills = skills(app, root, cwd);
|
||
if !skills.is_empty() {
|
||
s.push_str("\n# Skills\nLoad a skill with the skill tool when the task matches its description:\n");
|
||
for sk in &skills {
|
||
s.push_str(&format!("- {}: {}\n", sk.name, sk.description));
|
||
}
|
||
}
|
||
if let Some(u) = owner {
|
||
let memory: String = app.store.user_setting(&u.id, "memory").unwrap_or_default();
|
||
if !memory.trim().is_empty() {
|
||
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
|
||
}
|
||
}
|
||
match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) {
|
||
Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::<String>())),
|
||
Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"),
|
||
Err(_) => {}
|
||
}
|
||
let files = instruction_files(app, root, cwd);
|
||
if !files.is_empty() {
|
||
s.push_str("\n# Project instructions\nThese instructions come from the user's instruction files and OVERRIDE default behaviour.\n");
|
||
for (p, text) in files {
|
||
s.push_str(&format!("\n<instructions path=\"{}\">\n{}\n</instructions>\n", p.display(), text.trim()));
|
||
}
|
||
}
|
||
s
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Slash commands
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct Command {
|
||
pub name: String,
|
||
pub description: String,
|
||
#[serde(skip)]
|
||
pub body: String,
|
||
pub agent: Option<String>,
|
||
/// Craft: switch the session to clean room (true) or deep (false).
|
||
#[serde(skip)]
|
||
pub clean_room: Option<bool>,
|
||
}
|
||
|
||
pub fn commands(app: &App, root: &Path, cwd: &Path) -> Vec<Command> {
|
||
let mut map = BTreeMap::new();
|
||
for dir in resource_dirs(app, root, cwd, "commands") {
|
||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||
for e in entries.flatten() {
|
||
let p = e.path();
|
||
if p.extension().and_then(|x| x.to_str()) != Some("md") {
|
||
continue;
|
||
}
|
||
let Ok(text) = std::fs::read_to_string(&p) else { continue };
|
||
let (meta, body) = frontmatter(&text);
|
||
let name = p.file_stem().unwrap().to_string_lossy().to_string();
|
||
let description = meta
|
||
.get("description")
|
||
.cloned()
|
||
.unwrap_or_else(|| body.lines().find(|l| !l.trim().is_empty()).unwrap_or("").chars().take(80).collect());
|
||
let clean_room = meta.get("clean_room").and_then(|v| v.parse::<bool>().ok());
|
||
map.insert(name.clone(), Command { name, description, body, agent: meta.get("agent").cloned(), clean_room });
|
||
}
|
||
}
|
||
map.into_values().collect()
|
||
}
|
||
|
||
/// Expand "/name args" into the command's prompt. Returns None if not a known command.
|
||
pub fn expand_command(app: &App, root: &Path, cwd: &Path, text: &str) -> Option<(String, Option<String>, Option<bool>)> {
|
||
let rest = text.strip_prefix('/')?;
|
||
let (name, args) = rest.split_once(char::is_whitespace).unwrap_or((rest, ""));
|
||
let cmd = commands(app, root, cwd).into_iter().find(|c| c.name == name)?;
|
||
let args = args.trim();
|
||
let mut body = cmd.body.replace("$ARGUMENTS", args);
|
||
for (i, a) in args.split_whitespace().enumerate() {
|
||
body = body.replace(&format!("${}", i + 1), a);
|
||
}
|
||
if !cmd.body.contains("$ARGUMENTS") && !args.is_empty() {
|
||
body.push_str(&format!("\n\n{args}"));
|
||
}
|
||
Some((body, cmd.agent, cmd.clean_room))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Skills
|
||
|
||
#[derive(Debug, Clone, Serialize)]
|
||
pub struct Skill {
|
||
pub name: String,
|
||
pub description: String,
|
||
pub path: PathBuf,
|
||
}
|
||
|
||
pub fn skills(app: &App, root: &Path, cwd: &Path) -> Vec<Skill> {
|
||
let mut map = BTreeMap::new();
|
||
for dir in resource_dirs(app, root, cwd, "skills") {
|
||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||
for e in entries.flatten() {
|
||
let p = e.path().join("SKILL.md");
|
||
let Ok(text) = std::fs::read_to_string(&p) else { continue };
|
||
let (meta, _) = frontmatter(&text);
|
||
let name = meta.get("name").cloned().unwrap_or_else(|| e.file_name().to_string_lossy().into());
|
||
map.insert(name.clone(), Skill { name, description: meta.get("description").cloned().unwrap_or_default(), path: p });
|
||
}
|
||
}
|
||
map.into_values().collect()
|
||
}
|
||
|
||
pub struct SkillTool;
|
||
|
||
#[async_trait]
|
||
impl Tool for SkillTool {
|
||
fn name(&self) -> &str {
|
||
"skill"
|
||
}
|
||
fn description(&self) -> String {
|
||
"Load a skill (packaged instructions for a kind of task) by name. Follow the returned instructions.".into()
|
||
}
|
||
fn schema(&self) -> Value {
|
||
json!({"type": "object", "properties": {"name": {"type": "string"}}, "required": ["name"]})
|
||
}
|
||
fn read_only(&self) -> bool {
|
||
true
|
||
}
|
||
async fn run(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
|
||
let name = arg_str(&input, "name")?;
|
||
let Some(sk) = skills(&ctx.app, &ctx.root(), &ctx.cwd).into_iter().find(|s| s.name == name) else {
|
||
return Ok(ToolOutput::error(format!("unknown skill '{name}'")));
|
||
};
|
||
let text = std::fs::read_to_string(&sk.path)?;
|
||
let (_, body) = frontmatter(&text);
|
||
let dir = sk.path.parent().unwrap_or(Path::new("."));
|
||
Ok(ToolOutput::text(format!("Skill '{name}' (base directory: {}):\n\n{body}", dir.display())))
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn parses_frontmatter() {
|
||
let (m, b) = frontmatter("---\nname: x\ndescription: \"does y\"\n---\nBody here\n");
|
||
assert_eq!(m["name"], "x");
|
||
assert_eq!(m["description"], "does y");
|
||
assert_eq!(b.trim(), "Body here");
|
||
let (m2, b2) = frontmatter("no frontmatter");
|
||
assert!(m2.is_empty());
|
||
assert_eq!(b2, "no frontmatter");
|
||
}
|
||
}
|