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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-09 00:13:06 +01:00
1 parent aa1f8cf016
commit 6dffcb501a
52 files changed
+6477 -765

No files matched your search

+552
View File
@@ -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)
}