Files
codecraft/cli/src/agent.rs
T
Richard ZinkandClaude Opus 5.5 c850586a81 Chat tabs, chat mode, own modes, compaction notice, CLI binaries again
- Terminals and file managers as tabs of a chat; chat terminals keep running on
  the server (no idle timeout) and re-attach from any device
- "Just chat" in New session and a built-in chat mode with a lean prompt and only
  web tools; custom modes with kind (coding/conversation), read-only and tool picker
- Mode selector as a dropdown
- The chat shows running compactions (automatic: tokens and share of the limit)
  and labels summaries
- Docker image builds and serves the terminal client binaries again
- Fix: atomic session updates; background title/usage writes no longer undo each
  other (auto compaction triggered one turn late)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 11:24:58 +01:00

553 lines
27 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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, auto: false, tokens: None }]));
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)
}