Add terminal client, memory, model fallback, version and install improvements
- codecraft: full-screen terminal client (ratatui) – local tools and files, models via the CodeCraft server gateway or own providers (standalone with a guided setup), slash commands, @files, permission dialogs, resume, update - Static Linux CLI builds (x86_64/aarch64, cargo-zigbuild) served by the server: /cli/install.sh and /cli/download/<target> - Project memory (remember tool, .codecraft/memory.md) and personal memory - Model fallback when a host is unreachable or a budget is used up - Version/commit in UI and health, daily release check, "What's new" dialog - PWA: install button, maskable icon, manifest id and screenshots - Case-insensitive detection of connection errors for retries Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa1f8cf016
commit
6dffcb501a
52 files changed
+6477
-765
No files matched your search
@@ -0,0 +1,552 @@
|
||||
//! The local agent loop: model requests (server gateway or own provider),
|
||||
//! tool calls with permission checks, subagents, compaction, persistence.
|
||||
|
||||
use crate::conf::Config;
|
||||
use crate::models::{Catalog, ModelInfo};
|
||||
use crate::server::Server;
|
||||
use crate::session::LocalSession;
|
||||
use crate::tools::{self, Ctx, ToolSpec};
|
||||
use anyhow::{Result, anyhow};
|
||||
use codecraft_core::config::PermissionConfig;
|
||||
use codecraft_core::permission::{Decision, Evaluator, bash_prefix, split_command};
|
||||
use codecraft_core::provider::StreamEvent;
|
||||
use codecraft_core::types::{ImageData, Message, Part, Question, Role, Usage};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const MAX_STEPS: usize = 200;
|
||||
const COMPACT_AT: f64 = 0.85;
|
||||
|
||||
/// A mode (agent) as defined by the server or built in.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Mode {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub primary: bool,
|
||||
pub prompt: String,
|
||||
pub read_only: bool,
|
||||
pub tools: Option<Vec<String>>,
|
||||
pub model: Option<String>,
|
||||
}
|
||||
|
||||
pub fn builtin_modes() -> Vec<Mode> {
|
||||
use codecraft_core::prompts::*;
|
||||
let m = |name: &str, description: &str, primary: bool, prompt: String, read_only: bool| Mode { name: name.into(), description: description.into(), primary, prompt, read_only, tools: None, model: None };
|
||||
vec![
|
||||
m("build", "Full access: implement, edit, run", true, BUILD_PROMPT.into(), false),
|
||||
m("plan", "Read-only: analyse and propose a plan", true, PLAN_PROMPT.into(), true),
|
||||
m("craft", "Study a target, build your own version, check and refine", true, craft_prompt(true), false),
|
||||
m("general", "General-purpose subagent for multi-step tasks", false, GENERAL_PROMPT.into(), false),
|
||||
m("explore", "Read-only subagent for fast codebase searches", false, EXPLORE_PROMPT.into(), true),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn modes_from_server(cfg: &Value) -> Vec<Mode> {
|
||||
cfg["agents"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|a| Mode {
|
||||
name: a["name"].as_str().unwrap_or("").into(),
|
||||
description: a["description"].as_str().unwrap_or("").into(),
|
||||
primary: a["mode"] == "primary",
|
||||
prompt: a["prompt"].as_str().unwrap_or("").into(),
|
||||
read_only: a["read_only"].as_bool().unwrap_or(false),
|
||||
tools: a["tools"].as_array().map(|t| t.iter().filter_map(|x| x.as_str().map(String::from)).collect()),
|
||||
model: a["model"].as_str().map(|m| format!("{}{m}", crate::models::SERVER_PREFIX)),
|
||||
})
|
||||
.filter(|m| !m.name.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Everything the agent needs from the environment (shared, read-mostly).
|
||||
pub struct Env {
|
||||
pub cfg: Config,
|
||||
pub server: Option<Server>,
|
||||
pub server_cfg: Option<Value>,
|
||||
pub catalog: Catalog,
|
||||
pub modes: Vec<Mode>,
|
||||
pub permissions: PermissionConfig,
|
||||
pub base_prompt: String,
|
||||
pub auto_prompt: String,
|
||||
pub remote_tools: Vec<ToolSpec>,
|
||||
pub server_skills: Vec<(String, String)>,
|
||||
pub memory: String,
|
||||
}
|
||||
|
||||
impl Env {
|
||||
pub fn mode(&self, name: &str) -> Mode {
|
||||
self.modes.iter().find(|m| m.name == name).cloned().unwrap_or_else(|| builtin_modes().remove(0))
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Reply {
|
||||
Once,
|
||||
Always,
|
||||
Reject(Option<String>),
|
||||
}
|
||||
|
||||
/// Events for the UI.
|
||||
pub enum UiEvent {
|
||||
Busy(bool),
|
||||
Text(String),
|
||||
Reasoning(String),
|
||||
ToolStart { id: String, name: String, input: Value },
|
||||
ToolDone { id: String, content: String, is_error: bool, diff: Option<String> },
|
||||
Todos(Value),
|
||||
Usage { context: u64, total_cost: f64 },
|
||||
Permission { tool: String, title: String, input: Value, diff: Option<String>, reply: oneshot::Sender<Reply> },
|
||||
Question { questions: Vec<Question>, reply: oneshot::Sender<Option<serde_json::Map<String, Value>>> },
|
||||
Info(String),
|
||||
Error(String),
|
||||
/// A new step (model request) begins.
|
||||
Step,
|
||||
}
|
||||
|
||||
pub struct Agent {
|
||||
pub env: Arc<Env>,
|
||||
pub session: LocalSession,
|
||||
approved: HashSet<String>,
|
||||
read_files: Arc<Mutex<HashSet<PathBuf>>>,
|
||||
shells: Arc<Mutex<BTreeMap<String, Arc<tools::Shell>>>>,
|
||||
ui: mpsc::UnboundedSender<UiEvent>,
|
||||
}
|
||||
|
||||
fn instruction_files(cwd: &Path) -> Vec<(PathBuf, String)> {
|
||||
let mut out = Vec::new();
|
||||
let home = crate::conf::home();
|
||||
let mut dirs: Vec<PathBuf> = cwd.ancestors().take_while(|d| *d != home && d.parent().is_some()).map(Path::to_path_buf).collect();
|
||||
dirs.reverse();
|
||||
for d in dirs {
|
||||
for name in ["AGENTS.md", "CLAUDE.md"] {
|
||||
let p = d.join(name);
|
||||
if let Ok(text) = std::fs::read_to_string(&p) {
|
||||
out.push((p, text.chars().take(40_000).collect()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
pub fn new(env: Arc<Env>, session: LocalSession, ui: mpsc::UnboundedSender<UiEvent>) -> Self {
|
||||
Self { env, session, approved: HashSet::new(), read_files: Arc::default(), shells: Arc::default(), ui }
|
||||
}
|
||||
|
||||
fn emit(&self, ev: UiEvent) {
|
||||
let _ = self.ui.send(ev);
|
||||
}
|
||||
|
||||
fn model(&self, spec: &str) -> Result<ModelInfo> {
|
||||
self.env.catalog.find(spec).cloned().ok_or_else(|| anyhow!("model '{spec}' is not available – choose one with /model"))
|
||||
}
|
||||
|
||||
fn tools_for(&self, mode: &Mode, depth: usize) -> Vec<ToolSpec> {
|
||||
let mut all = tools::local_tools();
|
||||
all.extend(self.env.remote_tools.iter().map(|t| ToolSpec { name: t.name.clone(), description: t.description.clone(), schema: t.schema.clone(), read_only: t.read_only, is_edit: false, remote: true }));
|
||||
all.into_iter()
|
||||
.filter(|t| !mode.read_only || t.read_only)
|
||||
.filter(|t| !(t.name == "task" && depth >= 2))
|
||||
.filter(|t| match &mode.tools {
|
||||
None => true,
|
||||
Some(list) => list.iter().any(|a| a.eq_ignore_ascii_case(&t.name) || (t.remote && a == "mcp")),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn system_prompt(&self, mode: &Mode, model: &str) -> String {
|
||||
let env = &self.env;
|
||||
let cwd = PathBuf::from(&self.session.cwd);
|
||||
let mut s = format!("{}\n\n{}", env.base_prompt, mode.prompt);
|
||||
if self.session.permission_mode == "auto" && !mode.read_only {
|
||||
s.push_str(&env.auto_prompt);
|
||||
}
|
||||
s.push_str(&format!(
|
||||
"\n\n# Environment\n- Working directory: {}\n- Git repository: {}\n- Platform: {} ({}), local machine of the user (no sandbox – commands run with the user's rights)\n- Model: {model}\n- Date: {}\n",
|
||||
cwd.display(),
|
||||
if cwd.join(".git").exists() { "yes" } else { "no" },
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
chrono::Local::now().format("%Y-%m-%d"),
|
||||
));
|
||||
let subs: Vec<&Mode> = env.modes.iter().filter(|m| !m.primary).collect();
|
||||
if !mode.read_only && !subs.is_empty() {
|
||||
s.push_str("\n# Subagents\nAvailable for the task tool (subagent_type):\n");
|
||||
for m in subs {
|
||||
s.push_str(&format!("- {}{}: {}\n", m.name, if m.read_only { " (read-only)" } else { "" }, m.description));
|
||||
}
|
||||
}
|
||||
let mut skills: Vec<(String, String)> = env.server_skills.clone();
|
||||
for name in tools::builtin_skill_names() {
|
||||
if !skills.iter().any(|(n, _)| n == name) {
|
||||
let desc = tools::BUILTIN
|
||||
.iter()
|
||||
.find(|(p, _)| *p == format!("skills/{name}/SKILL.md"))
|
||||
.map(|(_, c)| codecraft_core_frontmatter(c).0.get("description").cloned().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
skills.push((name.to_string(), desc));
|
||||
}
|
||||
}
|
||||
for (name, p) in tools::project_skills(&cwd) {
|
||||
let desc = std::fs::read_to_string(&p).map(|c| codecraft_core_frontmatter(&c).0.get("description").cloned().unwrap_or_default()).unwrap_or_default();
|
||||
skills.retain(|(n, _)| n != &name);
|
||||
skills.push((name, desc));
|
||||
}
|
||||
if !skills.is_empty() {
|
||||
s.push_str("\n# Skills\nLoad a skill with the skill tool when the task matches its description:\n");
|
||||
for (n, d) in &skills {
|
||||
s.push_str(&format!("- {n}: {d}\n"));
|
||||
}
|
||||
}
|
||||
if !env.memory.trim().is_empty() {
|
||||
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", env.memory.trim()));
|
||||
}
|
||||
if let Ok(mem) = std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) {
|
||||
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>()));
|
||||
} else if !mode.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");
|
||||
}
|
||||
let files = instruction_files(&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
|
||||
}
|
||||
|
||||
/// One model request; returns the assistant message and the stop reason.
|
||||
async fn step(&mut self, model: &ModelInfo, system: String, messages: Vec<Message>, tools: &[ToolSpec], effort: Option<String>, mode: &str, cancel: &CancellationToken, live: bool) -> Result<(Message, String, Usage)> {
|
||||
let (tx, mut rx) = mpsc::channel(256);
|
||||
let env = self.env.clone();
|
||||
let m = model.clone();
|
||||
let defs = tools.iter().map(ToolSpec::def).collect();
|
||||
let sid = self.session.id.clone();
|
||||
let agent = mode.to_string();
|
||||
let c = cancel.clone();
|
||||
let handle = tokio::spawn(async move { crate::models::stream(&env.cfg, env.server.as_ref(), &m, system, messages, defs, effort, &sid, &agent, tx, c).await });
|
||||
let mut parts: Vec<Part> = Vec::new();
|
||||
let mut usage = Usage::default();
|
||||
let mut stop = String::new();
|
||||
while let Some(ev) = rx.recv().await {
|
||||
match ev {
|
||||
StreamEvent::TextDelta(t) => {
|
||||
if live {
|
||||
self.emit(UiEvent::Text(t.clone()));
|
||||
}
|
||||
match parts.last_mut() {
|
||||
Some(Part::Text { text }) => text.push_str(&t),
|
||||
_ => parts.push(Part::Text { text: t }),
|
||||
}
|
||||
}
|
||||
StreamEvent::ReasoningDelta(t) => {
|
||||
if live {
|
||||
self.emit(UiEvent::Reasoning(t.clone()));
|
||||
}
|
||||
match parts.last_mut() {
|
||||
Some(Part::Reasoning { text, .. }) => text.push_str(&t),
|
||||
_ => parts.push(Part::Reasoning { text: t, signature: None }),
|
||||
}
|
||||
}
|
||||
StreamEvent::ReasoningSignature(sig) => {
|
||||
if let Some(Part::Reasoning { signature, .. }) = parts.iter_mut().rev().find(|p| matches!(p, Part::Reasoning { .. })) {
|
||||
*signature = Some(sig);
|
||||
}
|
||||
}
|
||||
StreamEvent::ToolCall { id, name, input, extra } => parts.push(Part::ToolCall { id, name, input, extra }),
|
||||
StreamEvent::Usage(u) => usage = u,
|
||||
StreamEvent::Done { stop_reason } => stop = stop_reason,
|
||||
}
|
||||
}
|
||||
handle.await.map_err(|e| anyhow!("{e}"))??;
|
||||
let mut msg = Message::new(Role::Assistant, parts);
|
||||
msg.model = Some(model.spec.clone());
|
||||
Ok((msg, stop, usage))
|
||||
}
|
||||
|
||||
fn account(&mut self, model: &ModelInfo, usage: &Usage) {
|
||||
let cost = model.cost(usage);
|
||||
self.session.usage.input_tokens += usage.input_tokens;
|
||||
self.session.usage.output_tokens += usage.output_tokens;
|
||||
self.session.usage.cache_read_tokens += usage.cache_read_tokens;
|
||||
self.session.usage.cache_write_tokens += usage.cache_write_tokens;
|
||||
self.session.cost += cost;
|
||||
self.session.context_tokens = usage.context_tokens();
|
||||
crate::stats::record(&self.session, &model.spec, usage, cost);
|
||||
self.emit(UiEvent::Usage { context: usage.context_tokens(), total_cost: self.session.cost });
|
||||
}
|
||||
|
||||
/// Run one user turn.
|
||||
pub async fn prompt(&mut self, text: String, images: Vec<ImageData>, effort: Option<String>, cancel: CancellationToken) {
|
||||
self.emit(UiEvent::Busy(true));
|
||||
if self.session.title.is_empty() {
|
||||
self.session.title = text.lines().next().unwrap_or("").chars().take(70).collect();
|
||||
}
|
||||
let mut parts = vec![Part::Text { text }];
|
||||
parts.extend(images.into_iter().map(|image| Part::Image { image }));
|
||||
self.session.messages.push(Message::new(Role::User, parts));
|
||||
if let Err(e) = self.run_loop(effort, &cancel).await {
|
||||
self.emit(UiEvent::Error(format!("{e:#}")));
|
||||
}
|
||||
let _ = self.session.save();
|
||||
self.emit(UiEvent::Busy(false));
|
||||
}
|
||||
|
||||
async fn run_loop(&mut self, effort: Option<String>, cancel: &CancellationToken) -> Result<()> {
|
||||
for _ in 0..MAX_STEPS {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
let mode = self.env.mode(&self.session.mode);
|
||||
let model = self.model(&mode.model.clone().unwrap_or_else(|| self.session.model.clone()))?;
|
||||
// Compact before the context window overflows.
|
||||
if let Some(ctx) = model.config.context {
|
||||
if self.session.context_tokens as f64 > ctx as f64 * COMPACT_AT {
|
||||
self.compact(&model, cancel).await?;
|
||||
}
|
||||
}
|
||||
let tools = self.tools_for(&mode, 0);
|
||||
self.emit(UiEvent::Step);
|
||||
let system = self.system_prompt(&mode, &model.spec);
|
||||
let (msg, stop, usage) = self.step(&model, system, self.session.active(), &tools, effort.clone(), &mode.name, cancel, true).await?;
|
||||
self.account(&model, &usage);
|
||||
let calls: Vec<(String, String, Value)> = msg.parts.iter().filter_map(|p| if let Part::ToolCall { id, name, input, .. } = p { Some((id.clone(), name.clone(), input.clone())) } else { None }).collect();
|
||||
self.session.messages.push(msg);
|
||||
let _ = self.session.save();
|
||||
if calls.is_empty() {
|
||||
if stop == "max_tokens" || stop == "length" {
|
||||
self.emit(UiEvent::Error("The answer hit the output token limit.".into()));
|
||||
}
|
||||
break;
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
for (id, name, input) in calls {
|
||||
if cancel.is_cancelled() {
|
||||
results.push(tool_result(&id, &name, "Aborted by user".into(), true));
|
||||
continue;
|
||||
}
|
||||
let part = self.execute(&tools, &id, &name, input, &mode, cancel, 0).await;
|
||||
results.push(part);
|
||||
}
|
||||
self.session.messages.push(Message::new(Role::User, results));
|
||||
let _ = self.session.save();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ctx(&self, cancel: &CancellationToken) -> Ctx {
|
||||
Ctx {
|
||||
cwd: PathBuf::from(&self.session.cwd),
|
||||
cancel: cancel.clone(),
|
||||
read_files: self.read_files.clone(),
|
||||
shells: self.shells.clone(),
|
||||
server: self.env.server.clone(),
|
||||
server_skills: self.env.server_skills.iter().map(|(n, _)| n.clone()).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Permission check, then run the tool.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn execute<'a>(&'a mut self, tools: &'a [ToolSpec], id: &'a str, name: &'a str, input: Value, _mode: &'a Mode, cancel: &'a CancellationToken, depth: usize) -> futures::future::BoxFuture<'a, Part> {
|
||||
Box::pin(async move {
|
||||
let Some(spec) = tools.iter().find(|t| t.name == name) else {
|
||||
return tool_result(id, name, format!("Unknown or unavailable tool '{name}' in this mode."), true);
|
||||
};
|
||||
if depth == 0 {
|
||||
self.emit(UiEvent::ToolStart { id: id.into(), name: name.into(), input: input.clone() });
|
||||
}
|
||||
let decision = Evaluator { cfg: &self.env.permissions, mode: &self.session.permission_mode, approved: &self.approved }.evaluate(spec, &input);
|
||||
match decision {
|
||||
Decision::Deny => return self.finish(id, name, "Denied by the permission rules.".into(), true, None, depth),
|
||||
Decision::Ask => {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let cwd = PathBuf::from(&self.session.cwd);
|
||||
let title = match name {
|
||||
"bash" => input["description"].as_str().or(input["command"].as_str()).unwrap_or("").to_string(),
|
||||
_ => input["path"].as_str().map(|p| format!("{name} {p}")).unwrap_or_else(|| name.to_string()),
|
||||
};
|
||||
self.emit(UiEvent::Permission { tool: name.into(), title, input: input.clone(), diff: tools::preview(name, &input, &cwd), reply: tx });
|
||||
let reply = tokio::select! {
|
||||
r = rx => r.unwrap_or(Reply::Reject(None)),
|
||||
_ = cancel.cancelled() => Reply::Reject(Some("aborted".into())),
|
||||
};
|
||||
match reply {
|
||||
Reply::Reject(msg) => {
|
||||
let mut text = "The user rejected this tool call.".to_string();
|
||||
if let Some(m) = msg.filter(|m| !m.trim().is_empty()) {
|
||||
text.push_str(&format!(" User feedback: {m}"));
|
||||
}
|
||||
return self.finish(id, name, text, true, None, depth);
|
||||
}
|
||||
Reply::Always => {
|
||||
if name == "bash" {
|
||||
for part in split_command(input["command"].as_str().unwrap_or("")).unwrap_or_default() {
|
||||
self.approved.insert(format!("bash:{}", bash_prefix(&part)));
|
||||
}
|
||||
} else {
|
||||
self.approved.insert(name.to_string());
|
||||
}
|
||||
}
|
||||
Reply::Once => {}
|
||||
}
|
||||
}
|
||||
Decision::Allow => {}
|
||||
}
|
||||
match name {
|
||||
"ask_user" => {
|
||||
let questions: Vec<Question> = serde_json::from_value(input["questions"].clone()).unwrap_or_default();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.emit(UiEvent::Question { questions, reply: tx });
|
||||
let answers = tokio::select! { r = rx => r.ok().flatten(), _ = cancel.cancelled() => None };
|
||||
match answers {
|
||||
Some(a) => self.finish(id, name, serde_json::to_string_pretty(&a).unwrap_or_default(), false, None, depth),
|
||||
None => self.finish(id, name, "The user dismissed the questions without answering.".into(), true, None, depth),
|
||||
}
|
||||
}
|
||||
"todo_write" => {
|
||||
self.session.todos = input["todos"].clone();
|
||||
self.emit(UiEvent::Todos(input["todos"].clone()));
|
||||
let ctx = self.ctx(cancel);
|
||||
match tools::run(&ctx, name, &input).await {
|
||||
Ok(o) => self.finish(id, name, o.content, false, None, depth),
|
||||
Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth),
|
||||
}
|
||||
}
|
||||
"task" => {
|
||||
let text = self.subagent(&input, cancel, depth + 1).await;
|
||||
match text {
|
||||
Ok(t) => self.finish(id, name, t, false, None, depth),
|
||||
Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth),
|
||||
}
|
||||
}
|
||||
_ if spec.remote => {
|
||||
let server = self.env.server.clone();
|
||||
match server {
|
||||
Some(s) => match s.tool(name, input).await {
|
||||
Ok((content, err)) => self.finish(id, name, content, err, None, depth),
|
||||
Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth),
|
||||
},
|
||||
None => self.finish(id, name, "not connected to the server".into(), true, None, depth),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let ctx = self.ctx(cancel);
|
||||
match tools::run(&ctx, name, &input).await {
|
||||
Ok(o) => {
|
||||
let images = o.images.clone();
|
||||
let mut part = self.finish(id, name, o.content, o.is_error, o.diff, depth);
|
||||
if let Part::ToolResult { images: imgs, .. } = &mut part {
|
||||
*imgs = images;
|
||||
}
|
||||
part
|
||||
}
|
||||
Err(e) => self.finish(id, name, format!("Error: {e:#}"), true, None, depth),
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn finish(&self, id: &str, name: &str, content: String, is_error: bool, diff: Option<String>, depth: usize) -> Part {
|
||||
if depth == 0 {
|
||||
self.emit(UiEvent::ToolDone { id: id.into(), content: content.clone(), is_error, diff: diff.clone() });
|
||||
}
|
||||
let mut part = tool_result(id, name, content, is_error);
|
||||
if let (Some(d), Part::ToolResult { meta, .. }) = (diff, &mut part) {
|
||||
*meta = Some(json!({"diff": d}));
|
||||
}
|
||||
part
|
||||
}
|
||||
|
||||
/// Run a subagent with a fresh context; returns its final report.
|
||||
async fn subagent(&mut self, input: &Value, cancel: &CancellationToken, depth: usize) -> Result<String> {
|
||||
let kind = input["subagent_type"].as_str().unwrap_or("general");
|
||||
let parent = self.env.mode(&self.session.mode);
|
||||
let mut mode = self.env.modes.iter().find(|m| m.name == kind && !m.primary).cloned().unwrap_or_else(|| builtin_modes().into_iter().find(|m| m.name == "general").unwrap());
|
||||
if parent.read_only && !mode.read_only {
|
||||
mode = builtin_modes().into_iter().find(|m| m.name == "explore").unwrap();
|
||||
}
|
||||
let model = self.model(&mode.model.clone().unwrap_or_else(|| self.session.model.clone()))?;
|
||||
let tools = self.tools_for(&mode, depth);
|
||||
let prompt = input["prompt"].as_str().unwrap_or("").to_string();
|
||||
self.emit(UiEvent::Info(format!("subagent {kind}: {}", input["description"].as_str().unwrap_or(""))));
|
||||
let mut messages = vec![Message::new(Role::User, vec![Part::Text { text: prompt }])];
|
||||
let mut last = String::new();
|
||||
for _ in 0..MAX_STEPS {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
let system = self.system_prompt(&mode, &model.spec);
|
||||
let (msg, _, usage) = self.step(&model, system, messages.clone(), &tools, None, &mode.name, cancel, false).await?;
|
||||
self.account(&model, &usage);
|
||||
last = msg.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect::<Vec<_>>().join("");
|
||||
let calls: Vec<(String, String, Value)> = msg.parts.iter().filter_map(|p| if let Part::ToolCall { id, name, input, .. } = p { Some((id.clone(), name.clone(), input.clone())) } else { None }).collect();
|
||||
messages.push(msg);
|
||||
if calls.is_empty() {
|
||||
break;
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
for (id, name, input) in calls {
|
||||
results.push(self.execute(&tools, &id, &name, input, &mode, cancel, depth).await);
|
||||
}
|
||||
messages.push(Message::new(Role::User, results));
|
||||
}
|
||||
Ok(if last.trim().is_empty() { "(the subagent returned no report)".into() } else { last })
|
||||
}
|
||||
|
||||
/// Replace the history with a summary.
|
||||
pub async fn compact(&mut self, model: &ModelInfo, cancel: &CancellationToken) -> Result<()> {
|
||||
self.emit(UiEvent::Info("compacting the conversation…".into()));
|
||||
let mut transcript = String::new();
|
||||
for m in self.session.active() {
|
||||
let who = if matches!(m.role, Role::User) { "USER" } else { "ASSISTANT" };
|
||||
for p in &m.parts {
|
||||
let line = match p {
|
||||
Part::Text { text } | Part::Summary { text } => text.clone(),
|
||||
Part::ToolCall { name, input, .. } => format!("[tool call {name}: {}]", input.to_string().chars().take(1500).collect::<String>()),
|
||||
Part::ToolResult { name, content, .. } => format!("[result of {name}]\n{}", content.chars().take(3000).collect::<String>()),
|
||||
_ => continue,
|
||||
};
|
||||
transcript.push_str(&format!("### {who}\n{line}\n"));
|
||||
}
|
||||
}
|
||||
let prompt = format!("<transcript>\n{transcript}\n</transcript>\n\nSummarize this coding session so far so that the work can continue seamlessly from the summary alone: goals and explicit requests, decisions, files changed and their state, commands/tests and results, open problems and the exact next steps.");
|
||||
let msgs = vec![Message::new(Role::User, vec![Part::Text { text: prompt }])];
|
||||
let (msg, _, usage) = self.step(model, "You are a precise summarizer of software engineering sessions. Reply with the summary only.".into(), msgs, &[], None, "compact", cancel, false).await?;
|
||||
self.account(model, &usage);
|
||||
let summary: String = msg.parts.iter().filter_map(|p| if let Part::Text { text } = p { Some(text.as_str()) } else { None }).collect();
|
||||
if summary.trim().is_empty() {
|
||||
return Err(anyhow!("compaction produced no summary"));
|
||||
}
|
||||
self.session.messages.push(Message::new(Role::User, vec![Part::Summary { text: summary }]));
|
||||
self.session.context_tokens = 0;
|
||||
self.read_files.lock().unwrap().clear();
|
||||
let _ = self.session.save();
|
||||
self.emit(UiEvent::Info("conversation compacted".into()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn tool_result(id: &str, name: &str, content: String, is_error: bool) -> Part {
|
||||
Part::ToolResult { call_id: id.into(), name: name.into(), content, images: vec![], is_error, meta: None }
|
||||
}
|
||||
|
||||
/// Parse "---\nkey: value\n---\nbody" frontmatter.
|
||||
pub fn codecraft_core_frontmatter(text: &str) -> (BTreeMap<String, String>, String) {
|
||||
let mut meta = BTreeMap::new();
|
||||
let Some(rest) = text.strip_prefix("---\n") else { return (meta, text.to_string()) };
|
||||
let Some(end) = rest.find("\n---") else { return (meta, text.to_string()) };
|
||||
for line in rest[..end].lines() {
|
||||
if let Some((k, v)) = line.split_once(':') {
|
||||
meta.insert(k.trim().to_string(), v.trim().trim_matches('"').to_string());
|
||||
}
|
||||
}
|
||||
let body = rest[end + 4..].trim_start_matches('\n').to_string();
|
||||
(meta, body)
|
||||
}
|
||||
+1632
File diff suppressed because it is too large.
Load diff
+102
@@ -0,0 +1,102 @@
|
||||
//! Local configuration (~/.config/codecraft/config.toml, readable only by the user).
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use codecraft_core::config::ProviderConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerLogin {
|
||||
pub url: String,
|
||||
pub token: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Config {
|
||||
/// Signed-in CodeCraft server (models, modes and rules come from there).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub server: Option<ServerLogin>,
|
||||
/// Model used for new sessions ("provider/model", server models as "server:provider/model").
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<String>,
|
||||
/// Permission mode for new sessions (default: auto).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub permission_mode: Option<String>,
|
||||
/// UI language (en, de); default from the server or $LANG.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub lang: Option<String>,
|
||||
/// Theme override, e.g. "nord" and "dark" (default: your server theme).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub theme: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub theme_mode: Option<String>,
|
||||
/// Own model providers (standalone mode), same format as the server config.
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub providers: BTreeMap<String, ProviderConfig>,
|
||||
}
|
||||
|
||||
pub fn config_dir() -> PathBuf {
|
||||
if let Some(x) = std::env::var_os("CODECRAFT_CONFIG_DIR") {
|
||||
return PathBuf::from(x);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Some(x) = std::env::var_os("APPDATA") {
|
||||
return PathBuf::from(x).join("codecraft");
|
||||
}
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|| home().join(".config"));
|
||||
base.join("codecraft")
|
||||
}
|
||||
|
||||
pub fn data_dir() -> PathBuf {
|
||||
if let Some(x) = std::env::var_os("CODECRAFT_DATA_DIR") {
|
||||
return PathBuf::from(x);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Some(x) = std::env::var_os("LOCALAPPDATA") {
|
||||
return PathBuf::from(x).join("codecraft");
|
||||
}
|
||||
let base = std::env::var_os("XDG_DATA_HOME").map(PathBuf::from).unwrap_or_else(|| home().join(".local").join("share"));
|
||||
base.join("codecraft")
|
||||
}
|
||||
|
||||
pub fn home() -> PathBuf {
|
||||
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from).unwrap_or_else(|| PathBuf::from("."))
|
||||
}
|
||||
|
||||
fn path() -> PathBuf {
|
||||
config_dir().join("config.toml")
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Result<Self> {
|
||||
let p = path();
|
||||
match std::fs::read_to_string(&p) {
|
||||
Ok(text) => toml::from_str(&text).with_context(|| format!("invalid {}", p.display())),
|
||||
Err(_) => Ok(Self::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<()> {
|
||||
let p = path();
|
||||
std::fs::create_dir_all(p.parent().unwrap())?;
|
||||
let text = format!(
|
||||
"# CodeCraft terminal client – edit with `codecraft setup` or by hand.\n# API keys may be written as \"${{ENV_VAR}}\".\n\n{}",
|
||||
toml::to_string_pretty(self)?
|
||||
);
|
||||
std::fs::write(&p, text)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Nothing set up yet: the setup wizard runs first.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.server.is_none() && self.providers.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Multi-line input field with history.
|
||||
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Editor {
|
||||
pub lines: Vec<String>,
|
||||
/// Cursor: line index and char index.
|
||||
pub row: usize,
|
||||
pub col: usize,
|
||||
history: Vec<String>,
|
||||
hist_pos: Option<usize>,
|
||||
}
|
||||
|
||||
fn byte_idx(s: &str, ch: usize) -> usize {
|
||||
s.char_indices().nth(ch).map(|(i, _)| i).unwrap_or(s.len())
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn new() -> Self {
|
||||
let history = std::fs::read_to_string(crate::conf::data_dir().join("history.txt"))
|
||||
.map(|t| t.lines().map(|l| l.replace("\\n", "\n")).collect())
|
||||
.unwrap_or_default();
|
||||
Self { lines: vec![String::new()], history, ..Default::default() }
|
||||
}
|
||||
|
||||
pub fn text(&self) -> String {
|
||||
self.lines.join("\n")
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.lines.iter().all(|l| l.is_empty())
|
||||
}
|
||||
|
||||
pub fn set(&mut self, text: &str) {
|
||||
self.lines = text.split('\n').map(String::from).collect();
|
||||
if self.lines.is_empty() {
|
||||
self.lines.push(String::new());
|
||||
}
|
||||
self.row = self.lines.len() - 1;
|
||||
self.col = self.lines[self.row].chars().count();
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.lines = vec![String::new()];
|
||||
self.row = 0;
|
||||
self.col = 0;
|
||||
self.hist_pos = None;
|
||||
}
|
||||
|
||||
/// Take the text (and remember it in the history).
|
||||
pub fn take(&mut self) -> String {
|
||||
let t = self.text();
|
||||
if !t.trim().is_empty() && self.history.last() != Some(&t) {
|
||||
self.history.push(t.clone());
|
||||
if self.history.len() > 500 {
|
||||
self.history.remove(0);
|
||||
}
|
||||
let _ = std::fs::create_dir_all(crate::conf::data_dir());
|
||||
let _ = std::fs::write(crate::conf::data_dir().join("history.txt"), self.history.iter().map(|h| h.replace('\n', "\\n")).collect::<Vec<_>>().join("\n"));
|
||||
}
|
||||
self.clear();
|
||||
t
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, s: &str) {
|
||||
for (i, part) in s.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
self.newline();
|
||||
}
|
||||
let part = part.replace('\r', "").replace('\t', " ");
|
||||
let line = &mut self.lines[self.row];
|
||||
let at = byte_idx(line, self.col);
|
||||
line.insert_str(at, &part);
|
||||
self.col += part.chars().count();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn newline(&mut self) {
|
||||
let line = &mut self.lines[self.row];
|
||||
let at = byte_idx(line, self.col);
|
||||
let rest = line.split_off(at);
|
||||
self.lines.insert(self.row + 1, rest);
|
||||
self.row += 1;
|
||||
self.col = 0;
|
||||
}
|
||||
|
||||
pub fn backspace(&mut self) {
|
||||
if self.col > 0 {
|
||||
let line = &mut self.lines[self.row];
|
||||
let a = byte_idx(line, self.col - 1);
|
||||
let b = byte_idx(line, self.col);
|
||||
line.replace_range(a..b, "");
|
||||
self.col -= 1;
|
||||
} else if self.row > 0 {
|
||||
let line = self.lines.remove(self.row);
|
||||
self.row -= 1;
|
||||
self.col = self.lines[self.row].chars().count();
|
||||
self.lines[self.row].push_str(&line);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(&mut self) {
|
||||
let len = self.lines[self.row].chars().count();
|
||||
if self.col < len {
|
||||
let line = &mut self.lines[self.row];
|
||||
let a = byte_idx(line, self.col);
|
||||
let b = byte_idx(line, self.col + 1);
|
||||
line.replace_range(a..b, "");
|
||||
} else if self.row + 1 < self.lines.len() {
|
||||
let next = self.lines.remove(self.row + 1);
|
||||
self.lines[self.row].push_str(&next);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_word(&mut self) {
|
||||
let chars: Vec<char> = self.lines[self.row].chars().collect();
|
||||
let mut i = self.col;
|
||||
while i > 0 && chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
while i > 0 && !chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
let line = &mut self.lines[self.row];
|
||||
let a = byte_idx(line, i);
|
||||
let b = byte_idx(line, self.col);
|
||||
line.replace_range(a..b, "");
|
||||
self.col = i;
|
||||
}
|
||||
|
||||
pub fn left(&mut self) {
|
||||
if self.col > 0 {
|
||||
self.col -= 1;
|
||||
} else if self.row > 0 {
|
||||
self.row -= 1;
|
||||
self.col = self.lines[self.row].chars().count();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn right(&mut self) {
|
||||
if self.col < self.lines[self.row].chars().count() {
|
||||
self.col += 1;
|
||||
} else if self.row + 1 < self.lines.len() {
|
||||
self.row += 1;
|
||||
self.col = 0;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn home(&mut self) {
|
||||
self.col = 0;
|
||||
}
|
||||
|
||||
pub fn end(&mut self) {
|
||||
self.col = self.lines[self.row].chars().count();
|
||||
}
|
||||
|
||||
/// Up: previous line, or older history entry on the first line.
|
||||
pub fn up(&mut self) {
|
||||
if self.row > 0 {
|
||||
self.row -= 1;
|
||||
self.col = self.col.min(self.lines[self.row].chars().count());
|
||||
return;
|
||||
}
|
||||
if self.history.is_empty() {
|
||||
return;
|
||||
}
|
||||
let pos = match self.hist_pos {
|
||||
None if !self.is_empty() => return,
|
||||
None => self.history.len() - 1,
|
||||
Some(0) => 0,
|
||||
Some(p) => p - 1,
|
||||
};
|
||||
self.hist_pos = Some(pos);
|
||||
let h = self.history[pos].clone();
|
||||
self.set(&h);
|
||||
}
|
||||
|
||||
pub fn down(&mut self) {
|
||||
if self.row + 1 < self.lines.len() {
|
||||
self.row += 1;
|
||||
self.col = self.col.min(self.lines[self.row].chars().count());
|
||||
return;
|
||||
}
|
||||
if let Some(p) = self.hist_pos {
|
||||
if p + 1 < self.history.len() {
|
||||
self.hist_pos = Some(p + 1);
|
||||
let h = self.history[p + 1].clone();
|
||||
self.set(&h);
|
||||
} else {
|
||||
self.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The word the cursor is in (for @file and /command completion): (start char, word).
|
||||
pub fn current_word(&self) -> (usize, String) {
|
||||
let chars: Vec<char> = self.lines[self.row].chars().collect();
|
||||
let mut i = self.col.min(chars.len());
|
||||
while i > 0 && !chars[i - 1].is_whitespace() {
|
||||
i -= 1;
|
||||
}
|
||||
(i, chars[i..self.col.min(chars.len())].iter().collect())
|
||||
}
|
||||
|
||||
pub fn replace_word(&mut self, start: usize, with: &str) {
|
||||
let line = &mut self.lines[self.row];
|
||||
let a = byte_idx(line, start);
|
||||
let b = byte_idx(line, self.col);
|
||||
line.replace_range(a..b, with);
|
||||
self.col = start + with.chars().count();
|
||||
}
|
||||
|
||||
/// Cursor position in display columns (for the terminal cursor).
|
||||
pub fn cursor_x(&self) -> usize {
|
||||
let line = &self.lines[self.row];
|
||||
UnicodeWidthStr::width(&line[..byte_idx(line, self.col)])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! UI texts (English and German).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static GERMAN: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn set_lang(lang: &str) {
|
||||
GERMAN.store(lang.starts_with("de"), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn system_lang() -> String {
|
||||
std::env::var("LC_ALL").or_else(|_| std::env::var("LANG")).unwrap_or_default().chars().take(2).collect()
|
||||
}
|
||||
|
||||
/// (key, English, German)
|
||||
const TEXTS: &[(&str, &str, &str)] = &[
|
||||
("placeholder", "Message… (/ commands, @ files, Shift+Enter new line)", "Nachricht… (/ Befehle, @ Dateien, Shift+Enter neue Zeile)"),
|
||||
("busy_placeholder", "Message is queued and sent after this turn…", "Nachricht wird nach diesem Schritt gesendet…"),
|
||||
("permission", "Permission required", "Freigabe erforderlich"),
|
||||
("perm_keys", "[y] allow once [a] always in this session [n] reject [f] reject with feedback", "[y] einmal erlauben [a] immer in dieser Session [n] ablehnen [f] ablehnen mit Hinweis"),
|
||||
("feedback", "Tell the agent what to do instead (Enter to send):", "Was soll der Agent stattdessen tun? (Enter zum Senden):"),
|
||||
("question", "Question", "Frage"),
|
||||
("question_keys", "↑↓ choose · Enter confirm · type your own answer · Esc dismiss", "↑↓ wählen · Enter bestätigen · eigene Antwort tippen · Esc verwerfen"),
|
||||
("thinking", "Thinking", "Denkt nach"),
|
||||
("working", "Working", "Arbeitet"),
|
||||
("interrupt", "Esc to interrupt", "Esc unterbricht"),
|
||||
("mode", "mode", "Modus"),
|
||||
("context", "context", "Kontext"),
|
||||
("quit_again", "Press Ctrl+C again to quit", "Nochmal Ctrl+C zum Beenden"),
|
||||
("aborted", "interrupted", "unterbrochen"),
|
||||
("no_model", "No model available – run /setup", "Kein Modell verfügbar – /setup ausführen"),
|
||||
("help", "Commands", "Befehle"),
|
||||
("choose_model", "Choose a model", "Modell wählen"),
|
||||
("choose_mode", "Choose a mode", "Modus wählen"),
|
||||
("choose_session", "Resume a session", "Session fortsetzen"),
|
||||
("choose_theme", "Choose a theme", "Theme wählen"),
|
||||
("filter", "type to filter", "tippen zum Filtern"),
|
||||
("queued", "queued", "in der Warteschlange"),
|
||||
("welcome", "Welcome to CodeCraft", "Willkommen bei CodeCraft"),
|
||||
("setup_title", "Set up CodeCraft", "CodeCraft einrichten"),
|
||||
("setup_intro", "Where should the models come from? You can add more later with /setup.", "Woher sollen die Modelle kommen? Weitere kannst du später mit /setup hinzufügen."),
|
||||
("setup_server", "Sign in to a CodeCraft server", "Bei einem CodeCraft-Server anmelden"),
|
||||
("setup_provider", "Add a model provider (API key or local)", "Modell-Anbieter hinzufügen (API-Key oder lokal)"),
|
||||
("setup_done", "Done", "Fertig"),
|
||||
("server_url", "Server URL", "Server-Adresse"),
|
||||
("username", "Username", "Benutzer"),
|
||||
("password", "Password", "Passwort"),
|
||||
("api_key", "API key", "API-Key"),
|
||||
("base_url", "Endpoint URL", "Endpunkt-URL"),
|
||||
("provider_name", "Name for this provider", "Name für diesen Anbieter"),
|
||||
("detected", "running locally", "läuft lokal"),
|
||||
("saved", "Saved.", "Gespeichert."),
|
||||
("models_found", "models found", "Modelle gefunden"),
|
||||
("enter_next", "Enter = next · Esc = back", "Enter = weiter · Esc = zurück"),
|
||||
];
|
||||
|
||||
pub fn t(key: &str) -> &'static str {
|
||||
let de = GERMAN.load(Ordering::Relaxed);
|
||||
TEXTS.iter().find(|(k, _, _)| *k == key).map(|(_, en, d)| if de { *d } else { *en }).unwrap_or("?")
|
||||
}
|
||||
+333
-630
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,152 @@
|
||||
//! Model catalog: the server's models ("server:provider/model") and the user's
|
||||
//! own providers ("provider/model"), and streaming requests to either.
|
||||
|
||||
use crate::conf::Config;
|
||||
use crate::server::Server;
|
||||
use anyhow::{Result, anyhow};
|
||||
use codecraft_core::config::ModelConfig;
|
||||
use codecraft_core::provider::{ChatRequest, StreamEvent, ToolDef};
|
||||
use codecraft_core::types::{Message, Usage};
|
||||
use serde_json::Value;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub const SERVER_PREFIX: &str = "server:";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ModelInfo {
|
||||
/// Selection key, e.g. "server:gpu/qwen-coder" or "ollama/llama3".
|
||||
pub spec: String,
|
||||
pub label: String,
|
||||
pub config: ModelConfig,
|
||||
}
|
||||
|
||||
impl ModelInfo {
|
||||
pub fn cost(&self, u: &Usage) -> f64 {
|
||||
self.config.cost(u)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Catalog {
|
||||
pub models: Vec<ModelInfo>,
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
fn model_from_json(v: &Value) -> ModelConfig {
|
||||
serde_json::from_value(v.clone()).unwrap_or_else(|_| ModelConfig { id: v["id"].as_str().unwrap_or("").into(), ..Default::default() })
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
/// Collect models from the server config (if signed in) and local providers.
|
||||
pub async fn load(cfg: &Config, server_cfg: Option<&Value>) -> Self {
|
||||
let mut models = Vec::new();
|
||||
let mut currency = String::new();
|
||||
if let Some(sc) = server_cfg {
|
||||
currency = sc["currency"].as_str().unwrap_or("").to_string();
|
||||
for p in sc["models"].as_array().into_iter().flatten() {
|
||||
let provider = p["provider"].as_str().unwrap_or("");
|
||||
for m in p["models"].as_array().into_iter().flatten() {
|
||||
let config = model_from_json(m);
|
||||
let name = m["name"].as_str().unwrap_or(&config.id).to_string();
|
||||
models.push(ModelInfo { spec: format!("{SERVER_PREFIX}{provider}/{}", config.id), label: format!("server · {provider} · {name}"), config });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (name, pc) in &cfg.providers {
|
||||
if !pc.enabled {
|
||||
continue;
|
||||
}
|
||||
let mut list: Vec<ModelConfig> = pc.models.iter().map(|m| m.resolved()).collect();
|
||||
if list.is_empty() {
|
||||
// Discover (e.g. Ollama, LM Studio) – quick, failures leave the list empty.
|
||||
let provider = codecraft_core::provider::build(codecraft_core::provider::http_client(), pc);
|
||||
if let Ok(Ok(found)) = tokio::time::timeout(std::time::Duration::from_secs(5), provider.list_models()).await {
|
||||
list = found;
|
||||
}
|
||||
}
|
||||
for m in list {
|
||||
let label = format!("{name} · {}", m.name.clone().unwrap_or_else(|| m.id.clone()));
|
||||
models.push(ModelInfo { spec: format!("{name}/{}", m.id), label, config: m });
|
||||
}
|
||||
}
|
||||
Self { models, currency }
|
||||
}
|
||||
|
||||
pub fn find(&self, spec: &str) -> Option<&ModelInfo> {
|
||||
self.models.iter().find(|m| m.spec == spec)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stream one model request to the right backend.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn stream(
|
||||
cfg: &Config,
|
||||
server: Option<&Server>,
|
||||
model: &ModelInfo,
|
||||
system: String,
|
||||
messages: Vec<Message>,
|
||||
tools: Vec<ToolDef>,
|
||||
effort: Option<String>,
|
||||
session: &str,
|
||||
agent: &str,
|
||||
tx: mpsc::Sender<StreamEvent>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
if let Some(rest) = model.spec.strip_prefix(SERVER_PREFIX) {
|
||||
let server = server.ok_or_else(|| anyhow!("not signed in to a CodeCraft server (codecraft login)"))?;
|
||||
return server.stream(rest, system, messages, tools, effort, session, agent, tx, cancel).await;
|
||||
}
|
||||
let (pname, _) = model.spec.split_once('/').ok_or_else(|| anyhow!("invalid model '{}'", model.spec))?;
|
||||
let pc = cfg.providers.get(pname).ok_or_else(|| anyhow!("unknown provider '{pname}' – run /setup"))?;
|
||||
let provider = codecraft_core::provider::build(codecraft_core::provider::http_client(), pc);
|
||||
let mut mc = model.config.clone();
|
||||
if let Some(e) = effort {
|
||||
mc.effort = Some(e);
|
||||
}
|
||||
let max_tokens = mc.max_output.unwrap_or(16_000);
|
||||
let req = ChatRequest { model: mc, system, messages, tools, max_tokens, reasoning: true };
|
||||
provider.stream(req, tx, cancel).await
|
||||
}
|
||||
|
||||
/// Known endpoints for the setup wizard.
|
||||
pub struct Preset {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub kind: codecraft_core::config::ProviderKind,
|
||||
pub base_url: &'static str,
|
||||
pub needs_key: bool,
|
||||
pub key_hint: &'static str,
|
||||
}
|
||||
|
||||
pub fn presets() -> Vec<Preset> {
|
||||
use codecraft_core::config::ProviderKind::*;
|
||||
vec![
|
||||
Preset { id: "anthropic", label: "Anthropic (Claude)", kind: Anthropic, base_url: "", needs_key: true, key_hint: "sk-ant-… from console.anthropic.com" },
|
||||
Preset { id: "openai", label: "OpenAI", kind: Openai, base_url: "https://api.openai.com/v1", needs_key: true, key_hint: "sk-… from platform.openai.com" },
|
||||
Preset { id: "gemini", label: "Google Gemini", kind: Openai, base_url: "https://generativelanguage.googleapis.com/v1beta/openai", needs_key: true, key_hint: "key from aistudio.google.com" },
|
||||
Preset { id: "openrouter", label: "OpenRouter", kind: Openai, base_url: "https://openrouter.ai/api/v1", needs_key: true, key_hint: "sk-or-… from openrouter.ai" },
|
||||
Preset { id: "opencode", label: "opencode Zen / Go", kind: Openai, base_url: "https://opencode.ai/zen/v1", needs_key: true, key_hint: "key from opencode.ai" },
|
||||
Preset { id: "ollama", label: "Ollama (local)", kind: Openai, base_url: "http://localhost:11434/v1", needs_key: false, key_hint: "" },
|
||||
Preset { id: "lmstudio", label: "LM Studio (local)", kind: Openai, base_url: "http://localhost:1234/v1", needs_key: false, key_hint: "" },
|
||||
Preset { id: "llamacpp", label: "llama.cpp / vLLM (local)", kind: Openai, base_url: "http://localhost:8080/v1", needs_key: false, key_hint: "" },
|
||||
Preset { id: "custom", label: "Other OpenAI-compatible endpoint", kind: Openai, base_url: "", needs_key: false, key_hint: "optional" },
|
||||
]
|
||||
}
|
||||
|
||||
/// Local model servers that answer right now (for the wizard).
|
||||
pub async fn detect_local() -> Vec<&'static str> {
|
||||
let mut found = Vec::new();
|
||||
for p in presets().into_iter().filter(|p| p.base_url.starts_with("http://localhost")) {
|
||||
let ok = codecraft_core::provider::http_client()
|
||||
.get(format!("{}/models", p.base_url))
|
||||
.timeout(std::time::Duration::from_millis(800))
|
||||
.send()
|
||||
.await
|
||||
.is_ok_and(|r| r.status().is_success());
|
||||
if ok {
|
||||
found.push(p.id);
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
@@ -0,0 +1,812 @@
|
||||
{
|
||||
"ash-dark": {
|
||||
"accent": "#e0914f",
|
||||
"accent2": "#d6b46a",
|
||||
"aqua": "#8aa89a",
|
||||
"bg0": "#1c1b1f",
|
||||
"bg1": "#232226",
|
||||
"bg2": "#29282c",
|
||||
"bg3": "#3a383d",
|
||||
"blue": "#7c9aa1",
|
||||
"fg0": "#f3ede1",
|
||||
"fg1": "#e6dfd1",
|
||||
"fg2": "#cfc7b8",
|
||||
"fg3": "#b0a89b",
|
||||
"green": "#8f9a5c",
|
||||
"muted": "#999187",
|
||||
"on-accent": "#1c1b1f",
|
||||
"purple": "#b47e99",
|
||||
"red": "#c15c46",
|
||||
"syntax-comment": "#6d6a63",
|
||||
"syntax-function": "#7c9aa1",
|
||||
"syntax-keyword": "#c15c46",
|
||||
"syntax-meta": "#e0914f",
|
||||
"syntax-number": "#a585ac",
|
||||
"syntax-string": "#8f9a5c",
|
||||
"syntax-type": "#d6b46a",
|
||||
"syntax-variable": "#e6dfd1"
|
||||
},
|
||||
"ash-light": {
|
||||
"accent": "#a85f1d",
|
||||
"accent2": "#8a6a24",
|
||||
"aqua": "#3d6b72",
|
||||
"bg0": "#e9e3d5",
|
||||
"bg1": "#dcd5c3",
|
||||
"bg2": "#d2cab7",
|
||||
"bg3": "#c9c1ad",
|
||||
"blue": "#3d6b72",
|
||||
"fg0": "#1e1c18",
|
||||
"fg1": "#2a2822",
|
||||
"fg2": "#433f37",
|
||||
"fg3": "#5a554b",
|
||||
"green": "#5c6631",
|
||||
"muted": "#6f685c",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#7c4a66",
|
||||
"red": "#96392a",
|
||||
"syntax-comment": "#8a8274",
|
||||
"syntax-function": "#3d6b72",
|
||||
"syntax-keyword": "#96392a",
|
||||
"syntax-meta": "#a85f1d",
|
||||
"syntax-number": "#6a4c72",
|
||||
"syntax-string": "#5c6631",
|
||||
"syntax-type": "#8a6a24",
|
||||
"syntax-variable": "#2a2822"
|
||||
},
|
||||
"catppuccin-dark": {
|
||||
"accent": "#cba6f7",
|
||||
"accent2": "#fab387",
|
||||
"aqua": "#94e2d5",
|
||||
"bg0": "#1e1e2e",
|
||||
"bg1": "#181825",
|
||||
"bg2": "#313244",
|
||||
"bg3": "#45475a",
|
||||
"blue": "#89b4fa",
|
||||
"fg0": "#cdd6f4",
|
||||
"fg1": "#cdd6f4",
|
||||
"fg2": "#bac2de",
|
||||
"fg3": "#a6adc8",
|
||||
"green": "#a6e3a1",
|
||||
"muted": "#9399b2",
|
||||
"on-accent": "#1e1e2e",
|
||||
"purple": "#f5c2e7",
|
||||
"red": "#f38ba8",
|
||||
"syntax-comment": "#7f849c",
|
||||
"syntax-function": "#89b4fa",
|
||||
"syntax-keyword": "#cba6f7",
|
||||
"syntax-meta": "#f5c2e7",
|
||||
"syntax-number": "#fab387",
|
||||
"syntax-string": "#a6e3a1",
|
||||
"syntax-type": "#f9e2af",
|
||||
"syntax-variable": "#cdd6f4"
|
||||
},
|
||||
"catppuccin-light": {
|
||||
"accent": "#8839ef",
|
||||
"accent2": "#fe640b",
|
||||
"aqua": "#179299",
|
||||
"bg0": "#eff1f5",
|
||||
"bg1": "#e6e9ef",
|
||||
"bg2": "#ccd0da",
|
||||
"bg3": "#bcc0cc",
|
||||
"blue": "#1e66f5",
|
||||
"fg0": "#4c4f69",
|
||||
"fg1": "#4c4f69",
|
||||
"fg2": "#5c5f77",
|
||||
"fg3": "#6c6f85",
|
||||
"green": "#40a02b",
|
||||
"muted": "#6c6f85",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#ea76cb",
|
||||
"red": "#d20f39",
|
||||
"syntax-comment": "#8c8fa1",
|
||||
"syntax-function": "#1e66f5",
|
||||
"syntax-keyword": "#8839ef",
|
||||
"syntax-meta": "#ea76cb",
|
||||
"syntax-number": "#fe640b",
|
||||
"syntax-string": "#40a02b",
|
||||
"syntax-type": "#df8e1d",
|
||||
"syntax-variable": "#4c4f69"
|
||||
},
|
||||
"coffee-dark": {
|
||||
"accent": "#e0a15c",
|
||||
"accent2": "#ffda92",
|
||||
"aqua": "#9fb8a8",
|
||||
"bg0": "#1d150f",
|
||||
"bg1": "#2d2118",
|
||||
"bg2": "#3a2a20",
|
||||
"bg3": "#4a372a",
|
||||
"blue": "#a3b4c7",
|
||||
"fg0": "#f8eee0",
|
||||
"fg1": "#f0e2d0",
|
||||
"fg2": "#d8c6b0",
|
||||
"fg3": "#b09a84",
|
||||
"green": "#b5b27a",
|
||||
"muted": "#8f7a67",
|
||||
"on-accent": "#1d150f",
|
||||
"purple": "#cf90a2",
|
||||
"red": "#d78a6f",
|
||||
"syntax-comment": "#8f7a67",
|
||||
"syntax-function": "#e0a15c",
|
||||
"syntax-keyword": "#d78a6f",
|
||||
"syntax-meta": "#ec8f89",
|
||||
"syntax-number": "#cf90a2",
|
||||
"syntax-string": "#ffda92",
|
||||
"syntax-type": "#ec8f89",
|
||||
"syntax-variable": "#f0e2d0"
|
||||
},
|
||||
"coffee-light": {
|
||||
"accent": "#8e2d53",
|
||||
"accent2": "#b25d76",
|
||||
"aqua": "#3f6b5e",
|
||||
"bg0": "#e8e3c3",
|
||||
"bg1": "#ddd8b8",
|
||||
"bg2": "#d1ccad",
|
||||
"bg3": "#c5c1a6",
|
||||
"blue": "#8e2d53",
|
||||
"fg0": "#1f1013",
|
||||
"fg1": "#2d1a1d",
|
||||
"fg2": "#45302f",
|
||||
"fg3": "#5d4744",
|
||||
"green": "#4f6b00",
|
||||
"muted": "#6e5a55",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#7a003b",
|
||||
"red": "#a4405e",
|
||||
"syntax-comment": "#8a7d70",
|
||||
"syntax-function": "#8e2d53",
|
||||
"syntax-keyword": "#a4405e",
|
||||
"syntax-meta": "#b25d76",
|
||||
"syntax-number": "#7a003b",
|
||||
"syntax-string": "#4f6b00",
|
||||
"syntax-type": "#b25d76",
|
||||
"syntax-variable": "#2d1a1d"
|
||||
},
|
||||
"dracula-dark": {
|
||||
"accent": "#bd93f9",
|
||||
"accent2": "#ffb86c",
|
||||
"aqua": "#8be9fd",
|
||||
"bg0": "#282a36",
|
||||
"bg1": "#21222c",
|
||||
"bg2": "#343746",
|
||||
"bg3": "#44475a",
|
||||
"blue": "#8be9fd",
|
||||
"fg0": "#f8f8f2",
|
||||
"fg1": "#f8f8f2",
|
||||
"fg2": "#e2e2dc",
|
||||
"fg3": "#c0c0ba",
|
||||
"green": "#50fa7b",
|
||||
"muted": "#9aa3c9",
|
||||
"on-accent": "#282a36",
|
||||
"purple": "#ff79c6",
|
||||
"red": "#ff5555",
|
||||
"syntax-comment": "#6272a4",
|
||||
"syntax-function": "#50fa7b",
|
||||
"syntax-keyword": "#ff79c6",
|
||||
"syntax-meta": "#ffb86c",
|
||||
"syntax-number": "#bd93f9",
|
||||
"syntax-string": "#f1fa8c",
|
||||
"syntax-type": "#8be9fd",
|
||||
"syntax-variable": "#f8f8f2"
|
||||
},
|
||||
"dracula-light": {
|
||||
"accent": "#644ac9",
|
||||
"accent2": "#a34d14",
|
||||
"aqua": "#036a96",
|
||||
"bg0": "#fffbeb",
|
||||
"bg1": "#f5f1dc",
|
||||
"bg2": "#efeddc",
|
||||
"bg3": "#cfcfde",
|
||||
"blue": "#036a96",
|
||||
"fg0": "#1f1f1f",
|
||||
"fg1": "#1f1f1f",
|
||||
"fg2": "#3a3a3a",
|
||||
"fg3": "#6c664b",
|
||||
"green": "#14710a",
|
||||
"muted": "#6c664b",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#a3144d",
|
||||
"red": "#cb3a2a",
|
||||
"syntax-comment": "#6c664b",
|
||||
"syntax-function": "#14710a",
|
||||
"syntax-keyword": "#a3144d",
|
||||
"syntax-meta": "#a34d14",
|
||||
"syntax-number": "#644ac9",
|
||||
"syntax-string": "#846e15",
|
||||
"syntax-type": "#036a96",
|
||||
"syntax-variable": "#1f1f1f"
|
||||
},
|
||||
"everforest-dark": {
|
||||
"accent": "#a7c080",
|
||||
"accent2": "#e69875",
|
||||
"aqua": "#83c092",
|
||||
"bg0": "#2d353b",
|
||||
"bg1": "#343f44",
|
||||
"bg2": "#3d484d",
|
||||
"bg3": "#475258",
|
||||
"blue": "#7fbbb3",
|
||||
"fg0": "#e5dcc4",
|
||||
"fg1": "#d3c6aa",
|
||||
"fg2": "#c0b597",
|
||||
"fg3": "#9da9a0",
|
||||
"green": "#a7c080",
|
||||
"muted": "#859289",
|
||||
"on-accent": "#2d353b",
|
||||
"purple": "#d699b6",
|
||||
"red": "#e67e80",
|
||||
"syntax-comment": "#859289",
|
||||
"syntax-function": "#a7c080",
|
||||
"syntax-keyword": "#e67e80",
|
||||
"syntax-meta": "#e69875",
|
||||
"syntax-number": "#d699b6",
|
||||
"syntax-string": "#dbbc7f",
|
||||
"syntax-type": "#7fbbb3",
|
||||
"syntax-variable": "#d3c6aa"
|
||||
},
|
||||
"everforest-light": {
|
||||
"accent": "#8da101",
|
||||
"accent2": "#f57d26",
|
||||
"aqua": "#35a77c",
|
||||
"bg0": "#fdf6e3",
|
||||
"bg1": "#f4f0d9",
|
||||
"bg2": "#efebd4",
|
||||
"bg3": "#e0dcc7",
|
||||
"blue": "#3a94c5",
|
||||
"fg0": "#4a565c",
|
||||
"fg1": "#5c6a72",
|
||||
"fg2": "#66757c",
|
||||
"fg3": "#748086",
|
||||
"green": "#8da101",
|
||||
"muted": "#7a8478",
|
||||
"on-accent": "#2d353b",
|
||||
"purple": "#df69ba",
|
||||
"red": "#f85552",
|
||||
"syntax-comment": "#939f91",
|
||||
"syntax-function": "#8da101",
|
||||
"syntax-keyword": "#f85552",
|
||||
"syntax-meta": "#f57d26",
|
||||
"syntax-number": "#df69ba",
|
||||
"syntax-string": "#dfa000",
|
||||
"syntax-type": "#3a94c5",
|
||||
"syntax-variable": "#5c6a72"
|
||||
},
|
||||
"github-dark": {
|
||||
"accent": "#4493f8",
|
||||
"accent2": "#d29922",
|
||||
"aqua": "#39c5cf",
|
||||
"bg0": "#0d1117",
|
||||
"bg1": "#161b22",
|
||||
"bg2": "#21262d",
|
||||
"bg3": "#30363d",
|
||||
"blue": "#58a6ff",
|
||||
"fg0": "#f0f6fc",
|
||||
"fg1": "#e6edf3",
|
||||
"fg2": "#c9d1d9",
|
||||
"fg3": "#9198a1",
|
||||
"green": "#3fb950",
|
||||
"muted": "#8b949e",
|
||||
"on-accent": "#0d1117",
|
||||
"purple": "#bc8cff",
|
||||
"red": "#f85149",
|
||||
"syntax-comment": "#8b949e",
|
||||
"syntax-function": "#d2a8ff",
|
||||
"syntax-keyword": "#ff7b72",
|
||||
"syntax-meta": "#7ee787",
|
||||
"syntax-number": "#79c0ff",
|
||||
"syntax-string": "#a5d6ff",
|
||||
"syntax-type": "#ffa657",
|
||||
"syntax-variable": "#e6edf3"
|
||||
},
|
||||
"github-light": {
|
||||
"accent": "#0969da",
|
||||
"accent2": "#9a6700",
|
||||
"aqua": "#1b7c83",
|
||||
"bg0": "#ffffff",
|
||||
"bg1": "#f6f8fa",
|
||||
"bg2": "#eaeef2",
|
||||
"bg3": "#d0d7de",
|
||||
"blue": "#0969da",
|
||||
"fg0": "#1f2328",
|
||||
"fg1": "#1f2328",
|
||||
"fg2": "#31363c",
|
||||
"fg3": "#59636e",
|
||||
"green": "#1a7f37",
|
||||
"muted": "#59636e",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#8250df",
|
||||
"red": "#d1242f",
|
||||
"syntax-comment": "#59636e",
|
||||
"syntax-function": "#8250df",
|
||||
"syntax-keyword": "#cf222e",
|
||||
"syntax-meta": "#116329",
|
||||
"syntax-number": "#0550ae",
|
||||
"syntax-string": "#0a3069",
|
||||
"syntax-type": "#953800",
|
||||
"syntax-variable": "#1f2328"
|
||||
},
|
||||
"gruvbox-dark": {
|
||||
"accent": "#fabd2f",
|
||||
"accent2": "#fe8019",
|
||||
"aqua": "#8ec07c",
|
||||
"bg0": "#282828",
|
||||
"bg1": "#32302f",
|
||||
"bg2": "#3c3836",
|
||||
"bg3": "#504945",
|
||||
"blue": "#83a598",
|
||||
"fg0": "#fbf1c7",
|
||||
"fg1": "#ebdbb2",
|
||||
"fg2": "#d5c4a1",
|
||||
"fg3": "#a89984",
|
||||
"green": "#b8bb26",
|
||||
"muted": "#928374",
|
||||
"on-accent": "#282828",
|
||||
"purple": "#d3869b",
|
||||
"red": "#fb4934",
|
||||
"syntax-comment": "#928374",
|
||||
"syntax-function": "#8ec07c",
|
||||
"syntax-keyword": "#fb4934",
|
||||
"syntax-meta": "#fe8019",
|
||||
"syntax-number": "#d3869b",
|
||||
"syntax-string": "#b8bb26",
|
||||
"syntax-type": "#fabd2f",
|
||||
"syntax-variable": "#83a598"
|
||||
},
|
||||
"gruvbox-light": {
|
||||
"accent": "#b57614",
|
||||
"accent2": "#af3a03",
|
||||
"aqua": "#427b58",
|
||||
"bg0": "#fbf1c7",
|
||||
"bg1": "#f2e5bc",
|
||||
"bg2": "#ebdbb2",
|
||||
"bg3": "#d5c4a1",
|
||||
"blue": "#076678",
|
||||
"fg0": "#282828",
|
||||
"fg1": "#3c3836",
|
||||
"fg2": "#504945",
|
||||
"fg3": "#665c54",
|
||||
"green": "#79740e",
|
||||
"muted": "#7c6f64",
|
||||
"on-accent": "#282828",
|
||||
"purple": "#8f3f71",
|
||||
"red": "#9d0006",
|
||||
"syntax-comment": "#928374",
|
||||
"syntax-function": "#427b58",
|
||||
"syntax-keyword": "#9d0006",
|
||||
"syntax-meta": "#af3a03",
|
||||
"syntax-number": "#8f3f71",
|
||||
"syntax-string": "#79740e",
|
||||
"syntax-type": "#b57614",
|
||||
"syntax-variable": "#076678"
|
||||
},
|
||||
"harbor-dark": {
|
||||
"accent": "#e75a50",
|
||||
"accent2": "#e1ce98",
|
||||
"aqua": "#8a9ba6",
|
||||
"bg0": "#1b1b1b",
|
||||
"bg1": "#232323",
|
||||
"bg2": "#2e2e2e",
|
||||
"bg3": "#3f3f3f",
|
||||
"blue": "#77838a",
|
||||
"fg0": "#f7f4ea",
|
||||
"fg1": "#efebdc",
|
||||
"fg2": "#d6d1c0",
|
||||
"fg3": "#c0b79f",
|
||||
"green": "#a99b7a",
|
||||
"muted": "#a99b7a",
|
||||
"on-accent": "#1b1b1b",
|
||||
"purple": "#e58980",
|
||||
"red": "#f44336",
|
||||
"syntax-comment": "#77838a",
|
||||
"syntax-function": "#a99b7a",
|
||||
"syntax-keyword": "#e75a50",
|
||||
"syntax-meta": "#e58980",
|
||||
"syntax-number": "#e58980",
|
||||
"syntax-string": "#e1ce98",
|
||||
"syntax-type": "#e1ce98",
|
||||
"syntax-variable": "#efebdc"
|
||||
},
|
||||
"harbor-light": {
|
||||
"accent": "#4c6c94",
|
||||
"accent2": "#dc8164",
|
||||
"aqua": "#3d727d",
|
||||
"bg0": "#dfe4c4",
|
||||
"bg1": "#d0d6b4",
|
||||
"bg2": "#c3c9a6",
|
||||
"bg3": "#b3b994",
|
||||
"blue": "#4c6c94",
|
||||
"fg0": "#101c18",
|
||||
"fg1": "#1c2d28",
|
||||
"fg2": "#2f413b",
|
||||
"fg3": "#435650",
|
||||
"green": "#556753",
|
||||
"muted": "#556753",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#8a5b81",
|
||||
"red": "#b14752",
|
||||
"syntax-comment": "#6f7d6c",
|
||||
"syntax-function": "#3d727d",
|
||||
"syntax-keyword": "#b14752",
|
||||
"syntax-meta": "#b06247",
|
||||
"syntax-number": "#8a5b81",
|
||||
"syntax-string": "#556753",
|
||||
"syntax-type": "#4c6c94",
|
||||
"syntax-variable": "#1c2d28"
|
||||
},
|
||||
"kanagawa-dark": {
|
||||
"accent": "#7e9cd8",
|
||||
"accent2": "#ffa066",
|
||||
"aqua": "#7aa89f",
|
||||
"bg0": "#1f1f28",
|
||||
"bg1": "#16161d",
|
||||
"bg2": "#2a2a37",
|
||||
"bg3": "#363646",
|
||||
"blue": "#7fb4ca",
|
||||
"fg0": "#dcd7ba",
|
||||
"fg1": "#dcd7ba",
|
||||
"fg2": "#c8c093",
|
||||
"fg3": "#a6a69c",
|
||||
"green": "#98bb6c",
|
||||
"muted": "#8f8e85",
|
||||
"on-accent": "#1f1f28",
|
||||
"purple": "#957fb8",
|
||||
"red": "#e46876",
|
||||
"syntax-comment": "#727169",
|
||||
"syntax-function": "#7e9cd8",
|
||||
"syntax-keyword": "#957fb8",
|
||||
"syntax-meta": "#ffa066",
|
||||
"syntax-number": "#d27e99",
|
||||
"syntax-string": "#98bb6c",
|
||||
"syntax-type": "#7aa89f",
|
||||
"syntax-variable": "#e6c384"
|
||||
},
|
||||
"kanagawa-light": {
|
||||
"accent": "#4d699b",
|
||||
"accent2": "#cc6d00",
|
||||
"aqua": "#597b75",
|
||||
"bg0": "#f2ecbc",
|
||||
"bg1": "#e5ddb0",
|
||||
"bg2": "#dcd5ac",
|
||||
"bg3": "#d5cea3",
|
||||
"blue": "#4d699b",
|
||||
"fg0": "#43436c",
|
||||
"fg1": "#545464",
|
||||
"fg2": "#5f5f72",
|
||||
"fg3": "#6d6d7c",
|
||||
"green": "#6f894e",
|
||||
"muted": "#716e61",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#624c83",
|
||||
"red": "#c84053",
|
||||
"syntax-comment": "#8a8980",
|
||||
"syntax-function": "#4d699b",
|
||||
"syntax-keyword": "#624c83",
|
||||
"syntax-meta": "#cc6d00",
|
||||
"syntax-number": "#b35b79",
|
||||
"syntax-string": "#6f894e",
|
||||
"syntax-type": "#597b75",
|
||||
"syntax-variable": "#545464"
|
||||
},
|
||||
"nord-dark": {
|
||||
"accent": "#88c0d0",
|
||||
"accent2": "#d08770",
|
||||
"aqua": "#8fbcbb",
|
||||
"bg0": "#2e3440",
|
||||
"bg1": "#3b4252",
|
||||
"bg2": "#434c5e",
|
||||
"bg3": "#4c566a",
|
||||
"blue": "#81a1c1",
|
||||
"fg0": "#eceff4",
|
||||
"fg1": "#e5e9f0",
|
||||
"fg2": "#d8dee9",
|
||||
"fg3": "#b8c0cf",
|
||||
"green": "#a3be8c",
|
||||
"muted": "#8a93a5",
|
||||
"on-accent": "#2e3440",
|
||||
"purple": "#b48ead",
|
||||
"red": "#bf616a",
|
||||
"syntax-comment": "#616e88",
|
||||
"syntax-function": "#88c0d0",
|
||||
"syntax-keyword": "#81a1c1",
|
||||
"syntax-meta": "#d08770",
|
||||
"syntax-number": "#b48ead",
|
||||
"syntax-string": "#a3be8c",
|
||||
"syntax-type": "#8fbcbb",
|
||||
"syntax-variable": "#d8dee9"
|
||||
},
|
||||
"nord-light": {
|
||||
"accent": "#5e81ac",
|
||||
"accent2": "#b55f45",
|
||||
"aqua": "#4d8a89",
|
||||
"bg0": "#eceff4",
|
||||
"bg1": "#e5e9f0",
|
||||
"bg2": "#d8dee9",
|
||||
"bg3": "#c2c9d6",
|
||||
"blue": "#5e81ac",
|
||||
"fg0": "#2e3440",
|
||||
"fg1": "#3b4252",
|
||||
"fg2": "#434c5e",
|
||||
"fg3": "#4c566a",
|
||||
"green": "#5f7d48",
|
||||
"muted": "#5f6b80",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#8f6a8a",
|
||||
"red": "#a9434d",
|
||||
"syntax-comment": "#7b88a1",
|
||||
"syntax-function": "#4c7a90",
|
||||
"syntax-keyword": "#5e81ac",
|
||||
"syntax-meta": "#b55f45",
|
||||
"syntax-number": "#8f6a8a",
|
||||
"syntax-string": "#5f7d48",
|
||||
"syntax-type": "#4d8a89",
|
||||
"syntax-variable": "#3b4252"
|
||||
},
|
||||
"one-dark": {
|
||||
"accent": "#61afef",
|
||||
"accent2": "#d19a66",
|
||||
"aqua": "#56b6c2",
|
||||
"bg0": "#282c34",
|
||||
"bg1": "#21252b",
|
||||
"bg2": "#2c313a",
|
||||
"bg3": "#3e4451",
|
||||
"blue": "#61afef",
|
||||
"fg0": "#d7dae0",
|
||||
"fg1": "#abb2bf",
|
||||
"fg2": "#9da5b4",
|
||||
"fg3": "#8b929e",
|
||||
"green": "#98c379",
|
||||
"muted": "#7f848e",
|
||||
"on-accent": "#282c34",
|
||||
"purple": "#c678dd",
|
||||
"red": "#e06c75",
|
||||
"syntax-comment": "#7f848e",
|
||||
"syntax-function": "#61afef",
|
||||
"syntax-keyword": "#c678dd",
|
||||
"syntax-meta": "#56b6c2",
|
||||
"syntax-number": "#d19a66",
|
||||
"syntax-string": "#98c379",
|
||||
"syntax-type": "#e5c07b",
|
||||
"syntax-variable": "#e06c75"
|
||||
},
|
||||
"one-light": {
|
||||
"accent": "#4078f2",
|
||||
"accent2": "#c18401",
|
||||
"aqua": "#0184bc",
|
||||
"bg0": "#fafafa",
|
||||
"bg1": "#f0f0f1",
|
||||
"bg2": "#e5e5e6",
|
||||
"bg3": "#d4d4d6",
|
||||
"blue": "#4078f2",
|
||||
"fg0": "#232324",
|
||||
"fg1": "#383a42",
|
||||
"fg2": "#4f525e",
|
||||
"fg3": "#696c77",
|
||||
"green": "#50a14f",
|
||||
"muted": "#696c77",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#a626a4",
|
||||
"red": "#e45649",
|
||||
"syntax-comment": "#a0a1a7",
|
||||
"syntax-function": "#4078f2",
|
||||
"syntax-keyword": "#a626a4",
|
||||
"syntax-meta": "#0184bc",
|
||||
"syntax-number": "#986801",
|
||||
"syntax-string": "#50a14f",
|
||||
"syntax-type": "#c18401",
|
||||
"syntax-variable": "#e45649"
|
||||
},
|
||||
"rosepine-dark": {
|
||||
"accent": "#c4a7e7",
|
||||
"accent2": "#ebbcba",
|
||||
"aqua": "#9ccfd8",
|
||||
"bg0": "#191724",
|
||||
"bg1": "#1f1d2e",
|
||||
"bg2": "#26233a",
|
||||
"bg3": "#403d52",
|
||||
"blue": "#31748f",
|
||||
"fg0": "#e0def4",
|
||||
"fg1": "#e0def4",
|
||||
"fg2": "#c9c6e0",
|
||||
"fg3": "#908caa",
|
||||
"green": "#9ccfd8",
|
||||
"muted": "#908caa",
|
||||
"on-accent": "#191724",
|
||||
"purple": "#c4a7e7",
|
||||
"red": "#eb6f92",
|
||||
"syntax-comment": "#6e6a86",
|
||||
"syntax-function": "#ebbcba",
|
||||
"syntax-keyword": "#31748f",
|
||||
"syntax-meta": "#c4a7e7",
|
||||
"syntax-number": "#f6c177",
|
||||
"syntax-string": "#f6c177",
|
||||
"syntax-type": "#9ccfd8",
|
||||
"syntax-variable": "#e0def4"
|
||||
},
|
||||
"rosepine-light": {
|
||||
"accent": "#286983",
|
||||
"accent2": "#d7827e",
|
||||
"aqua": "#56949f",
|
||||
"bg0": "#faf4ed",
|
||||
"bg1": "#f2e9e1",
|
||||
"bg2": "#dfdad9",
|
||||
"bg3": "#cecacd",
|
||||
"blue": "#286983",
|
||||
"fg0": "#464261",
|
||||
"fg1": "#575279",
|
||||
"fg2": "#6a6585",
|
||||
"fg3": "#797593",
|
||||
"green": "#56949f",
|
||||
"muted": "#797593",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#907aa9",
|
||||
"red": "#b4637a",
|
||||
"syntax-comment": "#9893a5",
|
||||
"syntax-function": "#d7827e",
|
||||
"syntax-keyword": "#286983",
|
||||
"syntax-meta": "#907aa9",
|
||||
"syntax-number": "#ea9d34",
|
||||
"syntax-string": "#ea9d34",
|
||||
"syntax-type": "#56949f",
|
||||
"syntax-variable": "#575279"
|
||||
},
|
||||
"solarized-dark": {
|
||||
"accent": "#b58900",
|
||||
"accent2": "#cb4b16",
|
||||
"aqua": "#2aa198",
|
||||
"bg0": "#002b36",
|
||||
"bg1": "#073642",
|
||||
"bg2": "#0b4250",
|
||||
"bg3": "#155565",
|
||||
"blue": "#268bd2",
|
||||
"fg0": "#eee8d5",
|
||||
"fg1": "#93a1a1",
|
||||
"fg2": "#839496",
|
||||
"fg3": "#839496",
|
||||
"green": "#859900",
|
||||
"muted": "#6c8288",
|
||||
"on-accent": "#002b36",
|
||||
"purple": "#6c71c4",
|
||||
"red": "#dc322f",
|
||||
"syntax-comment": "#586e75",
|
||||
"syntax-function": "#268bd2",
|
||||
"syntax-keyword": "#859900",
|
||||
"syntax-meta": "#cb4b16",
|
||||
"syntax-number": "#d33682",
|
||||
"syntax-string": "#2aa198",
|
||||
"syntax-type": "#b58900",
|
||||
"syntax-variable": "#93a1a1"
|
||||
},
|
||||
"solarized-light": {
|
||||
"accent": "#b58900",
|
||||
"accent2": "#cb4b16",
|
||||
"aqua": "#2aa198",
|
||||
"bg0": "#fdf6e3",
|
||||
"bg1": "#eee8d5",
|
||||
"bg2": "#e4dcc4",
|
||||
"bg3": "#d6ceb5",
|
||||
"blue": "#268bd2",
|
||||
"fg0": "#073642",
|
||||
"fg1": "#586e75",
|
||||
"fg2": "#657b83",
|
||||
"fg3": "#657b83",
|
||||
"green": "#859900",
|
||||
"muted": "#6f8186",
|
||||
"on-accent": "#002b36",
|
||||
"purple": "#6c71c4",
|
||||
"red": "#dc322f",
|
||||
"syntax-comment": "#93a1a1",
|
||||
"syntax-function": "#268bd2",
|
||||
"syntax-keyword": "#859900",
|
||||
"syntax-meta": "#cb4b16",
|
||||
"syntax-number": "#d33682",
|
||||
"syntax-string": "#2aa198",
|
||||
"syntax-type": "#b58900",
|
||||
"syntax-variable": "#586e75"
|
||||
},
|
||||
"tokyonight-dark": {
|
||||
"accent": "#7aa2f7",
|
||||
"accent2": "#ff9e64",
|
||||
"aqua": "#73daca",
|
||||
"bg0": "#24283b",
|
||||
"bg1": "#1f2335",
|
||||
"bg2": "#292e42",
|
||||
"bg3": "#3b4261",
|
||||
"blue": "#7aa2f7",
|
||||
"fg0": "#c0caf5",
|
||||
"fg1": "#c0caf5",
|
||||
"fg2": "#a9b1d6",
|
||||
"fg3": "#9aa5ce",
|
||||
"green": "#9ece6a",
|
||||
"muted": "#737aa2",
|
||||
"on-accent": "#24283b",
|
||||
"purple": "#bb9af7",
|
||||
"red": "#f7768e",
|
||||
"syntax-comment": "#565f89",
|
||||
"syntax-function": "#7aa2f7",
|
||||
"syntax-keyword": "#bb9af7",
|
||||
"syntax-meta": "#e0af68",
|
||||
"syntax-number": "#ff9e64",
|
||||
"syntax-string": "#9ece6a",
|
||||
"syntax-type": "#2ac3de",
|
||||
"syntax-variable": "#7dcfff"
|
||||
},
|
||||
"tokyonight-light": {
|
||||
"accent": "#2e7de9",
|
||||
"accent2": "#b15c00",
|
||||
"aqua": "#118c74",
|
||||
"bg0": "#e1e2e7",
|
||||
"bg1": "#d0d5e3",
|
||||
"bg2": "#c4c8da",
|
||||
"bg3": "#a8aecb",
|
||||
"blue": "#2e7de9",
|
||||
"fg0": "#273463",
|
||||
"fg1": "#3760bf",
|
||||
"fg2": "#4b5a99",
|
||||
"fg3": "#6172b0",
|
||||
"green": "#587539",
|
||||
"muted": "#6c74a0",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#9854f1",
|
||||
"red": "#f52a65",
|
||||
"syntax-comment": "#848cb5",
|
||||
"syntax-function": "#2e7de9",
|
||||
"syntax-keyword": "#9854f1",
|
||||
"syntax-meta": "#8c6c3e",
|
||||
"syntax-number": "#b15c00",
|
||||
"syntax-string": "#587539",
|
||||
"syntax-type": "#007197",
|
||||
"syntax-variable": "#3760bf"
|
||||
},
|
||||
"vscode-dark": {
|
||||
"accent": "#0078d4",
|
||||
"accent2": "#ce9178",
|
||||
"aqua": "#4ec9b0",
|
||||
"bg0": "#1f1f1f",
|
||||
"bg1": "#181818",
|
||||
"bg2": "#2b2b2b",
|
||||
"bg3": "#3c3c3c",
|
||||
"blue": "#3794ff",
|
||||
"fg0": "#ffffff",
|
||||
"fg1": "#cccccc",
|
||||
"fg2": "#bbbbbb",
|
||||
"fg3": "#9d9d9d",
|
||||
"green": "#89d185",
|
||||
"muted": "#8b8b8b",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#c586c0",
|
||||
"red": "#f14c4c",
|
||||
"syntax-comment": "#6a9955",
|
||||
"syntax-function": "#dcdcaa",
|
||||
"syntax-keyword": "#569cd6",
|
||||
"syntax-meta": "#c586c0",
|
||||
"syntax-number": "#b5cea8",
|
||||
"syntax-string": "#ce9178",
|
||||
"syntax-type": "#4ec9b0",
|
||||
"syntax-variable": "#9cdcfe"
|
||||
},
|
||||
"vscode-light": {
|
||||
"accent": "#005fb8",
|
||||
"accent2": "#a31515",
|
||||
"aqua": "#267f99",
|
||||
"bg0": "#ffffff",
|
||||
"bg1": "#f8f8f8",
|
||||
"bg2": "#e5e5e5",
|
||||
"bg3": "#cecece",
|
||||
"blue": "#005fb8",
|
||||
"fg0": "#000000",
|
||||
"fg1": "#3b3b3b",
|
||||
"fg2": "#444444",
|
||||
"fg3": "#616161",
|
||||
"green": "#388a34",
|
||||
"muted": "#6e7681",
|
||||
"on-accent": "#ffffff",
|
||||
"purple": "#af00db",
|
||||
"red": "#cd3131",
|
||||
"syntax-comment": "#008000",
|
||||
"syntax-function": "#795e26",
|
||||
"syntax-keyword": "#0000ff",
|
||||
"syntax-meta": "#af00db",
|
||||
"syntax-number": "#098658",
|
||||
"syntax-string": "#a31515",
|
||||
"syntax-type": "#267f99",
|
||||
"syntax-variable": "#001080"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Connection to a CodeCraft server: login, configuration, model gateway, MCP tools.
|
||||
|
||||
use crate::conf::ServerLogin;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use codecraft_core::provider::{StreamEvent, ToolDef};
|
||||
use codecraft_core::types::{Message, Usage};
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Server {
|
||||
pub url: String,
|
||||
token: String,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
pub fn http() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(concat!("codecraft-cli/", env!("CARGO_PKG_VERSION")))
|
||||
.connect_timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.expect("http client")
|
||||
}
|
||||
|
||||
async fn check(resp: reqwest::Response) -> Result<reqwest::Response> {
|
||||
if resp.status().is_success() {
|
||||
return Ok(resp);
|
||||
}
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
let msg = serde_json::from_str::<Value>(&body).ok().and_then(|v| v["error"].as_str().map(String::from)).unwrap_or(body);
|
||||
match status.as_u16() {
|
||||
401 => bail!("the server rejected the login – run `codecraft login` again"),
|
||||
_ => bail!("server: {} {}", status.as_u16(), msg.trim()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign in with username and password; the server creates an API token for this machine.
|
||||
pub async fn login(url: &str, username: &str, password: &str) -> Result<ServerLogin> {
|
||||
let url = url.trim().trim_end_matches('/').to_string();
|
||||
let url = if url.starts_with("http://") || url.starts_with("https://") { url } else { format!("https://{url}") };
|
||||
let host = hostname();
|
||||
let resp = http()
|
||||
.post(format!("{url}/api/auth/cli-login"))
|
||||
.json(&json!({"username": username, "password": password, "hostname": host}))
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("cannot reach {url}"))?;
|
||||
let v: Value = check(resp).await?.json().await?;
|
||||
let token = v["token"].as_str().ok_or_else(|| anyhow!("unexpected answer from the server"))?.to_string();
|
||||
Ok(ServerLogin { url, token, username: v["user"]["username"].as_str().unwrap_or(username).to_string() })
|
||||
}
|
||||
|
||||
pub fn hostname() -> String {
|
||||
std::env::var("HOSTNAME")
|
||||
.ok()
|
||||
.or_else(|| std::fs::read_to_string("/etc/hostname").ok())
|
||||
.or_else(|| std::env::var("COMPUTERNAME").ok())
|
||||
.map(|h| h.trim().to_string())
|
||||
.filter(|h| !h.is_empty())
|
||||
.unwrap_or_else(|| "terminal".into())
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(login: &ServerLogin) -> Self {
|
||||
Self { url: login.url.clone(), token: login.token.clone(), http: http() }
|
||||
}
|
||||
|
||||
pub async fn get(&self, path: &str) -> Result<Value> {
|
||||
let resp = self.http.get(format!("{}/api{path}", self.url)).bearer_auth(&self.token).send().await.with_context(|| format!("cannot reach {}", self.url))?;
|
||||
Ok(check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn post(&self, path: &str, body: Value) -> Result<Value> {
|
||||
let resp = self.http.post(format!("{}/api{path}", self.url)).bearer_auth(&self.token).json(&body).send().await.with_context(|| format!("cannot reach {}", self.url))?;
|
||||
Ok(check(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
/// One model request through the server's gateway, as stream events.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn stream(
|
||||
&self,
|
||||
model: &str,
|
||||
system: String,
|
||||
messages: Vec<Message>,
|
||||
tools: Vec<ToolDef>,
|
||||
effort: Option<String>,
|
||||
session: &str,
|
||||
agent: &str,
|
||||
tx: mpsc::Sender<StreamEvent>,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<()> {
|
||||
let body = json!({
|
||||
"model": model, "system": system, "messages": messages, "tools": tools,
|
||||
"reasoning": true, "effort": effort, "session": session, "agent": agent,
|
||||
});
|
||||
let resp = self.http.post(format!("{}/api/llm/stream", self.url)).bearer_auth(&self.token).json(&body).send().await.with_context(|| format!("cannot reach {}", self.url))?;
|
||||
let resp = check(resp).await?;
|
||||
let mut events = resp.bytes_stream().eventsource();
|
||||
loop {
|
||||
let ev = tokio::select! {
|
||||
ev = events.next() => ev,
|
||||
_ = cancel.cancelled() => return Ok(()),
|
||||
};
|
||||
let Some(ev) = ev else { break };
|
||||
let ev = ev.map_err(|e| anyhow!("stream error: {e}"))?;
|
||||
let v: Value = serde_json::from_str(&ev.data).unwrap_or(Value::Null);
|
||||
let out = match v["type"].as_str().unwrap_or("") {
|
||||
"text" => StreamEvent::TextDelta(v["text"].as_str().unwrap_or("").into()),
|
||||
"reasoning" => StreamEvent::ReasoningDelta(v["text"].as_str().unwrap_or("").into()),
|
||||
"signature" => StreamEvent::ReasoningSignature(v["signature"].as_str().unwrap_or("").into()),
|
||||
"tool_call" => StreamEvent::ToolCall {
|
||||
id: v["id"].as_str().unwrap_or("").into(),
|
||||
name: v["name"].as_str().unwrap_or("").into(),
|
||||
input: v["input"].clone(),
|
||||
extra: v.get("extra").filter(|e| !e.is_null()).cloned(),
|
||||
},
|
||||
"usage" => StreamEvent::Usage(serde_json::from_value::<Usage>(v["usage"].clone()).unwrap_or_default()),
|
||||
"done" => StreamEvent::Done { stop_reason: v["stop_reason"].as_str().unwrap_or("").into() },
|
||||
"error" => bail!("{}", v["message"].as_str().unwrap_or("model error")),
|
||||
_ => continue,
|
||||
};
|
||||
if tx.send(out).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run one of the server's MCP tools.
|
||||
pub async fn tool(&self, name: &str, input: Value) -> Result<(String, bool)> {
|
||||
let v = self.post("/cli/tool", json!({"name": name, "input": input})).await?;
|
||||
Ok((v["content"].as_str().unwrap_or("").to_string(), v["is_error"].as_bool().unwrap_or(false)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Local sessions (~/.local/share/codecraft/sessions/<id>.json).
|
||||
|
||||
use anyhow::Result;
|
||||
use codecraft_core::types::{Message, Part, Usage, new_id, now_ms};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocalSession {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub cwd: String,
|
||||
pub model: String,
|
||||
pub mode: String,
|
||||
pub permission_mode: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub messages: Vec<Message>,
|
||||
#[serde(default)]
|
||||
pub usage: Usage,
|
||||
#[serde(default)]
|
||||
pub cost: f64,
|
||||
#[serde(default)]
|
||||
pub todos: Value,
|
||||
/// Tokens of the last request (context fill).
|
||||
#[serde(default)]
|
||||
pub context_tokens: u64,
|
||||
}
|
||||
|
||||
fn dir() -> PathBuf {
|
||||
crate::conf::data_dir().join("sessions")
|
||||
}
|
||||
|
||||
impl LocalSession {
|
||||
pub fn new(cwd: &str, model: &str, mode: &str, permission_mode: &str) -> Self {
|
||||
let now = now_ms();
|
||||
Self {
|
||||
id: new_id(),
|
||||
title: String::new(),
|
||||
cwd: cwd.into(),
|
||||
model: model.into(),
|
||||
mode: mode.into(),
|
||||
permission_mode: permission_mode.into(),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
messages: vec![],
|
||||
usage: Usage::default(),
|
||||
cost: 0.0,
|
||||
todos: Value::Null,
|
||||
context_tokens: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&mut self) -> Result<()> {
|
||||
if self.messages.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
self.updated_at = now_ms();
|
||||
std::fs::create_dir_all(dir())?;
|
||||
let tmp = dir().join(format!("{}.json.tmp", self.id));
|
||||
std::fs::write(&tmp, serde_json::to_vec(self)?)?;
|
||||
std::fs::rename(tmp, dir().join(format!("{}.json", self.id)))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(id: &str) -> Result<Self> {
|
||||
Ok(serde_json::from_slice(&std::fs::read(dir().join(format!("{id}.json")))?)?)
|
||||
}
|
||||
|
||||
/// Messages the model sees: everything from the last summary on.
|
||||
pub fn active(&self) -> Vec<Message> {
|
||||
let start = self.messages.iter().rposition(|m| m.parts.iter().any(|p| matches!(p, Part::Summary { .. }))).unwrap_or(0);
|
||||
self.messages[start..].to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
/// Sessions (newest first), optionally only those of one folder.
|
||||
pub fn list(cwd: Option<&str>) -> Vec<LocalSession> {
|
||||
let mut out: Vec<LocalSession> = std::fs::read_dir(dir())
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
|
||||
.filter_map(|e| serde_json::from_slice::<LocalSession>(&std::fs::read(e.path()).ok()?).ok())
|
||||
.filter(|s| cwd.is_none_or(|c| s.cwd == c))
|
||||
.collect();
|
||||
out.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Local usage log (~/.local/share/codecraft/usage.jsonl) for /costs.
|
||||
|
||||
use crate::session::LocalSession;
|
||||
use codecraft_core::types::{Usage, now_ms};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::Write;
|
||||
|
||||
fn path() -> std::path::PathBuf {
|
||||
crate::conf::data_dir().join("usage.jsonl")
|
||||
}
|
||||
|
||||
pub fn record(session: &LocalSession, model: &str, u: &Usage, cost: f64) {
|
||||
let line = json!({"ts": now_ms(), "session": session.id, "model": model, "in": u.input_tokens, "out": u.output_tokens, "cache_read": u.cache_read_tokens, "cache_write": u.cache_write_tokens, "cost": cost});
|
||||
let _ = std::fs::create_dir_all(crate::conf::data_dir());
|
||||
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(path()) {
|
||||
let _ = writeln!(f, "{line}");
|
||||
}
|
||||
}
|
||||
|
||||
/// (requests, input, output, cost) per model since `since` (ms).
|
||||
pub fn summary(since: i64) -> Vec<(String, u64, u64, u64, f64)> {
|
||||
let text = std::fs::read_to_string(path()).unwrap_or_default();
|
||||
let mut map: std::collections::BTreeMap<String, (u64, u64, u64, f64)> = Default::default();
|
||||
for line in text.lines() {
|
||||
let Ok(v) = serde_json::from_str::<Value>(line) else { continue };
|
||||
if v["ts"].as_i64().unwrap_or(0) < since {
|
||||
continue;
|
||||
}
|
||||
let e = map.entry(v["model"].as_str().unwrap_or("?").to_string()).or_default();
|
||||
e.0 += 1;
|
||||
e.1 += v["in"].as_u64().unwrap_or(0) + v["cache_read"].as_u64().unwrap_or(0) + v["cache_write"].as_u64().unwrap_or(0);
|
||||
e.2 += v["out"].as_u64().unwrap_or(0);
|
||||
e.3 += v["cost"].as_f64().unwrap_or(0.0);
|
||||
}
|
||||
map.into_iter().map(|(k, (n, i, o, c))| (k, n, i, o, c)).collect()
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Colours from the web themes (same palettes as the web UI, 24-bit).
|
||||
|
||||
use ratatui::style::Color;
|
||||
use serde_json::Value;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
const PALETTES: &str = include_str!("palettes.json");
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
#[allow(dead_code)]
|
||||
pub struct Theme {
|
||||
pub bg0: Color,
|
||||
pub bg1: Color,
|
||||
pub bg2: Color,
|
||||
pub bg3: Color,
|
||||
pub fg0: Color,
|
||||
pub fg1: Color,
|
||||
pub fg2: Color,
|
||||
pub fg3: Color,
|
||||
pub muted: Color,
|
||||
pub accent: Color,
|
||||
pub accent2: Color,
|
||||
pub on_accent: Color,
|
||||
pub red: Color,
|
||||
pub green: Color,
|
||||
pub blue: Color,
|
||||
pub aqua: Color,
|
||||
pub purple: Color,
|
||||
pub keyword: Color,
|
||||
pub string: Color,
|
||||
pub comment: Color,
|
||||
}
|
||||
|
||||
fn hex(v: &Value, key: &str, fallback: Color) -> Color {
|
||||
v[key]
|
||||
.as_str()
|
||||
.and_then(|s| {
|
||||
let s = s.trim_start_matches('#');
|
||||
Some(Color::Rgb(u8::from_str_radix(s.get(0..2)?, 16).ok()?, u8::from_str_radix(s.get(2..4)?, 16).ok()?, u8::from_str_radix(s.get(4..6)?, 16).ok()?))
|
||||
})
|
||||
.unwrap_or(fallback)
|
||||
}
|
||||
|
||||
pub fn names() -> Vec<String> {
|
||||
let all: Value = serde_json::from_str(PALETTES).unwrap_or(Value::Null);
|
||||
let mut out: Vec<String> = all.as_object().map(|o| o.keys().filter_map(|k| k.rsplit_once('-').map(|(b, _)| b.to_string())).collect()).unwrap_or_default();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Is the terminal background dark? (COLORFGBG, else assume dark)
|
||||
fn terminal_dark() -> bool {
|
||||
std::env::var("COLORFGBG").ok().and_then(|v| v.rsplit(';').next().and_then(|b| b.parse::<u8>().ok())).map(|bg| bg < 7 || bg == 8).unwrap_or(true)
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
/// `base` e.g. "gruvbox", `mode` "light" | "dark" | "auto".
|
||||
pub fn load(base: &str, mode: &str) -> Self {
|
||||
let dark = match mode {
|
||||
"light" => false,
|
||||
"dark" => true,
|
||||
_ => terminal_dark(),
|
||||
};
|
||||
let all: Value = serde_json::from_str(PALETTES).unwrap_or(Value::Null);
|
||||
let key = format!("{base}-{}", if dark { "dark" } else { "light" });
|
||||
let p = all.get(&key).or_else(|| all.get(if dark { "gruvbox-dark" } else { "gruvbox-light" })).cloned().unwrap_or(Value::Null);
|
||||
let d = |k: &str, f: Color| hex(&p, k, f);
|
||||
Self {
|
||||
bg0: d("bg0", Color::Reset),
|
||||
bg1: d("bg1", Color::Reset),
|
||||
bg2: d("bg2", Color::DarkGray),
|
||||
bg3: d("bg3", Color::DarkGray),
|
||||
fg0: d("fg0", Color::White),
|
||||
fg1: d("fg1", Color::Reset),
|
||||
fg2: d("fg2", Color::Gray),
|
||||
fg3: d("fg3", Color::Gray),
|
||||
muted: d("muted", Color::DarkGray),
|
||||
accent: d("accent", Color::Yellow),
|
||||
accent2: d("accent2", Color::LightYellow),
|
||||
on_accent: d("on-accent", Color::Black),
|
||||
red: d("red", Color::Red),
|
||||
green: d("green", Color::Green),
|
||||
blue: d("blue", Color::Blue),
|
||||
aqua: d("aqua", Color::Cyan),
|
||||
purple: d("purple", Color::Magenta),
|
||||
keyword: d("syntax-keyword", Color::Red),
|
||||
string: d("syntax-string", Color::Green),
|
||||
comment: d("syntax-comment", Color::DarkGray),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static CURRENT: OnceLock<std::sync::RwLock<Theme>> = OnceLock::new();
|
||||
|
||||
pub fn set(t: Theme) {
|
||||
let lock = CURRENT.get_or_init(|| std::sync::RwLock::new(t));
|
||||
*lock.write().unwrap() = t;
|
||||
}
|
||||
|
||||
pub fn get() -> Theme {
|
||||
*CURRENT.get_or_init(|| std::sync::RwLock::new(Theme::load("gruvbox", "auto"))).read().unwrap()
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Local tools: they run on this machine in the session's working directory.
|
||||
|
||||
use crate::server::Server;
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use base64::Engine;
|
||||
use codecraft_core::permission::ToolInfo;
|
||||
use codecraft_core::provider::ToolDef;
|
||||
use codecraft_core::types::ImageData;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
const MAX_OUTPUT: usize = 30_000;
|
||||
|
||||
/// Built-in skills and commands (also available without a server).
|
||||
pub const BUILTIN: &[(&str, &str)] = &[
|
||||
("skills/cad/SKILL.md", include_str!("../../server/resources/skills/cad/SKILL.md")),
|
||||
("skills/recreate/SKILL.md", include_str!("../../server/resources/skills/recreate/SKILL.md")),
|
||||
("skills/recreate/templates/01-inventory.md", include_str!("../../server/resources/skills/recreate/templates/01-inventory.md")),
|
||||
("skills/recreate/templates/02-overview.md", include_str!("../../server/resources/skills/recreate/templates/02-overview.md")),
|
||||
("skills/recreate/templates/03-architecture.md", include_str!("../../server/resources/skills/recreate/templates/03-architecture.md")),
|
||||
("skills/recreate/templates/04-file-formats.md", include_str!("../../server/resources/skills/recreate/templates/04-file-formats.md")),
|
||||
("skills/recreate/templates/05-ui-and-behaviour.md", include_str!("../../server/resources/skills/recreate/templates/05-ui-and-behaviour.md")),
|
||||
("skills/recreate/templates/06-assets.md", include_str!("../../server/resources/skills/recreate/templates/06-assets.md")),
|
||||
("skills/recreate/templates/recreation-plan.md", include_str!("../../server/resources/skills/recreate/templates/recreation-plan.md")),
|
||||
("commands/cad.md", include_str!("../../server/resources/commands/cad.md")),
|
||||
("commands/recreate-init.md", include_str!("../../server/resources/commands/recreate-init.md")),
|
||||
("commands/recreate-start.md", include_str!("../../server/resources/commands/recreate-start.md")),
|
||||
];
|
||||
|
||||
pub struct ToolSpec {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub schema: Value,
|
||||
pub read_only: bool,
|
||||
pub is_edit: bool,
|
||||
/// Executed by the server (its MCP tools).
|
||||
pub remote: bool,
|
||||
}
|
||||
|
||||
impl ToolInfo for ToolSpec {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
self.read_only
|
||||
}
|
||||
fn is_edit(&self) -> bool {
|
||||
self.is_edit
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolSpec {
|
||||
pub fn def(&self) -> ToolDef {
|
||||
ToolDef { name: self.name.clone(), description: self.description.clone(), input_schema: self.schema.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
fn spec(name: &str, description: &str, schema: Value, read_only: bool, is_edit: bool) -> ToolSpec {
|
||||
ToolSpec { name: name.into(), description: description.into(), schema, read_only, is_edit, remote: false }
|
||||
}
|
||||
|
||||
/// The local tool set.
|
||||
pub fn local_tools() -> Vec<ToolSpec> {
|
||||
vec![
|
||||
spec("read", "Read a file (text with line numbers; images are shown to you). Use offset/limit (lines) for large files.", json!({"type":"object","properties":{"path":{"type":"string"},"offset":{"type":"integer"},"limit":{"type":"integer"}},"required":["path"]}), true, false),
|
||||
spec("write", "Create or overwrite a file with the given content. Read existing files first.", json!({"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}), false, true),
|
||||
spec("edit", "Replace an exact text in a file. old_string must match exactly (including indentation) and be unique unless replace_all is true. Read the file first.", json!({"type":"object","properties":{"path":{"type":"string"},"old_string":{"type":"string"},"new_string":{"type":"string"},"replace_all":{"type":"boolean"}},"required":["path","old_string","new_string"]}), false, true),
|
||||
spec("list", "List a directory (respects .gitignore).", json!({"type":"object","properties":{"path":{"type":"string"}}}), true, false),
|
||||
spec("glob", "Find files by glob pattern, e.g. \"**/*.rs\" (respects .gitignore).", json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"}},"required":["pattern"]}), true, false),
|
||||
spec("grep", "Search file contents with a regular expression (respects .gitignore). Returns file:line: text.", json!({"type":"object","properties":{"pattern":{"type":"string"},"path":{"type":"string"},"glob":{"type":"string"},"ignore_case":{"type":"boolean"}},"required":["pattern"]}), true, false),
|
||||
spec("bash", "Run a shell command in the working directory (stdout and stderr combined). Default timeout 2 min, max 10 min (timeout_ms). For servers/watchers set run_in_background=true and read output with bash_output.", json!({"type":"object","properties":{"command":{"type":"string"},"description":{"type":"string"},"timeout_ms":{"type":"integer"},"run_in_background":{"type":"boolean"}},"required":["command"]}), false, false),
|
||||
spec("bash_output", "Read new output of a background shell.", json!({"type":"object","properties":{"shell_id":{"type":"string"}},"required":["shell_id"]}), true, false),
|
||||
spec("kill_shell", "Stop a background shell.", json!({"type":"object","properties":{"shell_id":{"type":"string"}},"required":["shell_id"]}), false, false),
|
||||
spec("todo_write", "Maintain a task list for the current work, shown live to the user. Send the complete list each time; exactly one item in_progress.", json!({"type":"object","properties":{"todos":{"type":"array","items":{"type":"object","properties":{"content":{"type":"string"},"status":{"type":"string","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}), true, false),
|
||||
spec("ask_user", "Ask the user one or more questions and wait for the answers. Only for decisions that are genuinely the user's. Offer 2-4 options per question.", json!({"type":"object","properties":{"questions":{"type":"array","items":{"type":"object","properties":{"question":{"type":"string"},"options":{"type":"array","items":{"type":"object","properties":{"label":{"type":"string"},"description":{"type":"string"}},"required":["label"]}}},"required":["question"]}}},"required":["questions"]}), true, false),
|
||||
spec("task", "Delegate a self-contained task to a subagent (fresh context). It returns only its final report.", json!({"type":"object","properties":{"description":{"type":"string"},"prompt":{"type":"string"},"subagent_type":{"type":"string"}},"required":["description","prompt"]}), true, false),
|
||||
spec("skill", "Load a skill (packaged instructions for a kind of task) by name and follow it.", json!({"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}), true, false),
|
||||
spec("remember", "Save a lasting insight about this project (build/test commands, conventions, pitfalls, decisions) to .codecraft/memory.md – it is loaded into every future session here. Keep entries short; don't store secrets or temporary state.", json!({"type":"object","properties":{"note":{"type":"string"}},"required":["note"]}), false, true),
|
||||
]
|
||||
}
|
||||
|
||||
/// What tools may touch and use.
|
||||
pub struct Ctx {
|
||||
pub cwd: PathBuf,
|
||||
pub cancel: CancellationToken,
|
||||
pub read_files: Arc<Mutex<HashSet<PathBuf>>>,
|
||||
pub shells: Arc<Mutex<BTreeMap<String, Arc<Shell>>>>,
|
||||
pub server: Option<Server>,
|
||||
/// Server skills (name -> description), fetched on demand.
|
||||
pub server_skills: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct Output {
|
||||
pub content: String,
|
||||
pub is_error: bool,
|
||||
pub images: Vec<ImageData>,
|
||||
/// Unified diff of a file change (shown in the UI).
|
||||
pub diff: Option<String>,
|
||||
}
|
||||
|
||||
impl Output {
|
||||
fn text(s: impl Into<String>) -> Self {
|
||||
Self { content: s.into(), is_error: false, images: vec![], diff: None }
|
||||
}
|
||||
pub fn error(s: impl Into<String>) -> Self {
|
||||
Self { content: s.into(), is_error: true, images: vec![], diff: None }
|
||||
}
|
||||
}
|
||||
|
||||
fn arg<'a>(v: &'a Value, k: &str) -> Result<&'a str> {
|
||||
v[k].as_str().ok_or_else(|| anyhow!("missing argument '{k}'"))
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> String {
|
||||
if s.len() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
let mut end = max;
|
||||
while !s.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
format!("{}\n… [truncated, {} more bytes]", &s[..end], s.len() - end)
|
||||
}
|
||||
|
||||
pub fn resolve(cwd: &Path, p: &str) -> PathBuf {
|
||||
let p = p.trim();
|
||||
let raw = if Path::new(p).is_absolute() {
|
||||
PathBuf::from(p)
|
||||
} else if let Some(rest) = p.strip_prefix("~/") {
|
||||
crate::conf::home().join(rest)
|
||||
} else {
|
||||
cwd.join(p)
|
||||
};
|
||||
let mut out = PathBuf::new();
|
||||
for c in raw.components() {
|
||||
match c {
|
||||
std::path::Component::ParentDir => {
|
||||
out.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
other => out.push(other),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn unified_diff(path: &str, old: &str, new: &str) -> String {
|
||||
similar::TextDiff::from_lines(old, new).unified_diff().context_radius(3).header(&format!("a/{path}"), &format!("b/{path}")).to_string()
|
||||
}
|
||||
|
||||
fn image_type(p: &Path) -> Option<&'static str> {
|
||||
match p.extension()?.to_str()?.to_ascii_lowercase().as_str() {
|
||||
"png" => Some("image/png"),
|
||||
"jpg" | "jpeg" => Some("image/jpeg"),
|
||||
"gif" => Some("image/gif"),
|
||||
"webp" => Some("image/webp"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_edit(old: &str, input: &Value) -> Result<String> {
|
||||
let from = arg(input, "old_string")?;
|
||||
let to = arg(input, "new_string")?;
|
||||
if from.is_empty() {
|
||||
bail!("old_string must not be empty (use write to create files)");
|
||||
}
|
||||
let n = old.matches(from).count();
|
||||
if n == 0 {
|
||||
bail!("old_string not found in the file – read it again and copy the exact text");
|
||||
}
|
||||
if n > 1 && !input["replace_all"].as_bool().unwrap_or(false) {
|
||||
bail!("old_string occurs {n} times – add more context or set replace_all");
|
||||
}
|
||||
Ok(if input["replace_all"].as_bool().unwrap_or(false) { old.replace(from, to) } else { old.replacen(from, to, 1) })
|
||||
}
|
||||
|
||||
/// The proposed change of a write/edit as a diff (for the permission dialog).
|
||||
pub fn preview(name: &str, input: &Value, cwd: &Path) -> Option<String> {
|
||||
let path = resolve(cwd, input["path"].as_str()?);
|
||||
let old = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let new = match name {
|
||||
"write" => input["content"].as_str()?.to_string(),
|
||||
"edit" => apply_edit(&old, input).ok()?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(unified_diff(&rel(cwd, &path), &old, &new))
|
||||
}
|
||||
|
||||
pub fn rel(cwd: &Path, p: &Path) -> String {
|
||||
p.strip_prefix(cwd).map(|r| r.display().to_string()).unwrap_or_else(|_| p.display().to_string())
|
||||
}
|
||||
|
||||
// --- background shells ---------------------------------------------------------------
|
||||
|
||||
pub struct Shell {
|
||||
#[allow(dead_code)]
|
||||
pub command: String,
|
||||
output: Mutex<String>,
|
||||
read_pos: Mutex<usize>,
|
||||
child: Mutex<Option<tokio::process::Child>>,
|
||||
pub done: Mutex<Option<i32>>,
|
||||
}
|
||||
|
||||
fn shell_cmd(cwd: &Path, command: &str) -> tokio::process::Command {
|
||||
#[cfg(windows)]
|
||||
let mut c = {
|
||||
// Git Bash if available, else PowerShell.
|
||||
let bash = std::env::var("CODECRAFT_SHELL").ok().or_else(|| which("bash"));
|
||||
match bash {
|
||||
Some(b) => {
|
||||
let mut c = tokio::process::Command::new(b);
|
||||
c.arg("-c").arg(format!("exec 2>&1\n{command}"));
|
||||
c
|
||||
}
|
||||
None => {
|
||||
let mut c = tokio::process::Command::new("powershell");
|
||||
c.args(["-NoProfile", "-Command", &format!("& {{ {command} }} 2>&1")]);
|
||||
c
|
||||
}
|
||||
}
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let mut c = {
|
||||
let shell = std::env::var("CODECRAFT_SHELL").unwrap_or_else(|_| "bash".into());
|
||||
let mut c = tokio::process::Command::new(shell);
|
||||
c.arg("-c").arg(format!("exec 2>&1\n{command}"));
|
||||
c
|
||||
};
|
||||
c.current_dir(cwd).env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").env("PAGER", "cat");
|
||||
c.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::null()).kill_on_drop(true);
|
||||
#[cfg(unix)]
|
||||
c.process_group(0);
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn which(name: &str) -> Option<String> {
|
||||
std::env::var_os("PATH").and_then(|p| std::env::split_paths(&p).map(|d| d.join(format!("{name}.exe"))).find(|p| p.is_file()).map(|p| p.display().to_string()))
|
||||
}
|
||||
|
||||
fn kill_tree(child: &tokio::process::Child) {
|
||||
#[cfg(unix)]
|
||||
if let Some(pid) = child.id() {
|
||||
unsafe extern "C" {
|
||||
fn kill(pid: i32, sig: i32) -> i32;
|
||||
}
|
||||
// SAFETY: plain syscall; negative pid = process group.
|
||||
unsafe {
|
||||
kill(-(pid as i32), 9);
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = child;
|
||||
}
|
||||
|
||||
async fn run_bash(ctx: &Ctx, input: &Value) -> Result<Output> {
|
||||
let command = arg(input, "command")?;
|
||||
let timeout = Duration::from_millis(input["timeout_ms"].as_u64().unwrap_or(120_000).min(600_000));
|
||||
let mut cmd = shell_cmd(&ctx.cwd, command);
|
||||
if input["run_in_background"].as_bool().unwrap_or(false) {
|
||||
let mut child = cmd.spawn()?;
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let id = format!("sh{}", ctx.shells.lock().unwrap().len() + 1);
|
||||
let shell = Arc::new(Shell { command: command.into(), output: Mutex::default(), read_pos: Mutex::new(0), child: Mutex::new(Some(child)), done: Mutex::new(None) });
|
||||
ctx.shells.lock().unwrap().insert(id.clone(), shell.clone());
|
||||
tokio::spawn(async move {
|
||||
let mut buf = vec![0u8; 8192];
|
||||
loop {
|
||||
match stdout.read(&mut buf).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
let mut out = shell.output.lock().unwrap();
|
||||
out.push_str(&String::from_utf8_lossy(&buf[..n]));
|
||||
if out.len() > 2_000_000 {
|
||||
let cut = out.len() - 1_000_000;
|
||||
let mut c = cut;
|
||||
while !out.is_char_boundary(c) {
|
||||
c += 1;
|
||||
}
|
||||
out.drain(..c);
|
||||
*shell.read_pos.lock().unwrap() = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let child = shell.child.lock().unwrap().take();
|
||||
if let Some(mut c) = child {
|
||||
let code = c.wait().await.ok().and_then(|s| s.code()).unwrap_or(-1);
|
||||
*shell.done.lock().unwrap() = Some(code);
|
||||
}
|
||||
});
|
||||
return Ok(Output::text(format!("Started in the background with shell id {id}. Read its output with bash_output, stop it with kill_shell.")));
|
||||
}
|
||||
let mut child = cmd.spawn()?;
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let mut out = Vec::new();
|
||||
let read = async {
|
||||
let _ = stdout.read_to_end(&mut out).await;
|
||||
};
|
||||
let status = tokio::select! {
|
||||
_ = read => child.wait().await.ok(),
|
||||
_ = tokio::time::sleep(timeout) => {
|
||||
kill_tree(&child);
|
||||
let _ = child.kill().await;
|
||||
return Ok(Output::error(format!("[command timed out after {}s and was killed]", timeout.as_secs())));
|
||||
}
|
||||
_ = ctx.cancel.cancelled() => {
|
||||
kill_tree(&child);
|
||||
let _ = child.kill().await;
|
||||
return Ok(Output::error("[command aborted by user]"));
|
||||
}
|
||||
};
|
||||
let code = status.and_then(|s| s.code()).unwrap_or(-1);
|
||||
let mut text = truncate(String::from_utf8_lossy(&out).trim_end(), MAX_OUTPUT);
|
||||
if text.is_empty() {
|
||||
text = "(no output)".into();
|
||||
}
|
||||
if code != 0 {
|
||||
text.push_str(&format!("\n[exit code {code}]"));
|
||||
}
|
||||
Ok(Output { content: text, is_error: code != 0, images: vec![], diff: None })
|
||||
}
|
||||
|
||||
// --- skills ------------------------------------------------------------------------------
|
||||
|
||||
/// Local skills of the project (.codecraft/skills, .claude/skills): name -> SKILL.md path.
|
||||
pub fn project_skills(cwd: &Path) -> BTreeMap<String, PathBuf> {
|
||||
let mut out = BTreeMap::new();
|
||||
for base in [".claude", ".codecraft"] {
|
||||
let dir = cwd.join(base).join("skills");
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||
for e in entries.flatten() {
|
||||
let p = e.path().join("SKILL.md");
|
||||
if p.is_file() {
|
||||
out.insert(e.file_name().to_string_lossy().to_string(), p);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn builtin_skill_names() -> Vec<&'static str> {
|
||||
BUILTIN.iter().filter_map(|(p, _)| p.strip_prefix("skills/").and_then(|r| r.strip_suffix("/SKILL.md"))).collect()
|
||||
}
|
||||
|
||||
fn skill_cache(name: &str) -> PathBuf {
|
||||
crate::conf::data_dir().join("skills").join(name)
|
||||
}
|
||||
|
||||
async fn load_skill(ctx: &Ctx, name: &str) -> Result<Output> {
|
||||
// Project skills win, then the server's, then the built-in ones.
|
||||
if let Some(p) = project_skills(&ctx.cwd).get(name) {
|
||||
let text = std::fs::read_to_string(p)?;
|
||||
let dir = p.parent().unwrap_or(&ctx.cwd);
|
||||
return Ok(Output::text(format!("{text}\n\n(Skill files are in {})", dir.display())));
|
||||
}
|
||||
if let Some(server) = ctx.server.as_ref().filter(|_| ctx.server_skills.iter().any(|s| s == name)) {
|
||||
let v = server.get(&format!("/cli/skill/{name}")).await?;
|
||||
let dir = skill_cache(name);
|
||||
for f in v["files"].as_array().into_iter().flatten() {
|
||||
let rel = f["path"].as_str().unwrap_or("");
|
||||
if rel.contains("..") {
|
||||
continue;
|
||||
}
|
||||
let p = dir.join(rel);
|
||||
std::fs::create_dir_all(p.parent().unwrap())?;
|
||||
std::fs::write(&p, f["content"].as_str().unwrap_or(""))?;
|
||||
}
|
||||
return Ok(Output::text(format!("{}\n\n(Skill files are in {})", v["content"].as_str().unwrap_or(""), dir.display())));
|
||||
}
|
||||
let prefix = format!("skills/{name}/");
|
||||
let files: Vec<&(&str, &str)> = BUILTIN.iter().filter(|(p, _)| p.starts_with(&prefix)).collect();
|
||||
if files.is_empty() {
|
||||
bail!("unknown skill '{name}'");
|
||||
}
|
||||
let dir = skill_cache(name);
|
||||
let mut main = String::new();
|
||||
for (p, content) in files {
|
||||
let rel = &p[prefix.len()..];
|
||||
if rel == "SKILL.md" {
|
||||
main = content.to_string();
|
||||
}
|
||||
let target = dir.join(rel);
|
||||
std::fs::create_dir_all(target.parent().unwrap())?;
|
||||
std::fs::write(&target, content)?;
|
||||
}
|
||||
Ok(Output::text(format!("{main}\n\n(Skill files are in {})", dir.display())))
|
||||
}
|
||||
|
||||
// --- dispatcher -----------------------------------------------------------------------------
|
||||
|
||||
/// Run a local tool (ask_user and task are handled by the agent loop).
|
||||
pub async fn run(ctx: &Ctx, name: &str, input: &Value) -> Result<Output> {
|
||||
match name {
|
||||
"read" => {
|
||||
let path = resolve(&ctx.cwd, arg(input, "path")?);
|
||||
if path.is_dir() {
|
||||
bail!("{} is a directory – use list", path.display());
|
||||
}
|
||||
let bytes = tokio::fs::read(&path).await.map_err(|e| anyhow!("{}: {e}", path.display()))?;
|
||||
ctx.read_files.lock().unwrap().insert(path.clone());
|
||||
if let Some(mt) = image_type(&path) {
|
||||
return Ok(Output {
|
||||
content: format!("Image {} ({} bytes)", rel(&ctx.cwd, &path), bytes.len()),
|
||||
is_error: false,
|
||||
images: vec![ImageData { media_type: mt.into(), data: base64::engine::general_purpose::STANDARD.encode(&bytes) }],
|
||||
diff: None,
|
||||
});
|
||||
}
|
||||
if bytes.iter().take(8000).any(|b| *b == 0) {
|
||||
return Ok(Output::text(format!("{} is a binary file ({} bytes)", path.display(), bytes.len())));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let offset = input["offset"].as_u64().unwrap_or(1).max(1) as usize;
|
||||
let limit = input["limit"].as_u64().unwrap_or(2000) as usize;
|
||||
let mut out = String::new();
|
||||
let total = text.lines().count();
|
||||
for (i, l) in text.lines().enumerate().skip(offset - 1).take(limit) {
|
||||
out.push_str(&format!("{:>6}\t{}\n", i + 1, if l.len() > 2000 { &l[..l.char_indices().nth(2000).map(|(i, _)| i).unwrap_or(l.len())] } else { l }));
|
||||
}
|
||||
if offset - 1 + limit < total {
|
||||
out.push_str(&format!("… ({} more lines, use offset)\n", total - (offset - 1 + limit)));
|
||||
}
|
||||
Ok(Output::text(if out.is_empty() { "(file is empty)".into() } else { truncate(&out, MAX_OUTPUT * 2) }))
|
||||
}
|
||||
"write" => {
|
||||
let path = resolve(&ctx.cwd, arg(input, "path")?);
|
||||
let content = arg(input, "content")?;
|
||||
let old = if path.exists() {
|
||||
if !ctx.read_files.lock().unwrap().contains(&path) {
|
||||
return Ok(Output::error("File exists but has not been read in this session. Read it first."));
|
||||
}
|
||||
std::fs::read_to_string(&path).unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(&path, content)?;
|
||||
ctx.read_files.lock().unwrap().insert(path.clone());
|
||||
let diff = unified_diff(&rel(&ctx.cwd, &path), &old, content);
|
||||
Ok(Output { content: format!("Wrote {} ({} lines)", rel(&ctx.cwd, &path), content.lines().count()), is_error: false, images: vec![], diff: Some(diff) })
|
||||
}
|
||||
"edit" => {
|
||||
let path = resolve(&ctx.cwd, arg(input, "path")?);
|
||||
if !ctx.read_files.lock().unwrap().contains(&path) {
|
||||
return Ok(Output::error("File has not been read in this session. Read it first."));
|
||||
}
|
||||
let old = std::fs::read_to_string(&path)?;
|
||||
let new = match apply_edit(&old, input) {
|
||||
Ok(n) => n,
|
||||
Err(e) => return Ok(Output::error(e.to_string())),
|
||||
};
|
||||
std::fs::write(&path, &new)?;
|
||||
let diff = unified_diff(&rel(&ctx.cwd, &path), &old, &new);
|
||||
Ok(Output { content: format!("Edited {}", rel(&ctx.cwd, &path)), is_error: false, images: vec![], diff: Some(diff) })
|
||||
}
|
||||
"list" => {
|
||||
let path = resolve(&ctx.cwd, input["path"].as_str().unwrap_or("."));
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
for e in ignore::WalkBuilder::new(&path).max_depth(Some(1)).hidden(false).build().flatten() {
|
||||
if e.depth() == 0 || e.file_name() == ".git" {
|
||||
continue;
|
||||
}
|
||||
let dir = e.file_type().is_some_and(|t| t.is_dir());
|
||||
entries.push(format!("{}{}", e.file_name().to_string_lossy(), if dir { "/" } else { "" }));
|
||||
}
|
||||
entries.sort();
|
||||
Ok(Output::text(if entries.is_empty() { "(empty)".into() } else { entries.join("\n") }))
|
||||
}
|
||||
"glob" => {
|
||||
let base = resolve(&ctx.cwd, input["path"].as_str().unwrap_or("."));
|
||||
let matcher = globset::Glob::new(arg(input, "pattern")?)?.compile_matcher();
|
||||
let mut hits = Vec::new();
|
||||
for e in ignore::WalkBuilder::new(&base).hidden(false).build().flatten() {
|
||||
if e.file_type().is_some_and(|t| t.is_file()) {
|
||||
let r = e.path().strip_prefix(&base).unwrap_or(e.path());
|
||||
if matcher.is_match(r) {
|
||||
hits.push(rel(&ctx.cwd, e.path()));
|
||||
if hits.len() >= 500 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Output::text(if hits.is_empty() { "No files found.".into() } else { hits.join("\n") }))
|
||||
}
|
||||
"grep" => {
|
||||
let base = resolve(&ctx.cwd, input["path"].as_str().unwrap_or("."));
|
||||
let re = regex::RegexBuilder::new(arg(input, "pattern")?).case_insensitive(input["ignore_case"].as_bool().unwrap_or(false)).build()?;
|
||||
let filter = input["glob"].as_str().map(|g| globset::Glob::new(g).map(|g| g.compile_matcher())).transpose()?;
|
||||
let mut out = String::new();
|
||||
let mut n = 0;
|
||||
'files: for e in ignore::WalkBuilder::new(&base).hidden(false).build().flatten() {
|
||||
if !e.file_type().is_some_and(|t| t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(f) = &filter {
|
||||
if !f.is_match(e.file_name()) && !f.is_match(e.path().strip_prefix(&base).unwrap_or(e.path())) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Ok(text) = std::fs::read_to_string(e.path()) else { continue };
|
||||
for (i, line) in text.lines().enumerate() {
|
||||
if re.is_match(line) {
|
||||
out.push_str(&format!("{}:{}: {}\n", rel(&ctx.cwd, e.path()), i + 1, line.chars().take(300).collect::<String>()));
|
||||
n += 1;
|
||||
if n >= 300 {
|
||||
out.push_str("… (more matches – narrow the search)\n");
|
||||
break 'files;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Output::text(if out.is_empty() { "No matches.".into() } else { out }))
|
||||
}
|
||||
"bash" => run_bash(ctx, input).await,
|
||||
"bash_output" => {
|
||||
let id = arg(input, "shell_id")?;
|
||||
let shell = ctx.shells.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no shell {id}"))?;
|
||||
let out = shell.output.lock().unwrap();
|
||||
let mut pos = shell.read_pos.lock().unwrap();
|
||||
let new = out[(*pos).min(out.len())..].to_string();
|
||||
*pos = out.len();
|
||||
let state = match *shell.done.lock().unwrap() {
|
||||
Some(c) => format!("[exited with code {c}]"),
|
||||
None => "[running]".into(),
|
||||
};
|
||||
Ok(Output::text(format!("{}\n{state}", truncate(&new, MAX_OUTPUT))))
|
||||
}
|
||||
"kill_shell" => {
|
||||
let id = arg(input, "shell_id")?;
|
||||
let shell = ctx.shells.lock().unwrap().get(id).cloned().ok_or_else(|| anyhow!("no shell {id}"))?;
|
||||
if let Some(c) = shell.child.lock().unwrap().as_ref() {
|
||||
kill_tree(c);
|
||||
}
|
||||
if let Some(mut c) = shell.child.lock().unwrap().take() {
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
Ok(Output::text(format!("Shell {id} stopped.")))
|
||||
}
|
||||
"todo_write" => {
|
||||
let todos = input["todos"].as_array().cloned().unwrap_or_default();
|
||||
let open = todos.iter().filter(|t| t["status"] != "completed").count();
|
||||
Ok(Output::text(format!("Todo list updated ({open} open).")))
|
||||
}
|
||||
"skill" => load_skill(ctx, arg(input, "name")?).await,
|
||||
"remember" => {
|
||||
let note = arg(input, "note")?.trim().replace('\n', " ");
|
||||
let path = ctx.cwd.join(".codecraft").join("memory.md");
|
||||
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||
let mut text = std::fs::read_to_string(&path).unwrap_or_else(|_| "# Project memory\n\nLearned facts about this project (maintained by CodeCraft; edit freely).\n\n".into());
|
||||
if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str(&format!("- {note}\n"));
|
||||
std::fs::write(&path, &text)?;
|
||||
Ok(Output::text(format!("Saved to .codecraft/memory.md: {note}")))
|
||||
}
|
||||
other => bail!("unknown tool '{other}'"),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user