From 1cb8aa6cb80d9c9dee6acf0c590f41d4490a651e Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:03:48 -0300 Subject: [PATCH 1/2] feat(files): list and read the files a bot made files.list finds them in the crew's work folder and the bot's folder (only what changed after the bot was created), plus every file its Write/Edit calls touched, which tool items now record in a new file field. files.read gives a file's bytes to the app and refuses any path outside those places. Spec 8.4 and 11.2. --- app/src/lib/protocol.gen.ts | 62 ++++++- crates/botloft-core/src/chat.rs | 9 + crates/botloft-core/src/protocol/chat.rs | 5 + crates/botloft-core/src/protocol/export.rs | 14 ++ crates/botloft-core/src/protocol/files.rs | 64 +++++++ crates/botloft-core/src/protocol/mod.rs | 4 + crates/botloft-store/src/chat.rs | 53 ++++++ crates/botloftd/src/chat/events.rs | 3 +- crates/botloftd/src/rpc/dispatch.rs | 6 +- crates/botloftd/src/service/files/mod.rs | 161 ++++++++++++++++++ crates/botloftd/src/service/files/scan.rs | 184 +++++++++++++++++++++ crates/botloftd/src/service/mod.rs | 1 + crates/botloftd/tests/files.rs | 130 +++++++++++++++ 13 files changed, 692 insertions(+), 4 deletions(-) create mode 100644 crates/botloft-core/src/protocol/files.rs create mode 100644 crates/botloftd/src/service/files/mod.rs create mode 100644 crates/botloftd/src/service/files/scan.rs create mode 100644 crates/botloftd/tests/files.rs diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index bbb1d9f4..0ac2262b 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -409,6 +409,59 @@ export type AttachmentData = { mediaType: string, */ data: string, }; +/** + * A file in the bot's folder or the crew's work folder, or one the bot's + * own `Write`/`Edit` calls changed. + */ +export type BotFile = { +/** + * Absolute path. + */ +path: string, +/** + * File name, without folders. + */ +name: string, +/** + * Folder it is in, relative to the work folder or the bot's folder when + * it is inside one, else the absolute folder; empty at the top. + */ +folder: string, +/** + * Guessed from the extension. + */ +mediaType: string, +/** + * Bytes. + */ +size: number, +/** + * Unix time in milliseconds. + */ +modifiedAt: number, +/** + * The bot's own `Write`/`Edit` calls changed it, as opposed to a file + * that only showed up in the folder. + */ +writtenByBot: boolean, }; + +export type FilesListParams = { botId: BotId, }; + +export type FilesReadParams = { botId: BotId, +/** + * A `BotFile.path`. + */ +path: string, }; + +/** + * A file's bytes, for the app to preview. + */ +export type FileData = { mediaType: string, +/** + * The file's bytes, base64. + */ +data: string, }; + /** * Text sent to a bot, by the owner, another bot or the daemon. */ @@ -552,7 +605,12 @@ input: string, status: ToolStatus, /** * The start of what the tool returned, cut at 8 KB. */ -output: string | null, }; +output: string | null, +/** + * The full path of the file a `Write`/`Edit`/`NotebookEdit` call + * changes; absent for other tools and for items stored before it existed. + */ +file: string | null, }; export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, input: string, status: ApprovalStatus, /** @@ -746,6 +804,8 @@ export interface RpcMethods { "messages.send": { params: MessagesSendParams; result: Message }; "messages.list": { params: MessagesListParams; result: Array }; "attachments.read": { params: AttachmentIdParams; result: AttachmentData }; + "files.list": { params: FilesListParams; result: Array }; + "files.read": { params: FilesReadParams; result: FileData }; "deliveries.list": { params: DeliveriesListParams; result: Array }; "deliveries.retry": { params: DeliveryIdParams; result: Delivery }; "tasks.list": { params: TasksListParams; result: Array }; diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs index 779ffcfa..7198445a 100644 --- a/crates/botloft-core/src/chat.rs +++ b/crates/botloft-core/src/chat.rs @@ -101,6 +101,15 @@ pub fn tool_summary(name: &str, input: &Value) -> String { one_line(&summary.unwrap_or_default(), SUMMARY_MAX_CHARS) } +/// The full path of the file a `Write`/`Edit`/`NotebookEdit` call changes. +pub fn tool_file(name: &str, input: &Value) -> Option { + matches!(name, "Edit" | "MultiEdit" | "Write" | "NotebookEdit") + .then(|| field(input, "file_path").or_else(|| field(input, "notebook_path"))) + .flatten() + .filter(|path| !path.is_empty() && path.len() <= 1024) + .map(str::to_owned) +} + /// The last part of a path, whichever slash it uses. fn file_name(path: &str) -> String { path.rsplit(['/', '\\']) diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs index f8ac8ad4..092158a8 100644 --- a/crates/botloft-core/src/protocol/chat.rs +++ b/crates/botloft-core/src/protocol/chat.rs @@ -121,6 +121,10 @@ pub struct ToolItem { pub status: ToolStatus, /// The start of what the tool returned, cut at 8 KB. pub output: Option, + /// The full path of the file a `Write`/`Edit`/`NotebookEdit` call + /// changes; absent for other tools and for items stored before it existed. + #[serde(default)] + pub file: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -259,6 +263,7 @@ mod tests { input: "{}".into(), status: ToolStatus::Running, output: None, + file: None, }); let json = serde_json::to_value(&tool).expect("serialize"); assert_eq!(json["kind"], "tool"); diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index fd20fbef..6dec278d 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -131,6 +131,10 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -255,6 +259,16 @@ fn export_bindings() { &out.name::(), &out.name::(), ); + out.method( + method::FILES_LIST, + &out.name::(), + &out.name::>(), + ); + out.method( + method::FILES_READ, + &out.name::(), + &out.name::(), + ); out.method( method::DELIVERIES_LIST, &out.name::(), diff --git a/crates/botloft-core/src/protocol/files.rs b/crates/botloft-core/src/protocol/files.rs new file mode 100644 index 00000000..d529331b --- /dev/null +++ b/crates/botloft-core/src/protocol/files.rs @@ -0,0 +1,64 @@ +//! The files a bot made, for the app's files panel (spec 8.5). + +use serde::{Deserialize, Serialize}; + +use crate::ids::BotId; + +/// A file in the bot's folder or the crew's work folder, or one the bot's +/// own `Write`/`Edit` calls changed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct BotFile { + /// Absolute path. + pub path: String, + /// File name, without folders. + pub name: String, + /// Folder it is in, relative to the work folder or the bot's folder when + /// it is inside one, else the absolute folder; empty at the top. + pub folder: String, + /// Guessed from the extension. + pub media_type: String, + /// Bytes. + pub size: u64, + /// Unix time in milliseconds. + pub modified_at: i64, + /// The bot's own `Write`/`Edit` calls changed it, as opposed to a file + /// that only showed up in the folder. + pub written_by_bot: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct FilesListParams { + pub bot_id: BotId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct FilesReadParams { + pub bot_id: BotId, + /// A `BotFile.path`. + pub path: String, +} + +/// A file's bytes, for the app to preview. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct FileData { + pub media_type: String, + /// The file's bytes, base64. + pub data: String, +} + +impl std::fmt::Debug for FileData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FileData") + .field("media_type", &self.media_type) + .field("data", &format_args!("<{} base64 chars>", self.data.len())) + .finish() + } +} diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 6d4b3a95..bf2a9360 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -52,12 +52,14 @@ macro_rules! text_enum { mod chat; #[cfg(test)] mod export; +mod files; mod messaging; mod methods; mod model; mod routines; pub use chat::*; +pub use files::*; pub use messaging::*; pub use methods::*; pub use model::*; @@ -92,6 +94,8 @@ pub mod method { pub const MESSAGES_SEND: &str = "messages.send"; pub const MESSAGES_LIST: &str = "messages.list"; pub const ATTACHMENTS_READ: &str = "attachments.read"; + pub const FILES_LIST: &str = "files.list"; + pub const FILES_READ: &str = "files.read"; pub const DELIVERIES_LIST: &str = "deliveries.list"; pub const DELIVERIES_RETRY: &str = "deliveries.retry"; pub const TASKS_LIST: &str = "tasks.list"; diff --git a/crates/botloft-store/src/chat.rs b/crates/botloft-store/src/chat.rs index c07099ad..7a81257b 100644 --- a/crates/botloft-store/src/chat.rs +++ b/crates/botloft-store/src/chat.rs @@ -131,6 +131,21 @@ impl Store { .optional()?) } + /// Paths of the files the bot's `Write`/`Edit` calls changed, newest + /// first and without repeats; failed calls do not count. + pub fn written_files(&self, bot: &BotId, limit: u32) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT json_extract(data, '$.file') FROM chat_items \ + WHERE bot_id = ?1 AND kind = 'tool' \ + AND json_extract(data, '$.file') IS NOT NULL \ + AND json_extract(data, '$.status') != 'failed' \ + GROUP BY json_extract(data, '$.file') \ + ORDER BY MAX(rowid) DESC LIMIT ?2", + )?; + let rows = stmt.query_map(params![bot.as_str(), limit], |row| row.get(0))?; + Ok(rows.collect::>()?) + } + /// The conversation-list line: the newest item that has one. pub fn last_activity(&self, bot: &BotId) -> Result> { let mut stmt = self.conn.prepare(&format!( @@ -208,6 +223,7 @@ mod tests { input: "{}".into(), status: ToolStatus::Running, output: None, + file: None, }; let entry = item(bot, ChatBody::Tool(running.clone()), 5); fx.store.insert_chat_item(&entry).expect("insert"); @@ -235,6 +251,43 @@ mod tests { ); } + #[test] + fn written_files_come_newest_first_without_repeats_or_failures() { + let fx = Fixture::new(); + let bot = &fx.bots[0].id; + let write = |file: Option<&str>, status: ToolStatus| { + ChatBody::Tool(ToolItem { + tool_use_id: "t".into(), + name: "Write".into(), + summary: String::new(), + input: "{}".into(), + status, + output: None, + file: file.map(str::to_owned), + }) + }; + for (n, (file, status)) in [ + (Some(r"C:\work\a.md"), ToolStatus::Done), + (Some(r"C:\work\b.pdf"), ToolStatus::Done), + (Some(r"C:\work\bad.md"), ToolStatus::Failed), + (None, ToolStatus::Done), + (Some(r"C:\work\a.md"), ToolStatus::Done), + ] + .into_iter() + .enumerate() + { + fx.store + .insert_chat_item(&item(bot, write(file, status), n as i64)) + .expect("insert"); + } + let files = fx.store.written_files(bot, 10).expect("files"); + assert_eq!(files, [r"C:\work\a.md", r"C:\work\b.pdf"]); + assert_eq!( + fx.store.written_files(bot, 1).expect("one"), + [r"C:\work\a.md"] + ); + } + #[test] fn the_last_activity_skips_turn_ends() { let fx = Fixture::new(); diff --git a/crates/botloftd/src/chat/events.rs b/crates/botloftd/src/chat/events.rs index 62e6a3a2..56ca5dc6 100644 --- a/crates/botloftd/src/chat/events.rs +++ b/crates/botloftd/src/chat/events.rs @@ -2,7 +2,7 @@ //! (spec 8.1). Fields are read defensively: the format is not documented //! and grows between Claude Code versions. -use botloft_core::chat::{TOOL_OUTPUT_MAX, clip, tool_input_max, tool_summary}; +use botloft_core::chat::{TOOL_OUTPUT_MAX, clip, tool_file, tool_input_max, tool_summary}; use botloft_core::ids::BotId; use botloft_core::protocol::{ AccountUsage, ChatBody, ChatDelta, NoticeCode, NoticeItem, NoticeLevel, ReplyItem, ToolItem, @@ -123,6 +123,7 @@ fn assistant(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { input: clip(&input.to_string(), tool_input_max(name)), status: ToolStatus::Running, output: None, + file: tool_file(name, input), }), ); } diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 2f25c020..346ef47d 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -8,8 +8,8 @@ use serde_json::Value; use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ - self, ApiResult, attachments, bots, chat, crews, deliveries, lead, messages, models, modes, - routines, tasks, + self, ApiResult, attachments, bots, chat, crews, deliveries, files, lead, messages, models, + modes, routines, tasks, }; use crate::state::Daemon; @@ -43,6 +43,8 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(messages::send(daemon, parse(params)?)), method::MESSAGES_LIST => reply(messages::list(daemon, parse(params)?)), method::ATTACHMENTS_READ => reply(attachments::read(daemon, parse(params)?)), + method::FILES_LIST => reply(files::list(daemon, parse(params)?)), + method::FILES_READ => reply(files::read(daemon, parse(params)?)), method::DELIVERIES_LIST => reply(deliveries::list(daemon, parse(params)?)), method::DELIVERIES_RETRY => reply(deliveries::retry(daemon, parse(params)?)), method::ROUTINES_LIST => reply(routines::list(daemon, parse(params)?)), diff --git a/crates/botloftd/src/service/files/mod.rs b/crates/botloftd/src/service/files/mod.rs new file mode 100644 index 00000000..b965fed7 --- /dev/null +++ b/crates/botloftd/src/service/files/mod.rs @@ -0,0 +1,161 @@ +//! The files a bot made (spec 8.5): what the app's files panel lists and +//! previews. They are in the crew's work folder or the bot's own folder, or +//! wherever the bot's `Write`/`Edit` calls put them. + +mod scan; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use botloft_core::ids::BotId; +use botloft_core::protocol::{BotFile, FileData, FilesListParams, FilesReadParams}; +use botloft_store::BotRecord; + +use self::scan::{Found, Root, key, keys, media_type, millis, walk}; +use super::{ApiError, ApiResult, bots, crews}; +use crate::state::Daemon; + +/// The newest files listed; the rest are older than anything the owner wants. +const LIST_MAX: usize = 200; +/// How many of the bot's own writes are looked up. +const WRITTEN_MAX: u32 = 200; +/// Largest file the app previews: base64 of it must fit a 32 MiB frame. +const PREVIEW_MAX_BYTES: u64 = 20 * 1024 * 1024; +/// Generated for the bot by the daemon: not something it made for the owner. +const WORKSPACE_SKIP: [&str; 2] = ["attachments", "CLAUDE.md"]; + +/// Where a bot's files live, and what it wrote by name. +struct Places { + work: PathBuf, + workspace: PathBuf, + written: Vec, +} + +fn places(daemon: &Daemon, bot_id: &BotId) -> ApiResult<(BotRecord, Places)> { + let store = daemon.store(); + let bot = bots::find(&store, bot_id)?; + let crew = crews::find(&store, &bot.crew_id)?; + let written = store.written_files(&bot.id, WRITTEN_MAX)?; + drop(store); + let places = Places { + work: daemon.paths.work_folder(&crew), + workspace: daemon.paths.bot_workspace(&crew.slug, &bot.slug), + written, + }; + Ok((bot, places)) +} + +/// `files.list`: the files in the work folder and the bot's folder that are +/// newer than the bot, plus every file its `Write`/`Edit` calls changed that +/// still exists. Newest first. +pub fn list(daemon: &Daemon, params: FilesListParams) -> ApiResult> { + let (bot, places) = places(daemon, ¶ms.bot_id)?; + let written = keys(&places.written); + let roots = [ + Root { + path: &places.work, + skip_top: &[], + }, + Root { + path: &places.workspace, + skip_top: &WORKSPACE_SKIP, + }, + ]; + let mut found: HashMap = HashMap::new(); + for root in &roots { + for file in walk(root, bot.created_at) { + found.insert(key(&file.path), file); + } + } + for path in &places.written { + let path = PathBuf::from(path); + if let Ok(meta) = std::fs::metadata(&path) + && meta.is_file() + { + found.entry(key(&path)).or_insert_with(|| Found { + size: meta.len(), + modified_at: millis(&meta), + path, + }); + } + } + let folders = [places.work.as_path(), places.workspace.as_path()]; + let mut files: Vec = found + .into_iter() + .map(|(id, file)| describe(file, written.contains(&id), &folders)) + .collect(); + files.sort_by(|a, b| { + b.modified_at + .cmp(&a.modified_at) + .then_with(|| a.path.cmp(&b.path)) + }); + files.truncate(LIST_MAX); + Ok(files) +} + +fn describe(file: Found, written_by_bot: bool, roots: &[&Path]) -> BotFile { + let name = file_name(&file.path); + let parent = file.path.parent().unwrap_or(Path::new("")); + let folder = roots + .iter() + .find_map(|root| parent.strip_prefix(root).ok()) + .unwrap_or(parent) + .to_string_lossy() + .into_owned(); + BotFile { + media_type: media_type(&name).to_owned(), + path: file.path.to_string_lossy().into_owned(), + name, + folder, + size: file.size, + modified_at: file.modified_at, + written_by_bot, + } +} + +fn file_name(path: &Path) -> String { + path.file_name() + .map_or_else(String::new, |name| name.to_string_lossy().into_owned()) +} + +/// `files.read`: the bytes of a file `files.list` can show. A path outside +/// the bot's folders that the bot did not write is refused, so the panel +/// cannot be used to read anything else on the computer. +pub fn read(daemon: &Daemon, params: FilesReadParams) -> ApiResult { + let (_, places) = places(daemon, ¶ms.bot_id)?; + let asked = PathBuf::from(¶ms.path); + let gone = || ApiError::NotFound(format!("{} is no longer there", params.path)); + let path = asked.canonicalize().map_err(|_| gone())?; + let inside = [&places.work, &places.workspace] + .into_iter() + .filter_map(|root| root.canonicalize().ok()) + .any(|root| path.starts_with(root)); + let written = places + .written + .iter() + .filter_map(|file| Path::new(file).canonicalize().ok()) + .any(|file| file == path); + if !asked.is_absolute() || !(inside || written) { + return Err(ApiError::NotFound(format!( + "{} is not one of this bot's files", + params.path + ))); + } + let meta = std::fs::metadata(&path).map_err(|_| gone())?; + if !meta.is_file() { + return Err(gone()); + } + if meta.len() > PREVIEW_MAX_BYTES { + return Err(ApiError::validation(format!( + "the file is larger than {} MB", + PREVIEW_MAX_BYTES / (1024 * 1024) + ))); + } + let bytes = std::fs::read(&path).map_err(|_| gone())?; + Ok(FileData { + media_type: media_type(&file_name(&path)).to_owned(), + data: BASE64.encode(bytes), + }) +} diff --git a/crates/botloftd/src/service/files/scan.rs b/crates/botloftd/src/service/files/scan.rs new file mode 100644 index 00000000..200cdbe5 --- /dev/null +++ b/crates/botloftd/src/service/files/scan.rs @@ -0,0 +1,184 @@ +//! Walking a folder for the files a bot made (spec 8.5): bounded, so a work +//! folder as big as `Documents` costs little, and never through links. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +/// Folders below the top that are never files a bot made for the owner. +const SKIP_DIRS: [&str; 5] = [ + "node_modules", + "target", + "__pycache__", + "venv", + "site-packages", +]; +const MAX_DEPTH: usize = 6; +const MAX_VISITED: usize = 20_000; + +/// A file found: its path, size and modification time (Unix ms). +pub(super) struct Found { + pub path: PathBuf, + pub size: u64, + pub modified_at: i64, +} + +/// What to leave out at the top of a root: the workspace holds the rules and +/// memory the daemon writes and the files the owner sent. +pub(super) struct Root<'a> { + pub path: &'a Path, + pub skip_top: &'a [&'a str], +} + +/// Files below `root` modified at or after `since` (Unix ms), in no order. +/// Hidden entries, links and generated folders are left out. +pub(super) fn walk(root: &Root<'_>, since: i64) -> Vec { + let mut found = Vec::new(); + let mut seen = 0; + let mut pending = vec![(root.path.to_path_buf(), 0)]; + while let Some((dir, depth)) = pending.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + seen += 1; + if seen > MAX_VISITED { + return found; + } + let name = entry.file_name().to_string_lossy().into_owned(); + let left_out = depth == 0 && root.skip_top.contains(&name.as_str()); + if name.starts_with('.') || name.starts_with("~$") || left_out { + continue; + } + // `DirEntry::metadata` does not follow links. + let Ok(meta) = entry.metadata() else { continue }; + if meta.file_type().is_symlink() { + continue; + } + if meta.is_dir() { + if depth < MAX_DEPTH && !SKIP_DIRS.contains(&name.as_str()) { + pending.push((entry.path(), depth + 1)); + } + } else if meta.is_file() { + let modified_at = millis(&meta); + if modified_at >= since { + found.push(Found { + path: entry.path(), + size: meta.len(), + modified_at, + }); + } + } + } + } + found +} + +/// Modification time of `meta` in Unix milliseconds. +pub(super) fn millis(meta: &std::fs::Metadata) -> i64 { + meta.modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map_or(0, |since| { + i64::try_from(since.as_millis()).unwrap_or(i64::MAX) + }) +} + +/// One key for a path however it is spelled: Windows paths do not care about +/// case or the direction of slashes. +pub(super) fn key(path: &Path) -> String { + let text = path.to_string_lossy().replace('\\', "/"); + if cfg!(windows) { + text.to_lowercase() + } else { + text + } +} + +/// The paths of `files` as keys, to ask "did the bot write this?". +pub(super) fn keys(files: &[String]) -> HashSet { + files.iter().map(|file| key(Path::new(file))).collect() +} + +/// The media type for a file name, by extension. +pub(super) fn media_type(name: &str) -> &'static str { + let extension = name + .rsplit_once('.') + .map(|(_, extension)| extension.to_ascii_lowercase()) + .unwrap_or_default(); + match extension.as_str() { + "pdf" => "application/pdf", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "gif" => "image/gif", + "webp" => "image/webp", + "svg" => "image/svg+xml", + "txt" | "log" => "text/plain", + "md" | "markdown" => "text/markdown", + "csv" => "text/csv", + "html" | "htm" => "text/html", + "json" => "application/json", + "xml" => "application/xml", + "yml" | "yaml" => "application/yaml", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "zip" => "application/zip", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "mp4" => "video/mp4", + // Source and config files read as text. + "rs" | "ts" | "tsx" | "js" | "jsx" | "py" | "css" | "toml" | "ini" | "sql" | "sh" + | "ps1" | "bat" | "java" | "go" | "c" | "h" | "cpp" | "cs" | "rb" | "php" => "text/plain", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn extensions_give_media_types() { + assert_eq!(media_type("Report.PDF"), "application/pdf"); + assert_eq!(media_type("notes.md"), "text/markdown"); + assert_eq!(media_type("script.ps1"), "text/plain"); + assert_eq!(media_type("noextension"), "application/octet-stream"); + } + + #[test] + fn a_walk_skips_hidden_generated_and_top_level_leftovers() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path(); + for file in [ + "a.md", + "sub/deep/b.pdf", + ".hidden.txt", + ".git/config", + "node_modules/x.js", + "attachments/2026-01-01/mine.png", + "CLAUDE.md", + "~$lock.docx", + ] { + let path = root.join(file); + std::fs::create_dir_all(path.parent().expect("parent")).expect("dirs"); + std::fs::write(path, "x").expect("write"); + } + let skip = ["attachments", "CLAUDE.md"]; + let top = Root { + path: root, + skip_top: &skip, + }; + let mut names: Vec<_> = walk(&top, 0) + .into_iter() + .map(|file| key(file.path.strip_prefix(root).expect("inside"))) + .collect(); + names.sort(); + assert_eq!(names, ["a.md", "sub/deep/b.pdf"]); + let all = Root { + path: root, + skip_top: &[], + }; + assert!(walk(&all, i64::MAX).is_empty(), "nothing is that new"); + } +} diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index 8120a9c0..f2acf3a2 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -6,6 +6,7 @@ pub mod bots; pub mod chat; pub mod crews; pub mod deliveries; +pub mod files; pub mod lead; pub mod messages; pub mod models; diff --git a/crates/botloftd/tests/files.rs b/crates/botloftd/tests/files.rs new file mode 100644 index 00000000..9557e379 --- /dev/null +++ b/crates/botloftd/tests/files.rs @@ -0,0 +1,130 @@ +//! The files a bot made (spec 8.5): listed for the app's panel and read for +//! its preview, without opening the rest of the computer. + +mod common; + +use std::fs; +use std::path::Path; +use std::time::{Duration, SystemTime}; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use botloft_core::protocol::BotState; +use common::{TestDaemon, stream}; +use serde_json::{Value, json}; + +const NOT_FOUND: i64 = -32002; + +fn put(path: &Path, text: &str) { + fs::create_dir_all(path.parent().expect("parent")).expect("dirs"); + fs::write(path, text).expect("write"); +} + +fn names(files: &Value) -> Vec { + let mut names: Vec = files + .as_array() + .expect("list") + .iter() + .map(|file| file["name"].as_str().expect("name").to_owned()) + .collect(); + names.sort(); + names +} + +#[tokio::test] +async fn the_panel_lists_and_reads_what_the_bot_made() { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" }); + let bot = app.call("bots.create", bot).await.expect("bot"); + t.until_state(&bot, BotState::Idle).await; + let process = t.process_of(&bot).await; + + let workspace = std::path::PathBuf::from(bot["workspace"].as_str().expect("workspace")); + let work = workspace.parent().expect("crew folder").join("shared"); + put(&work.join("report.pdf"), "pdf"); + put(&work.join("notes/a.md"), "# notes"); + put(&work.join(".hidden"), "x"); + put(&workspace.join("CLAUDE.md"), "memory"); + put(&workspace.join("attachments/2026-01-01/sent.png"), "png"); + // Older than the bot: the owner's own file, not something it made. + put(&work.join("old.txt"), "old"); + let hour_ago = SystemTime::now() - Duration::from_secs(3600); + fs::File::options() + .write(true) + .open(work.join("old.txt")) + .expect("open") + .set_modified(hour_ago) + .expect("set mtime"); + + // The bot writes a file far from its folders; another one it never touched. + let elsewhere = tempfile::tempdir().expect("tempdir"); + put(&elsewhere.path().join("made.txt"), "made"); + put(&elsewhere.path().join("private.txt"), "private"); + let made = elsewhere.path().join("made.txt"); + let input = json!({ "file_path": made, "content": "made" }); + process + .emit(stream::tool_use("toolu_1", "Write", input)) + .await; + + let list = json!({ "botId": bot["id"] }); + let mut files = Value::Null; + for _ in 0..100 { + files = app.call("files.list", list.clone()).await.expect("list"); + if names(&files).contains(&"made.txt".to_owned()) { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert_eq!(names(&files), ["a.md", "made.txt", "report.pdf"]); + let made_entry = files + .as_array() + .expect("list") + .iter() + .find(|file| file["name"] == "made.txt") + .expect("made"); + assert_eq!(made_entry["writtenByBot"], true); + let notes = files + .as_array() + .expect("list") + .iter() + .find(|file| file["name"] == "a.md") + .expect("notes"); + assert_eq!(notes["folder"], "notes"); + assert_eq!(notes["mediaType"], "text/markdown"); + assert_eq!(notes["writtenByBot"], false); + + let read = |path: &Path| json!({ "botId": bot["id"], "path": path }); + let pdf = app + .call("files.read", read(&work.join("report.pdf"))) + .await + .expect("read"); + assert_eq!( + pdf, + json!({ "mediaType": "application/pdf", "data": BASE64.encode(b"pdf") }) + ); + app.call("files.read", read(&made)).await.expect("written"); + + // Nothing else on the computer opens through the panel. + let private = elsewhere.path().join("private.txt"); + // Two levels up from the work folder is the folder of every crew. + put(&work.join("..").join("..").join("secret.txt"), "secret"); + let climbing = work.join("..").join("..").join("secret.txt"); + for refused in [ + private, + climbing, + Path::new("report.pdf").to_path_buf(), + work.join("missing.txt"), + work.join("notes"), + ] { + let err = app + .call("files.read", read(&refused)) + .await + .expect_err(&format!("{} is refused", refused.display())); + assert_eq!(err.code, NOT_FOUND, "{}", refused.display()); + } +} From 4856e7b6dab219defecf896104195895d4623ce1 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:03:48 -0300 Subject: [PATCH 2/2] feat(app): a files panel beside the bot's chat A Files button in the bot header opens a panel with what the bot made, newest first, with a preview for images, PDFs, markdown and text, and Open / Show in folder through two new shell commands that never run programs. The button counts files that appeared since the owner last looked. Spec 15.1, 15.2. --- app/src-tauri/src/lib.rs | 79 ++++++++++- app/src-tauri/tauri.conf.json | 2 +- app/src/dev/seedChats.ts | 13 ++ app/src/features/bots/BotHeader.tsx | 31 +++++ app/src/features/bots/BotView.tsx | 36 ++++- app/src/features/chat/rows.test.ts | 1 + app/src/features/files/FilePreview.tsx | 173 +++++++++++++++++++++++++ app/src/features/files/FilesPanel.tsx | 136 +++++++++++++++++++ app/src/features/files/files.test.tsx | 101 +++++++++++++++ app/src/features/files/kinds.ts | 58 +++++++++ app/src/features/files/useBotFiles.ts | 80 ++++++++++++ app/src/i18n/en/files.ts | 32 +++++ app/src/i18n/en/index.ts | 2 + app/src/i18n/es/files.ts | 34 +++++ app/src/i18n/es/index.ts | 2 + app/src/i18n/pt-BR/files.ts | 34 +++++ app/src/i18n/pt-BR/index.ts | 2 + app/src/lib/base64.ts | 10 ++ app/src/lib/fake.ts | 3 + app/src/lib/fakeChat.ts | 1 + app/src/lib/fakeFiles.ts | 69 ++++++++++ app/src/lib/fakeHost.ts | 13 ++ app/src/lib/host.ts | 4 + app/src/lib/tauriHost.ts | 2 + docs/spec.md | 22 +++- 25 files changed, 930 insertions(+), 10 deletions(-) create mode 100644 app/src/features/files/FilePreview.tsx create mode 100644 app/src/features/files/FilesPanel.tsx create mode 100644 app/src/features/files/files.test.tsx create mode 100644 app/src/features/files/kinds.ts create mode 100644 app/src/features/files/useBotFiles.ts create mode 100644 app/src/i18n/en/files.ts create mode 100644 app/src/i18n/es/files.ts create mode 100644 app/src/i18n/pt-BR/files.ts create mode 100644 app/src/lib/fakeFiles.ts diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index ae76d0ad..97d5472a 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -65,6 +65,59 @@ async fn open_path(path: String) -> Result<(), String> { .await } +/// Extensions `open_file` will open with the program Windows picks. Anything +/// else could be a program or a script that runs when opened. +const OPENABLE: [&str; 24] = [ + "pdf", "png", "jpg", "jpeg", "gif", "webp", "svg", "txt", "md", "csv", "json", "log", "html", + "htm", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "mp3", "wav", "mp4", "rtf", +]; + +fn is_openable(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| OPENABLE.contains(&extension.to_ascii_lowercase().as_str())) +} + +/// Opens a file a bot made with the program Windows uses for it. Only +/// documents, images, sound and video: never something that runs. +#[tauri::command] +async fn open_file(path: String) -> Result<(), String> { + blocking(move || { + let file = Path::new(&path); + if !file.is_absolute() || !file.is_file() { + return Err(format!("{path} is not a file")); + } + if !is_openable(file) { + return Err("this kind of file is not opened from Botloft".into()); + } + std::process::Command::new("explorer.exe") + .arg(file) + .spawn() + .map(drop) + .map_err(|err| format!("cannot open the file: {err}")) + }) + .await +} + +/// Shows a file in its folder, selected. Nothing is run. +#[tauri::command] +async fn reveal_file(path: String) -> Result<(), String> { + use std::os::windows::process::CommandExt as _; + blocking(move || { + let file = Path::new(&path); + if !file.is_absolute() || !file.exists() || path.contains('"') { + return Err(format!("{path} is not a file")); + } + // Explorer reads `/select,` and the path as one argument. + std::process::Command::new("explorer.exe") + .raw_arg(format!("/select,\"{path}\"")) + .spawn() + .map(drop) + .map_err(|err| format!("cannot open Explorer: {err}")) + }) + .await +} + /// Whether `url` is a plain web link, safe to hand to the default browser. fn is_web_link(url: &str) -> bool { let rest = url @@ -108,6 +161,8 @@ pub fn run() { claude_sign_in, read_owner_token, open_path, + open_file, + reveal_file, open_url ]) .run(tauri::generate_context!()) @@ -116,7 +171,29 @@ pub fn run() { #[cfg(test)] mod tests { - use super::is_web_link; + use std::path::Path; + + use super::{is_openable, is_web_link}; + + #[test] + fn only_documents_and_media_open() { + for file in [r"C:\w\Report.PDF", r"C:\w\a b\notes.md", r"C:\w\chart.png"] { + assert!(is_openable(Path::new(file)), "{file}"); + } + for file in [ + r"C:\w\setup.exe", + r"C:\w\run.bat", + r"C:\w\run.cmd", + r"C:\w\run.ps1", + r"C:\w\link.lnk", + r"C:\w\page.hta", + r"C:\w\macro.docm", + r"C:\w\noextension", + r"C:\w\report.pdf.exe", + ] { + assert!(!is_openable(Path::new(file)), "{file}"); + } + } #[test] fn only_plain_web_links_open() { diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index c476cbb7..190d0370 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ } ], "security": { - "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost ws://127.0.0.1:*; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'" + "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost ws://127.0.0.1:*; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; frame-src blob:; font-src 'self'" } }, "plugins": { diff --git a/app/src/dev/seedChats.ts b/app/src/dev/seedChats.ts index d06a23f9..6be88bbf 100644 --- a/app/src/dev/seedChats.ts +++ b/app/src/dev/seedChats.ts @@ -72,6 +72,19 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void { const call = chat.tool(crew.scout, name, { summary, input: JSON.stringify({ path: summary }) }); chat.finish(call, name === "Write" ? "Wrote 42 lines" : "ok"); } + at(93); + const made = fake.files; + made.add(crew.scout, "week-39.md", { + folder: "summaries", + text: SUMMARY, + writtenByBot: true, + at: fake.now, + }); + made.add(crew.scout, "papers.csv", { + text: "paper,pages,deep_read\nRetrieval at scale,14,yes\nAgents that plan,22,yes\nSmall evals,9,maybe\n", + at: fake.now + MINUTE, + }); + made.add(crew.scout, "week-39-report.docx", { text: "docx", at: fake.now + 2 * MINUTE }); at(92); chat.reply(crew.scout, SUMMARY); const handoff = chat.tool(crew.scout, "mcp__botloft__send_message", { diff --git a/app/src/features/bots/BotHeader.tsx b/app/src/features/bots/BotHeader.tsx index 0cd88b7b..d446b10d 100644 --- a/app/src/features/bots/BotHeader.tsx +++ b/app/src/features/bots/BotHeader.tsx @@ -2,6 +2,7 @@ import { Archive, Crown, Ellipsis, + Files, FolderOpen, PanelRight, Pause, @@ -32,11 +33,18 @@ export function BotHeader({ crew, detailsOpen, onToggleDetails, + filesOpen, + freshFiles, + onToggleFiles, }: { bot: Bot; crew: Crew; detailsOpen: boolean; onToggleDetails(): void; + filesOpen: boolean; + /** Files that showed up since the owner last looked. */ + freshFiles: number; + onToggleFiles(): void; }) { const t = useT(); const words = t.bots.header; @@ -99,6 +107,29 @@ export function BotHeader({ + + + + + + +
+

+ {file.name} +

+

{fileSize(file.size)}

+
+
+ +
+ + ); +} + +function Body({ shown }: { shown: Shown }) { + const t = useT().files.preview; + switch (shown.kind) { + case "loading": + return ( +

+ + {t.loading} +

+ ); + case "none": + return

{t.none}

; + case "failed": + return ( +
+

+ {shown.gone ? t.gone : shown.tooBig ? t.tooBig : t.failed} +

+ {!shown.gone && !shown.tooBig && ( +

{shown.detail}

+ )} +
+ ); + case "image": + return ( +
+ {shown.alt} +
+ ); + case "pdf": + return