use std::fs::OpenOptions;
use std::io::{ErrorKind, Read as _, Write as _};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use byob_protocol::{Artifact, ArtifactKind, ArtifactRef, ArtifactVersion, MediaKind, MediaRef, artifact::valid_id};
use crate::artifact_types::{ArtifactType, ArtifactTypes};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::sandbox;
use crate::{Access, FileChange, Tool, ToolContext, ToolOutput, ToolSpec, parse_args};
pub const ARTIFACT_DIR: &str = ".byob/artifacts";
const META_FILE: &str = "artifact.json";
pub const MAX_CONTENT_BYTES: usize = 1024 * 1024;
pub const MAX_FILE_BYTES: u64 = 32 * 1024 * 1024;
pub use crate::edits::MAX_EDITS;
pub const MAX_VERSIONS: usize = 500;
const MAX_TITLE_CHARS: usize = 80;
const MAX_NOTE_CHARS: usize = 200;
const MAX_META_BYTES: u64 = 512 * 1024;
const MAX_LISTED: usize = 2000;
const MAX_ADOPTED: usize = 4;
const MAX_READ_CHARS: usize = 200_000;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Meta {
pub chat: String,
#[serde(flatten)]
pub artifact: Artifact,
}
fn now_ms() -> i64 {
static LAST: std::sync::atomic::AtomicI64 = std::sync::atomic::AtomicI64::new(0);
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| i64::try_from(d.as_millis()).unwrap_or(i64::MAX));
let prev = LAST.try_update(Ordering::AcqRel, Ordering::Acquire, |last| Some(now.max(last.saturating_add(1)))).unwrap_or(now);
now.max(prev.saturating_add(1))
}
fn root_dir(workspace: &Path) -> Result<(PathBuf, PathBuf), String> {
let r = sandbox::resolve(workspace, ARTIFACT_DIR)?;
for dir in [r.root.join(sandbox::RESERVED_DIR), r.abs.clone()] {
match std::fs::symlink_metadata(&dir) {
Ok(m) if m.file_type().is_symlink() || !m.is_dir() => return Err(format!("{} is not a plain folder; can't keep artifacts there", dir.display())),
Ok(_) => {}
Err(e) if e.kind() == ErrorKind::NotFound => std::fs::create_dir(&dir)
.or_else(|e| if e.kind() == ErrorKind::AlreadyExists { Ok(()) } else { Err(e) })
.map_err(|e| format!("can't create {}: {e}", dir.display()))?,
Err(e) => return Err(format!("can't access {}: {e}", dir.display())),
}
}
Ok((r.root, r.abs))
}
fn artifact_dir(workspace: &Path, id: &str) -> Result<(PathBuf, PathBuf), String> {
if !valid_id(id) {
return Err(format!("there is no artifact {id:?}"));
}
let r = sandbox::resolve(workspace, &format!("{ARTIFACT_DIR}/{id}"))?;
match std::fs::symlink_metadata(&r.abs) {
Ok(m) if m.is_dir() && !m.file_type().is_symlink() => Ok((r.root, r.abs)),
_ => Err(format!("there is no artifact {id:?}")),
}
}
fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let meta = std::fs::metadata(path).map_err(|e| format!("{name}: {e}"))?;
if !meta.is_file() {
return Err(format!("{name} is not a file"));
}
if meta.len() > max {
return Err(format!("{name} is too big ({} bytes)", meta.len()));
}
let mut out = Vec::new();
std::fs::File::open(path).and_then(|f| f.take(max).read_to_end(&mut out)).map_err(|e| format!("{name}: {e}"))?;
Ok(out)
}
fn version_file(workspace: &Path, path: &str) -> Option<PathBuf> {
let r = sandbox::resolve_follow(workspace, path).ok()?;
std::fs::metadata(&r.abs).ok().filter(std::fs::Metadata::is_file).map(|_| r.abs)
}
pub fn load(workspace: &Path, id: &str) -> Result<Meta, String> {
let (_, dir) = artifact_dir(workspace, id)?;
let bytes = read_capped(&dir.join(META_FILE), MAX_META_BYTES).map_err(|_| format!("there is no artifact {id:?}"))?;
let mut meta: Meta = serde_json::from_slice(&bytes).map_err(|e| format!("artifact {id:?} has a broken index: {e}"))?;
if meta.artifact.id != id {
return Err(format!("artifact {id:?} has a broken index"));
}
meta.artifact.versions.retain(|v| version_file(workspace, &v.path).is_some());
meta.artifact.content_type = meta.artifact.content_type().to_owned();
if meta.artifact.versions.is_empty() {
return Err(format!("artifact {id:?} no longer exists"));
}
Ok(meta)
}
pub fn load_in_chat(workspace: &Path, chat: &str, id: &str) -> Result<Meta, String> {
let meta = load(workspace, id)?;
if meta.chat != chat {
return Err(format!("there is no artifact {id:?} in this chat"));
}
Ok(meta)
}
fn save(dir: &Path, meta: &Meta) -> Result<(), String> {
let json = serde_json::to_vec_pretty(meta).map_err(|e| e.to_string())?;
let tmp = dir.join(format!("{META_FILE}.tmp"));
let _ = std::fs::remove_file(&tmp);
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp).map_err(|e| format!("can't save the artifact index: {e}"))?;
f.write_all(&json).map_err(|e| format!("can't save the artifact index: {e}"))?;
drop(f);
std::fs::rename(&tmp, dir.join(META_FILE)).map_err(|e| format!("can't save the artifact index: {e}"))
}
pub fn list(workspace: &Path, chat: &str) -> Vec<Artifact> {
let Ok(r) = sandbox::resolve(workspace, ARTIFACT_DIR) else { return Vec::new() };
let Ok(entries) = std::fs::read_dir(&r.abs) else { return Vec::new() };
let mut out: Vec<Artifact> = entries
.take(MAX_LISTED)
.filter_map(Result::ok)
.filter_map(|e| e.file_name().to_str().map(str::to_owned))
.filter(|id| valid_id(id))
.filter_map(|id| load(workspace, &id).ok())
.filter(|m| m.chat == chat)
.map(|m| m.artifact)
.collect();
out.sort_by_key(|a| (a.versions.first().map_or(0, |v| v.created_at), a.id.clone()));
out
}
pub fn remove_chat(workspace: &Path, chat: &str) -> usize {
let Ok(r) = sandbox::resolve(workspace, ARTIFACT_DIR) else { return 0 };
let Ok(entries) = std::fs::read_dir(&r.abs) else { return 0 };
let mut removed = 0;
for id in entries.filter_map(Result::ok).filter_map(|e| e.file_name().to_str().map(str::to_owned)) {
let Ok((_, dir)) = artifact_dir(workspace, &id) else { continue };
let Ok(bytes) = read_capped(&dir.join(META_FILE), MAX_META_BYTES) else { continue };
let Ok(meta) = serde_json::from_slice::<Meta>(&bytes) else { continue };
if meta.chat != chat {
continue;
}
match std::fs::remove_dir_all(&dir) {
Ok(()) => removed += 1,
Err(e) => log::warn!("couldn't delete artifact {id} of chat {chat}: {e}"),
}
}
removed
}
pub fn read_version(workspace: &Path, chat: &str, id: &str, version: u32, max: u64) -> Result<(Artifact, Vec<u8>), String> {
let meta = load_in_chat(workspace, chat, id)?;
let v = meta.artifact.version(version).ok_or_else(|| format!("artifact {id:?} has no version {version}"))?;
let file = version_file(workspace, &v.path).ok_or_else(|| format!("version {version} of {id:?} is gone"))?;
let bytes = read_capped(&file, max)?;
Ok((meta.artifact, bytes))
}
pub fn parse_version_path(path: &str) -> Option<ArtifactRef> {
let norm = path.replace('\\', "/");
let rest = norm.rsplit_once(&format!("{ARTIFACT_DIR}/")).map(|(_, r)| r)?;
let (id, file) = rest.split_once('/')?;
let n: u32 = file.strip_prefix('v')?.split_once('.')?.0.parse().ok()?;
(valid_id(id) && n > 0 && !file.contains('/')).then(|| ArtifactRef { id: id.to_owned(), version: n })
}
fn new_id(title: &str) -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let mut slug = String::new();
for c in title.chars().flat_map(char::to_lowercase) {
if c.is_ascii_alphanumeric() {
slug.push(c);
} else if !slug.ends_with('-') && !slug.is_empty() {
slug.push('-');
}
if slug.len() >= 40 {
break;
}
}
let slug = slug.trim_matches('-');
let slug = if slug.is_empty() { "artifact" } else { slug };
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| u64::from(d.subsec_nanos()));
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
let mix = nanos ^ n.wrapping_mul(0x9e37_79b9) ^ u64::from(std::process::id()).rotate_left(16);
format!("{slug}-{:04x}", mix & 0xffff)
}
fn new_artifact_dir(workspace: &Path, title: &str) -> Result<(String, PathBuf), String> {
let (_, root) = root_dir(workspace)?;
for _ in 0..16 {
let id = new_id(title);
let dir = root.join(&id);
match std::fs::create_dir(&dir) {
Ok(()) => return Ok((id, dir)),
Err(e) if e.kind() == ErrorKind::AlreadyExists => {}
Err(e) => return Err(format!("can't create the artifact: {e}")),
}
}
Err("can't find a free artifact id; try again".into())
}
fn rel(root: &Path, path: &Path) -> String {
path.strip_prefix(root).unwrap_or(path).to_string_lossy().replace('\\', "/")
}
fn clip(s: &str, max: usize) -> String {
let clean: String = s.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
let clean = clean.trim();
let mut out: String = clean.chars().take(max).collect();
if clean.chars().count() > max {
out.push('…');
}
out
}
fn clean_filename(types: &ArtifactTypes, raw: &str, title: &str) -> Result<(String, String), String> {
let last = raw.trim().rsplit(['/', '\\']).next().unwrap_or_default();
let name: String = last.chars().filter(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '_' | ' ')).take(100).collect();
let name = name.trim().trim_start_matches('.').to_owned();
let lower = name.to_ascii_lowercase();
let ext = if let Some(ext) = types.compound_extension(&lower) {
ext
} else {
match lower.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() && (1..=10).contains(&ext.len()) && ext.bytes().all(|b| b.is_ascii_alphanumeric()) => ext.to_owned(),
_ => {
return Err(format!("filename {raw:?} {}", types.extension_hint()));
}
}
};
let name = if name.is_empty() { format!("{}.{ext}", new_id(title)) } else { name };
Ok((name, ext))
}
fn write_version(dir: &Path, n: u32, ext: &str, content: &str) -> Result<PathBuf, String> {
write_version_bytes(dir, n, ext, content.as_bytes())
}
fn write_version_bytes(dir: &Path, n: u32, ext: &str, bytes: &[u8]) -> Result<PathBuf, String> {
let path = dir.join(format!("v{n}.{ext}"));
let mut f = OpenOptions::new().write(true).create_new(true).open(&path).map_err(|e| format!("can't write version {n}: {e}"))?;
f.write_all(bytes).map_err(|e| format!("can't write version {n}: {e}"))?;
Ok(path)
}
fn check_content(ty: &ArtifactType, content: &str) -> Result<(String, String), String> {
if content.len() > MAX_CONTENT_BYTES {
return Err(format!("the content is too big ({} bytes; the limit is {MAX_CONTENT_BYTES})", content.len()));
}
if content.trim().is_empty() {
return Err("the content is empty".into());
}
ty.hooks.check(content)
}
fn chat_of<'a>(ctx: &ToolContext<'a>) -> Result<&'a str, String> {
ctx.chat().filter(|c| !c.is_empty()).ok_or_else(|| "artifacts are only available in a chat".to_owned())
}
fn adopt(workspace: &Path, chat: &str, media: &[MediaRef]) -> Vec<ArtifactRef> {
let mut out = Vec::new();
for m in media.iter().filter(|m| m.kind != MediaKind::File).take(MAX_ADOPTED) {
let kind = if m.kind == MediaKind::Chart { ArtifactKind::Chart } else { ArtifactKind::Image };
let name = m.name().to_owned();
let content_type = kind.content_type(&name).to_owned();
let title = name.strip_suffix(".chart.json").or_else(|| name.rsplit_once('.').map(|(s, _)| s)).unwrap_or(&name).to_owned();
let size = version_file(workspace, &m.path).and_then(|p| std::fs::metadata(p).ok()).map_or(m.size, |md| md.len());
let result = new_artifact_dir(workspace, &title).and_then(|(id, dir)| {
let version = ArtifactVersion { n: 1, path: m.path.clone(), created_at: now_ms(), note: String::new(), size };
let artifact = Artifact {
id: id.clone(),
kind,
content_type: content_type.clone(),
title: clip(&title, MAX_TITLE_CHARS),
filename: name.clone(),
versions: vec![version],
};
save(&dir, &Meta { chat: chat.to_owned(), artifact }).map(|()| id)
});
match result {
Ok(id) => out.push(ArtifactRef { id, version: 1 }),
Err(e) => log::warn!("adopting {} as an artifact: {e}", m.path),
}
}
out
}
pub fn for_call(workspace: &Path, chat: &str, changes: &[FileChange], media: &[MediaRef]) -> Vec<ArtifactRef> {
let made: Vec<ArtifactRef> = changes
.iter()
.filter_map(|c| match c {
FileChange::Created(p) => parse_version_path(&p.to_string_lossy()),
_ => None,
})
.collect();
if !made.is_empty() {
return made;
}
adopt(workspace, chat, media)
}
#[derive(Debug, Clone, Default)]
pub struct SaveBytes<'a> {
pub chat: &'a str,
pub id: Option<&'a str>,
pub title: Option<&'a str>,
pub filename: Option<&'a str>,
pub bytes: &'a [u8],
pub note: &'a str,
pub replace: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Saved {
pub artifact: Artifact,
pub version: u32,
pub file: PathBuf,
pub created: bool,
pub replaced: bool,
}
pub fn save_bytes(types: &ArtifactTypes, workspace: &Path, s: &SaveBytes<'_>) -> Result<Saved, String> {
if s.chat.is_empty() {
return Err("artifacts are only available in a chat".into());
}
if s.bytes.len() as u64 > MAX_FILE_BYTES {
return Err(format!("the file is too big ({} bytes; at most {MAX_FILE_BYTES})", s.bytes.len()));
}
if s.bytes.is_empty() {
return Err("the file is empty".into());
}
let note = clip(s.note, MAX_NOTE_CHARS);
let root = sandbox::root(workspace)?;
let Some(id) = s.id.map(str::trim).filter(|i| !i.is_empty()) else {
let title = clip(s.title.unwrap_or_default(), MAX_TITLE_CHARS);
if title.is_empty() {
return Err("a new artifact needs a title".into());
}
let (filename, ext) = clean_filename(types, s.filename.unwrap_or_default(), &title)?;
let ty = types.for_filename(&filename);
let (id, dir) = new_artifact_dir(workspace, &title)?;
let result = (|| {
let file = write_version_bytes(&dir, 1, &ext, s.bytes)?;
let version = ArtifactVersion { n: 1, path: rel(&root, &file), created_at: now_ms(), note: note.clone(), size: s.bytes.len() as u64 };
let artifact =
Artifact { id: id.clone(), kind: ty.kind, content_type: ty.content_type.clone(), title: title.clone(), filename, versions: vec![version] };
save(&dir, &Meta { chat: s.chat.to_owned(), artifact: artifact.clone() })?;
Ok::<_, String>(Saved { artifact, version: 1, file, created: true, replaced: false })
})();
if result.is_err() {
let _ = std::fs::remove_dir_all(&dir);
}
return result;
};
let mut meta = load_in_chat(workspace, s.chat, id)?;
let id = meta.artifact.id.clone();
let (root, dir) = artifact_dir(workspace, &id)?;
let all = serde_json::from_slice::<Meta>(&read_capped(&dir.join(META_FILE), MAX_META_BYTES)?).map_err(|e| e.to_string())?;
if let Some(name) = s.filename.map(str::trim).filter(|n| !n.is_empty() && *n != meta.artifact.filename) {
let (filename, _) = clean_filename(types, name, &meta.artifact.title)?;
let ty = types.for_filename(&filename);
meta.artifact.filename = filename;
meta.artifact.content_type = ty.content_type.clone();
meta.artifact.kind = ty.kind;
}
if let Some(t) = s.title.map(|t| clip(t, MAX_TITLE_CHARS)).filter(|t| !t.is_empty()) {
meta.artifact.title = t;
}
let lower = meta.artifact.filename.to_ascii_lowercase();
let ext = types.compound_extension(&lower).unwrap_or_else(|| lower.rsplit_once('.').map_or("bin".to_owned(), |(_, e)| e.to_owned()));
let latest = meta.artifact.latest().map(|v| v.n);
let top = all.artifact.versions.iter().map(|v| v.n).max().unwrap_or(0);
meta.artifact.versions = all.artifact.versions;
if let Some(n) = s.replace.filter(|n| Some(*n) == latest && *n == top)
&& let Some(v) = meta.artifact.versions.iter_mut().find(|v| v.n == n)
&& v.path.ends_with(&format!("/v{n}.{ext}"))
{
let file = dir.join(format!("v{n}.{ext}"));
let tmp = dir.join(format!("v{n}.{ext}.tmp"));
let _ = std::fs::remove_file(&tmp);
let mut f = OpenOptions::new().write(true).create_new(true).open(&tmp).map_err(|e| format!("can't write version {n}: {e}"))?;
f.write_all(s.bytes).map_err(|e| format!("can't write version {n}: {e}"))?;
drop(f);
std::fs::rename(&tmp, &file).map_err(|e| format!("can't write version {n}: {e}"))?;
v.size = s.bytes.len() as u64;
v.created_at = now_ms();
if !note.is_empty() {
v.note = note;
}
save(&dir, &meta)?;
return Ok(Saved { artifact: meta.artifact, version: n, file, created: false, replaced: true });
}
if meta.artifact.versions.len() >= MAX_VERSIONS {
return Err(format!("{id} has too many versions; create a new artifact"));
}
let n = top.saturating_add(1);
let file = write_version_bytes(&dir, n, &ext, s.bytes)?;
meta.artifact.versions.push(ArtifactVersion { n, path: rel(&root, &file), created_at: now_ms(), note, size: s.bytes.len() as u64 });
if let Err(e) = save(&dir, &meta) {
let _ = std::fs::remove_file(&file);
return Err(e);
}
meta.artifact.versions.retain(|v| version_file(workspace, &v.path).is_some());
Ok(Saved { artifact: meta.artifact, version: n, file, created: false, replaced: false })
}
pub fn prompt_list(types: &ArtifactTypes, artifacts: &[Artifact]) -> String {
if artifacts.is_empty() {
return String::new();
}
let mut out = String::from("Artifacts in this chat (update them by id):");
for a in artifacts.iter().rev().take(30) {
let v = a.latest().map_or(1, |v| v.n);
out.push_str(&format!("\n- {}: \"{}\" ({}, v{v}, {})", a.id, a.title, types.of(a).label, a.filename));
}
out
}
#[derive(Clone, Default)]
pub struct CreateArtifact {
pub types: ArtifactTypes,
}
#[derive(Clone, Default)]
pub struct UpdateArtifact {
pub types: ArtifactTypes,
}
#[derive(Clone, Default)]
pub struct ReadArtifact {
pub types: ArtifactTypes,
}
#[derive(Deserialize)]
struct CreateArgs {
title: String,
#[serde(default, alias = "name", alias = "file")]
filename: String,
content: String,
#[serde(default)]
note: String,
}
#[derive(Deserialize)]
struct Edit {
find: String,
#[serde(default, alias = "replacement", alias = "replace_with")]
replace: String,
}
#[derive(Deserialize)]
struct UpdateArgs {
id: String,
#[serde(default)]
content: Option<String>,
#[serde(default)]
edits: Option<Vec<Edit>>,
#[serde(default)]
note: String,
}
#[derive(Deserialize)]
struct ReadArgs {
id: String,
#[serde(default)]
version: Option<u32>,
}
fn arg_str<'a>(args: &'a Value, key: &str) -> &'a str {
args.get(key).and_then(Value::as_str).unwrap_or_default()
}
impl Tool for CreateArtifact {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "create_artifact".into(),
description: self.types.tool_description(),
parameters: json!({
"type": "object",
"properties": {
"title": {"type": "string", "description": "Short title, e.g. \"Loan calculator\""},
"filename": {"type": "string", "description": "File name with extension, e.g. loan-calculator.html, q3-summary.md, prices.csv"},
"content": {"type": "string", "description": "The whole file"},
"note": {"type": "string", "description": "Optional: what this first version is"}
},
"required": ["title", "filename", "content"]
}),
}
}
fn title(&self, args: &Value) -> String {
format!("Made {}", clip(arg_str(args, "title"), 60))
}
fn pending_title(&self, args: &Value) -> String {
format!("Make {}", clip(arg_str(args, "title"), 60))
}
fn access(&self, _args: &Value) -> Access {
Access::Read
}
fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput {
match create(&self.types, args, ctx) {
Ok(out) => out,
Err(e) => ToolOutput::err(e),
}
}
}
fn create(types: &ArtifactTypes, args: &Value, ctx: &ToolContext<'_>) -> Result<ToolOutput, String> {
let chat = chat_of(ctx)?;
let a: CreateArgs = parse_args(args)?;
let title = clip(&a.title, MAX_TITLE_CHARS);
if title.is_empty() {
return Err("give the artifact a title".into());
}
let (filename, ext) = clean_filename(types, &a.filename, &title)?;
let ty = types.for_filename(&filename);
let (content, warning) = check_content(&ty, &a.content)?;
let (id, dir) = new_artifact_dir(ctx.workspace, &title)?;
let result = (|| {
let file = write_version(&dir, 1, &ext, &content)?;
let root = sandbox::root(ctx.workspace)?;
let version = ArtifactVersion { n: 1, path: rel(&root, &file), created_at: now_ms(), note: clip(&a.note, MAX_NOTE_CHARS), size: content.len() as u64 };
let artifact = Artifact {
id: id.clone(),
kind: ty.kind,
content_type: ty.content_type.clone(),
title: title.clone(),
filename: filename.clone(),
versions: vec![version],
};
save(&dir, &Meta { chat: chat.to_owned(), artifact })?;
Ok::<_, String>(file)
})();
let file = match result {
Ok(f) => f,
Err(e) => {
let _ = std::fs::remove_dir_all(&dir);
return Err(e);
}
};
let text = format!(
"Created the {} artifact \"{title}\" (id {id}, version 1, {filename}). It shows beside the chat. To change it later, call update_artifact with id {id}.{warning}",
ty.label
);
Ok(ToolOutput { text, failed: false, changes: vec![FileChange::Created(file)], images: Vec::new() })
}
impl Tool for UpdateArtifact {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "update_artifact".into(),
description: "Save a new version of an artifact. Prefer `edits`: find/replace pairs applied in order, where each `find` is exact text that occurs exactly once \
in the current version (include enough surrounding text to make it unique). Send `content` (the whole file) only for a rewrite. \
Use read_artifact first if you don't have the current text."
.into(),
parameters: json!({
"type": "object",
"properties": {
"id": {"type": "string", "description": "The artifact's id"},
"edits": {"type": "array", "items": {"type": "object", "properties": {"find": {"type": "string"}, "replace": {"type": "string"}}, "required": ["find", "replace"]}},
"content": {"type": "string", "description": "Or: the whole new file"},
"note": {"type": "string", "description": "What changed, in a few words"}
},
"required": ["id"]
}),
}
}
fn title(&self, args: &Value) -> String {
format!("Updated {}", clip(arg_str(args, "id"), 60))
}
fn pending_title(&self, args: &Value) -> String {
format!("Update {}", clip(arg_str(args, "id"), 60))
}
fn access(&self, _args: &Value) -> Access {
Access::Read
}
fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput {
match update(&self.types, args, ctx) {
Ok(out) => out,
Err(e) => ToolOutput::err(e),
}
}
}
pub fn apply_edits(text: &str, edits: &[(String, String)]) -> Result<String, String> {
let opts =
crate::edits::Opts { replace_all: false, offer_replace_all: false, max_bytes: MAX_CONTENT_BYTES, what: "the current version", reread: "read_artifact" };
crate::edits::apply(text, edits, &opts).map(|a| a.text)
}
fn update(types: &ArtifactTypes, args: &Value, ctx: &ToolContext<'_>) -> Result<ToolOutput, String> {
let chat = chat_of(ctx)?;
let a: UpdateArgs = parse_args(args)?;
let mut meta = load_in_chat(ctx.workspace, chat, a.id.trim())?;
let id = meta.artifact.id.clone();
let ty = types.of(&meta.artifact);
if !ty.text {
return Err(format!("{id} is not a text file ({}); it can't be edited as text. Make a new one instead", ty.label));
}
if meta.artifact.versions.len() >= MAX_VERSIONS {
return Err(format!("{id} has too many versions; create a new artifact"));
}
let latest = meta.artifact.latest().cloned().ok_or_else(|| format!("artifact {id:?} has no versions"))?;
let new_text = match (a.content, a.edits) {
(Some(_), Some(_)) => return Err("give either content or edits, not both".into()),
(None, None) => return Err("give edits (find/replace pairs) or the whole new content".into()),
(Some(content), None) => content,
(None, Some(edits)) => {
let file = version_file(ctx.workspace, &latest.path).ok_or_else(|| format!("the latest version of {id} is gone"))?;
let bytes = read_capped(&file, MAX_FILE_BYTES)?;
let current = String::from_utf8(bytes).map_err(|_| format!("{id} is not a text file"))?;
let pairs: Vec<(String, String)> = edits.into_iter().map(|e| (e.find, e.replace)).collect();
let out = apply_edits(¤t, &pairs)?;
if out == current {
return Err("the edits change nothing".into());
}
out
}
};
let (content, warning) = check_content(&ty, &new_text)?;
let (root, dir) = artifact_dir(ctx.workspace, &id)?;
let ext = latest.path.rsplit_once("/v").and_then(|(_, f)| f.split_once('.')).map(|(_, e)| e.to_owned()).unwrap_or_else(|| {
let lower = meta.artifact.filename.to_ascii_lowercase();
types.compound_extension(&lower).unwrap_or_else(|| lower.rsplit_once('.').map_or("txt".to_owned(), |(_, e)| e.to_owned()))
});
let all = serde_json::from_slice::<Meta>(&read_capped(&dir.join(META_FILE), MAX_META_BYTES)?).map_err(|e| e.to_string())?;
let n = all.artifact.versions.iter().map(|v| v.n).max().unwrap_or(0).saturating_add(1);
let file = write_version(&dir, n, &ext, &content)?;
let note = clip(&a.note, MAX_NOTE_CHARS);
meta.artifact.versions = all.artifact.versions;
meta.artifact.versions.push(ArtifactVersion { n, path: rel(&root, &file), created_at: now_ms(), note: note.clone(), size: content.len() as u64 });
if let Err(e) = save(&dir, &meta) {
let _ = std::fs::remove_file(&file);
return Err(e);
}
let text =
format!("Saved version {n} of \"{}\" (id {id}){}.{warning}", meta.artifact.title, if note.is_empty() { String::new() } else { format!(": {note}") });
Ok(ToolOutput { text, failed: false, changes: vec![FileChange::Created(file)], images: Vec::new() })
}
impl Tool for ReadArtifact {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "read_artifact".into(),
description:
"Read the current text of an artifact (or of an older version), e.g. before changing it with update_artifact. Image artifacts are shown to you."
.into(),
parameters: json!({
"type": "object",
"properties": {
"id": {"type": "string"},
"version": {"type": "integer", "description": "Optional; default the latest"}
},
"required": ["id"]
}),
}
}
fn title(&self, args: &Value) -> String {
format!("Read {}", clip(arg_str(args, "id"), 60))
}
fn pending_title(&self, args: &Value) -> String {
self.title(args)
}
fn access(&self, _args: &Value) -> Access {
Access::Read
}
fn run(&self, args: &Value, ctx: &ToolContext<'_>) -> ToolOutput {
let result = (|| {
let chat = chat_of(ctx)?;
let a: ReadArgs = parse_args(args)?;
let meta = load_in_chat(ctx.workspace, chat, a.id.trim())?;
let v = match a.version {
Some(n) => meta.artifact.version(n).cloned().ok_or_else(|| format!("{} has no version {n}", meta.artifact.id))?,
None => meta.artifact.latest().cloned().ok_or("no versions")?,
};
let ty = self.types.of(&meta.artifact);
if !ty.text {
let (_, bytes) = read_version(ctx.workspace, chat, &meta.artifact.id, v.n, MAX_FILE_BYTES)?;
if crate::images::sniff(&bytes).is_none() {
return Err(format!("{} is not a text file ({}); it can't be read as text", meta.artifact.id, ty.label));
}
let (mime, data) = crate::images::fit(bytes, crate::images::MODEL_IMAGE_SIDE, byob_protocol::media::MAX_IMAGE_BYTES)?;
let text = format!(
"{} \"{}\", version {} of {} ({}): an image",
meta.artifact.id,
meta.artifact.title,
v.n,
meta.artifact.versions.len(),
meta.artifact.filename
);
return Ok(ToolOutput { images: vec![format!("data:{mime};base64,{}", byob_protocol::media::encode(&data))], ..ToolOutput::ok(text) });
}
let (_, bytes) = read_version(ctx.workspace, chat, &meta.artifact.id, v.n, MAX_FILE_BYTES)?;
let text = String::from_utf8_lossy(&bytes);
let shown: String = text.chars().take(MAX_READ_CHARS).collect();
let more = if shown.len() < text.len() { "\n… [cut off]" } else { "" };
Ok(ToolOutput::ok(format!(
"{} \"{}\", version {} of {} ({}):\n{shown}{more}",
meta.artifact.id,
meta.artifact.title,
v.n,
meta.artifact.versions.len(),
meta.artifact.filename
)))
})();
match result {
Ok(out) => out,
Err(e) => ToolOutput::err(e),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::AtomicBool;
fn run_in(tool: &dyn Tool, ws: &Path, chat: &str, args: Value) -> ToolOutput {
let cancel = AtomicBool::new(false);
tool.run(&args, &ToolContext::new(ws, &cancel).with_chat(chat))
}
fn id_of(out: &ToolOutput) -> String {
let p = match &out.changes[..] {
[FileChange::Created(p)] => p.to_string_lossy().into_owned(),
other => panic!("{other:?} / {}", out.text),
};
parse_version_path(&p).unwrap().id
}
const HTML: &str = "<!doctype html><button style=\"background:red\">Go</button><button>Stop</button>";
#[test]
fn create_update_read_and_versions() {
let d = tempfile::tempdir().unwrap();
let ws = d.path();
let out = run_in(&CreateArtifact::default(), ws, "c1", json!({"title": "Loan calculator", "filename": "loan.html", "content": HTML}));
assert!(!out.failed, "{}", out.text);
let id = id_of(&out);
assert!(id.starts_with("loan-calculator-"), "{id}");
assert!(out.text.contains(&id));
let a = &list(ws, "c1")[0];
assert_eq!((a.kind, a.filename.as_str(), a.versions.len()), (ArtifactKind::Html, "loan.html", 1));
assert_eq!(a.versions[0].path, format!(".byob/artifacts/{id}/v1.html"));
assert!(list(ws, "other").is_empty(), "artifacts are per chat");
let out = run_in(
&UpdateArtifact::default(),
ws,
"c1",
json!({"id": id, "edits": [{"find": "background:red", "replace": "background:blue"}], "note": "blue"}),
);
assert!(!out.failed, "{}", out.text);
assert_eq!(parse_version_path(&id_of_path(&out)).unwrap().version, 2);
let (a, v2) = read_version(ws, "c1", &id, 2, MAX_FILE_BYTES).unwrap();
assert_eq!(String::from_utf8(v2).unwrap(), HTML.replace("red", "blue"));
assert_eq!(a.latest().unwrap().note, "blue");
assert_eq!(read_version(ws, "c1", &id, 1, MAX_FILE_BYTES).unwrap().1, HTML.as_bytes());
let read = run_in(&ReadArtifact::default(), ws, "c1", json!({"id": id}));
assert!(read.text.contains("version 2") && read.text.contains("background:blue"), "{}", read.text);
let other_chat = run_in(&ReadArtifact::default(), ws, "c2", json!({"id": id}));
assert!(other_chat.failed);
assert!(run_in(&UpdateArtifact::default(), ws, "c2", json!({"id": id, "content": "x"})).failed, "can't touch another chat's artifact");
assert!(read_version(ws, "c2", &id, 1, MAX_FILE_BYTES).is_err());
let out = run_in(&UpdateArtifact::default(), ws, "c1", json!({"id": id, "content": "<p>new</p>"}));
assert!(!out.failed, "{}", out.text);
assert_eq!(load(ws, &id).unwrap().artifact.latest().unwrap().n, 3);
std::fs::remove_file(ws.join(format!(".byob/artifacts/{id}/v3.html"))).unwrap();
assert_eq!(load(ws, &id).unwrap().artifact.latest().unwrap().n, 2);
let out = run_in(&UpdateArtifact::default(), ws, "c1", json!({"id": id, "content": "<p>again</p>"}));
assert!(!out.failed, "{}", out.text);
assert_eq!(load(ws, &id).unwrap().artifact.versions.iter().map(|v| v.n).collect::<Vec<_>>(), vec![1, 2, 4]);
}
#[test]
fn removing_a_chat_removes_only_its_artifacts() {
let d = tempfile::tempdir().unwrap();
let ws = d.path();
for (chat, title) in [("c1", "One"), ("c1", "Two"), ("c2", "Other")] {
assert!(!run_in(&CreateArtifact::default(), ws, chat, json!({"title": title, "filename": "a.md", "content": "# hi"})).failed);
}
assert_eq!(remove_chat(ws, "c1"), 2);
assert!(list(ws, "c1").is_empty());
assert_eq!(list(ws, "c2").len(), 1);
assert_eq!(std::fs::read_dir(ws.join(ARTIFACT_DIR)).unwrap().count(), 1, "nothing left behind on disk");
assert_eq!(remove_chat(ws, "c1"), 0);
assert_eq!(remove_chat(&ws.join("missing"), "c2"), 0);
}
fn id_of_path(out: &ToolOutput) -> String {
match &out.changes[..] {
[FileChange::Created(p)] => p.to_string_lossy().into_owned(),
other => panic!("{other:?}"),
}
}
#[test]
fn edits_must_match_exactly_once() {
let t = "a b a c";
assert_eq!(apply_edits(t, &[("b".into(), "B".into()), ("c".into(), "C".into())]).unwrap(), "a B a C");
let e = apply_edits(t, &[("a".into(), "x".into())]).unwrap_err();
assert!(e.contains("occurs 2 times") && e.contains("edit 1"), "{e}");
let e = apply_edits(t, &[("b".into(), "B".into()), ("zz".into(), "x".into())]).unwrap_err();
assert!(e.contains("edit 2") && e.contains("not in the current version") && e.contains("read_artifact"), "{e}");
assert_eq!(apply_edits(t, &[("b".into(), "q".into()), ("q a".into(), "r".into())]).unwrap(), "a r c");
assert!(apply_edits(t, &[]).is_err());
assert!(apply_edits(t, &[(String::new(), "x".into())]).is_err());
let many: Vec<(String, String)> = (0..=MAX_EDITS).map(|i| (format!("{i}"), String::new())).collect();
assert!(apply_edits(t, &many).unwrap_err().contains("too many"));
let big = "x".repeat(MAX_CONTENT_BYTES);
assert!(apply_edits("ab", &[("a".into(), big)]).unwrap_err().contains("too big"));
}
#[test]
fn failed_edits_write_nothing() {
let d = tempfile::tempdir().unwrap();
let ws = d.path();
let id = id_of(&run_in(&CreateArtifact::default(), ws, "c", json!({"title": "T", "filename": "t.html", "content": HTML})));
let out = run_in(&UpdateArtifact::default(), ws, "c", json!({"id": id, "edits": [{"find": "button", "replace": "x"}]}));
assert!(out.failed && out.text.contains("occurs 4 times"), "{}", out.text);
assert!(out.changes.is_empty());
assert_eq!(load(ws, &id).unwrap().artifact.versions.len(), 1);
let both = run_in(&UpdateArtifact::default(), ws, "c", json!({"id": id, "content": "x", "edits": [{"find": "Go", "replace": "x"}]}));
assert!(both.failed && both.text.contains("not both"));
assert!(run_in(&UpdateArtifact::default(), ws, "c", json!({"id": id})).failed);
assert!(run_in(&UpdateArtifact::default(), ws, "c", json!({"id": "../../etc", "content": "x"})).failed);
let same = run_in(&UpdateArtifact::default(), ws, "c", json!({"id": id, "edits": [{"find": "Go", "replace": "Go"}]}));
assert!(same.failed && same.text.contains("change nothing"));
}
#[test]
fn create_validates_names_kinds_and_sizes() {
let d = tempfile::tempdir().unwrap();
let ws = d.path();
let c = |args: Value| run_in(&CreateArtifact::default(), ws, "c", args);
assert!(c(json!({"title": "T", "filename": "noext", "content": "x"})).text.contains("extension"));
assert!(c(json!({"title": "T", "filename": "p.png", "content": "x"})).text.contains("images"));
assert!(c(json!({"title": "", "filename": "a.md", "content": "x"})).failed);
assert!(c(json!({"title": "T", "filename": "a.md", "content": " "})).failed);
let big = "x".repeat(MAX_CONTENT_BYTES + 1);
assert!(c(json!({"title": "T", "filename": "a.md", "content": big})).text.contains("too big"));
assert!(c(json!({"title": "T", "filename": "a.chart.json", "content": "{nope"})).failed);
assert!(list(ws, "c").is_empty());
let root = ws.join(ARTIFACT_DIR);
assert_eq!(std::fs::read_dir(&root).map(|r| r.count()).unwrap_or(0), 0);
let md = c(json!({"title": "Q3 summary", "filename": "../../q3.md", "content": "# Q3\n"}));
assert!(!md.failed, "{}", md.text);
let chart = c(json!({"title": "Sales", "filename": "sales.chart.json",
"content": r#"{"type":"bar","x":"m","series":["v"],"data":[{"m":"a","v":1}]}"#}));
assert!(!chart.failed, "{}", chart.text);
let csv = c(json!({"title": "Prices", "filename": "prices.csv", "content": "a,b\n1,2\n"}));
let net = c(json!({"title": "Net", "filename": "n.html", "content": "<script src=\"https://cdn.example/x.js\"></script>"}));
assert!(net.text.contains("blocked"), "{}", net.text);
let kinds: Vec<(ArtifactKind, String)> = list(ws, "c").into_iter().map(|a| (a.kind, a.filename)).collect();
assert_eq!(
kinds,
vec![
(ArtifactKind::Markdown, "q3.md".into()),
(ArtifactKind::Chart, "sales.chart.json".into()),
(ArtifactKind::Text, "prices.csv".into()),
(ArtifactKind::Html, "n.html".into())
]
);
assert!(!csv.failed);
assert!(Path::new(&id_of_path(&chart)).ends_with("v1.chart.json"));
let cancel = AtomicBool::new(false);
assert!(CreateArtifact::default().run(&json!({"title": "T", "filename": "a.md", "content": "x"}), &ToolContext::new(ws, &cancel)).failed);
}
#[test]
fn a_registered_type_is_used_end_to_end() {
struct Rows;
impl crate::ArtifactHooks for Rows {
fn check(&self, content: &str) -> Result<(String, String), String> {
if content.lines().all(|l| l.split(',').count() == 2) {
Ok((content.to_owned(), " Two columns.".into()))
} else {
Err("every row needs two cells".into())
}
}
}
let types = ArtifactTypes::builtin();
types.register(ArtifactType::new("text/csv", "table", ArtifactKind::Text).suffixes(&[".csv"]).hooks(std::sync::Arc::new(Rows)));
let d = tempfile::tempdir().unwrap();
let ws = d.path();
let create = CreateArtifact { types: types.clone() };
assert!(run_in(&create, ws, "c", json!({"title": "P", "filename": "p.csv", "content": "a,b,c"})).failed);
let out = run_in(&create, ws, "c", json!({"title": "P", "filename": "p.csv", "content": "a,b\n1,2"}));
assert!(out.text.contains("Created the table artifact") && out.text.ends_with("Two columns."), "{}", out.text);
let a = &list(ws, "c")[0];
assert_eq!((a.kind, a.content_type.as_str()), (ArtifactKind::Text, "text/csv"));
let update = UpdateArtifact { types: types.clone() };
assert!(run_in(&update, ws, "c", json!({"id": a.id, "edits": [{"find": "1,2", "replace": "1,2,3"}]})).failed);
assert!(!run_in(&update, ws, "c", json!({"id": a.id, "edits": [{"find": "1,2", "replace": "3,4"}]})).failed);
assert!(prompt_list(&types, &list(ws, "c")).contains("(table, v2, p.csv)"));
assert!(prompt_list(&ArtifactTypes::builtin(), &list(ws, "c")).contains("(file, v2, p.csv)"));
}
#[cfg(unix)]
#[test]
fn never_writes_through_a_linked_artifact_folder() {
let outside = tempfile::tempdir().unwrap();
let d = tempfile::tempdir().unwrap();
std::fs::create_dir(d.path().join(".byob")).unwrap();
std::os::unix::fs::symlink(outside.path(), d.path().join(ARTIFACT_DIR)).unwrap();
let out = run_in(&CreateArtifact::default(), d.path(), "c", json!({"title": "T", "filename": "a.md", "content": "x"}));
assert!(out.failed, "{}", out.text);
assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
}
#[test]
fn charts_and_images_are_adopted() {
let d = tempfile::tempdir().unwrap();
let ws = d.path();
std::fs::create_dir(ws.join("charts")).unwrap();
std::fs::write(ws.join("charts/Sales.chart.json"), "{}").unwrap();
std::fs::write(ws.join("plot.png"), "png").unwrap();
let media = vec![MediaRef::new("charts/Sales.chart.json", 2), MediaRef::new("plot.png", 3), MediaRef::new("notes.txt", 1)];
let refs = for_call(ws, "c", &[], &media);
assert_eq!(refs.len(), 2);
let list = list(ws, "c");
assert_eq!(list.iter().map(|a| (a.kind, a.title.as_str())).collect::<Vec<_>>(), vec![(ArtifactKind::Chart, "Sales"), (ArtifactKind::Image, "plot")]);
assert_eq!(list[1].versions[0].path, "plot.png");
let p = ws.join(".byob/artifacts/x-1/v3.md");
assert_eq!(for_call(ws, "c", &[FileChange::Created(p)], &media), vec![ArtifactRef { id: "x-1".into(), version: 3 }]);
}
#[test]
fn image_artifacts_are_shown_to_the_model() {
let d = tempfile::tempdir().unwrap();
let ws = d.path();
let mut png = Vec::new();
image::RgbImage::new(4, 4).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
std::fs::write(ws.join("plot.png"), &png).unwrap();
for_call(ws, "c", &[], &[MediaRef::new("plot.png", png.len() as u64)]);
let id = list(ws, "c")[0].id.clone();
let out = run_in(&ReadArtifact { types: ArtifactTypes::builtin() }, ws, "c", json!({"id": id}));
assert!(!out.failed && out.text.contains("an image"), "{}", out.text);
assert_eq!(out.images, vec![format!("data:image/png;base64,{}", byob_protocol::media::encode(&png))]);
}
#[test]
fn version_paths() {
assert_eq!(parse_version_path(".byob/artifacts/a-1/v12.chart.json"), Some(ArtifactRef { id: "a-1".into(), version: 12 }));
assert_eq!(parse_version_path("/w/.byob/artifacts/a/v1.html"), Some(ArtifactRef { id: "a".into(), version: 1 }));
for bad in [".byob/artifacts/a/artifact.json", ".byob/artifacts/A/v1.md", ".byob/artifacts/a/v0.md", "charts/x.chart.json", ".byob/artifacts/a/b/v1.md"]
{
assert_eq!(parse_version_path(bad), None, "{bad}");
}
}
#[test]
fn prompt_lists_latest_versions() {
let d = tempfile::tempdir().unwrap();
let id = id_of(&run_in(&CreateArtifact::default(), d.path(), "c", json!({"title": "Calc", "filename": "calc.html", "content": HTML})));
let p = prompt_list(&ArtifactTypes::builtin(), &list(d.path(), "c"));
assert!(p.contains(&format!("{id}: \"Calc\" (app, v1, calc.html)")), "{p}");
assert_eq!(prompt_list(&ArtifactTypes::builtin(), &[]), "");
}
#[test]
fn bytes_from_a_plugin_become_versions_and_drafts_replace_one() {
let d = tempfile::tempdir().unwrap();
let types = ArtifactTypes::builtin();
types.register(ArtifactType::new("application/x-sheet", "spreadsheet", ArtifactKind::Text).suffixes(&[".xlsx"]).binary());
types.register(ArtifactType::new("text/csv", "table", ArtifactKind::Text).suffixes(&[".csv"]));
let save = |s: SaveBytes<'_>| save_bytes(&types, d.path(), &s);
assert!(save(SaveBytes { chat: "c", bytes: b"a,b\n1,2\n", ..Default::default() }).unwrap_err().contains("title"));
let a = save(SaveBytes { chat: "c", title: Some("Sales"), filename: Some("sales.csv"), bytes: b"a,b\n1,2\n", ..Default::default() }).unwrap();
assert!(a.created && a.version == 1 && a.artifact.content_type == "text/csv", "{a:?}");
let id = a.artifact.id.clone();
assert_eq!(parse_version_path(&a.file.to_string_lossy()), Some(ArtifactRef { id: id.clone(), version: 1 }));
let bin = [0u8, 159, 146, 150, 1, 2];
let b = save(SaveBytes { chat: "c", id: Some(&id), filename: Some("sales.xlsx"), bytes: &bin, note: "imported", ..Default::default() }).unwrap();
assert_eq!((b.version, b.created, b.replaced), (2, false, false));
assert_eq!((b.artifact.filename.as_str(), b.artifact.content_type.as_str()), ("sales.xlsx", "application/x-sheet"));
assert!(b.file.ends_with("v2.xlsx"));
assert_eq!(std::fs::read(&b.file).unwrap(), bin);
let c = save(SaveBytes { chat: "c", id: Some(&id), bytes: b"draft", replace: Some(2), ..Default::default() }).unwrap();
assert_eq!((c.version, c.replaced), (2, true));
assert_eq!(std::fs::read(&c.file).unwrap(), b"draft");
assert_eq!(load(d.path(), &id).unwrap().artifact.version(2).unwrap().size, 5);
let e = save(SaveBytes { chat: "c", id: Some(&id), bytes: b"again", replace: Some(1), ..Default::default() }).unwrap();
assert_eq!((e.version, e.replaced), (3, false));
assert_eq!(e.artifact.versions.iter().map(|v| v.n).collect::<Vec<_>>(), [1, 2, 3]);
assert!(save(SaveBytes { chat: "other", id: Some(&id), bytes: b"x", ..Default::default() }).is_err());
assert!(save(SaveBytes { chat: "c", id: Some(&id), bytes: b"", ..Default::default() }).is_err());
assert!(save(SaveBytes { chat: "c", id: Some("nope"), bytes: b"x", ..Default::default() }).is_err());
}
}