Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion crates/buzz-acp/src/base_prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,13 @@ Your persistent workspace is in your working directory:
| `GUIDES/` | How-to documentation |
| `WORK_LOGS/` | Timestamped activity logs |
| `OUTBOX/` | Drafts pending review or send |
| `REPOS/` | Checked-out source repositories |
| `REPOS/` | Source checkouts. Work in an existing local checkout when one exists; clone here only when none does |
| `.scratch/` | Ephemeral working files |

Knowledge files use `ALL_CAPS_WITH_UNDERSCORES.md` naming. `AGENTS.md` lists active agents and roles. See `AGENTS.md` in your working directory for full workspace conventions.

These paths are relative to your working directory — keep exploration there. Never run `find` or recursive searches over `$HOME` or `/` hunting for workspace files: they live under your working directory, not elsewhere on disk.

## Agent Memory

Your `core` memory is auto-injected into your context every turn — it holds identity, durable rules, and goals across sessions.
Expand Down
90 changes: 83 additions & 7 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ async fn create_session_and_apply_model(
// header from `engram_fetch::build_core_section`, so we just append it.
let combined_system_prompt: Option<String> = if agent.protocol_version >= 2 {
with_core(
framed_system_prompt(ctx.base_prompt, ctx.system_prompt.as_deref()),
framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()),
agent_core,
)
} else {
Expand Down Expand Up @@ -670,15 +670,53 @@ pub(crate) fn prepend_base_for_legacy(
/// split the combined value into labeled sub-sections. Each prompt is wrapped
/// only when present, so a persona-only agent yields `[System]\n{persona}`
/// rather than an unlabeled blob that would be mislabeled as `[Base]`.
fn framed_system_prompt(base_prompt: Option<&str>, system_prompt: Option<&str>) -> Option<String> {
match (base_prompt, system_prompt) {
///
/// Prepends a `[Workspace]` section naming the agent's absolute working
/// directory. The base prompt describes the workspace layout but never its
/// absolute root, so without this anchor a model fills the gap by searching
/// `$HOME` (triggering macOS TCC prompts) or by inventing its own workspace
/// directory. The line is emitted only when a real base prompt is present and
/// `cwd` is an absolute path other than the `/` fallback — naming `/` as the
/// workspace would itself invite a `$HOME`-wide scan.
fn framed_system_prompt(
cwd: &str,
base_prompt: Option<&str>,
system_prompt: Option<&str>,
) -> Option<String> {
let body = match (base_prompt, system_prompt) {
(Some(bp), Some(sp)) => Some(format!(
"{}\n\n[System]\n{sp}",
crate::queue::base_section(bp)
)),
(Some(bp), None) => Some(crate::queue::base_section(bp)),
(None, Some(sp)) => Some(format!("[System]\n{sp}")),
(None, None) => None,
}?;
// Anchor the workspace only when a base prompt is present — the workspace
// section grounds the base prompt's layout description, so it is meaningless
// for a persona-only (`[System]`-only) agent that never received that layout.
match (base_prompt, workspace_section(cwd)) {
(Some(_), Some(workspace)) => Some(format!("{workspace}\n\n{body}")),
_ => Some(body),
}
}

/// Render the `[Workspace]` grounding section, or `None` when `cwd` is unusable.
///
/// Skips relative paths and the `/` fallback (`std::env::current_dir()` resolves
/// to `/` on failure): a `/`-rooted workspace line would actively encourage the
/// `$HOME`-wide scan this section exists to prevent.
fn workspace_section(cwd: &str) -> Option<String> {
if cwd != "/" && cwd.starts_with('/') {
Some(format!(
"[Workspace]\nYour absolute working directory is `{cwd}`. All workspace \
files — `AGENTS.md`, `RESEARCH/`, `PLANS/`, `GUIDES/`, `WORK_LOGS/`, \
`OUTBOX/` — and any repositories you clone (under `{cwd}/REPOS/`) live \
here. This is where you already are; do not search `$HOME` or other \
directories for them."
))
} else {
None
}
}

Expand Down Expand Up @@ -2393,28 +2431,66 @@ mod tests {

#[test]
fn test_framed_system_prompt_both_present_carries_both_headers() {
let framed = framed_system_prompt(Some("base text"), Some("persona text"))
let framed = framed_system_prompt("/", Some("base text"), Some("persona text"))
.expect("both present yields Some");
assert_eq!(framed, "[Base]\nbase text\n\n[System]\npersona text");
}

#[test]
fn test_framed_system_prompt_base_only_labels_base() {
let framed = framed_system_prompt(Some("base text"), None).expect("base yields Some");
let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some");
assert_eq!(framed, "[Base]\nbase text");
}

#[test]
fn test_framed_system_prompt_persona_only_labels_system() {
// A bare persona would be mislabeled "Base" downstream — it must carry
// its own [System] header even when no base prompt exists.
let framed = framed_system_prompt(None, Some("persona text")).expect("persona yields Some");
let framed =
framed_system_prompt("/", None, Some("persona text")).expect("persona yields Some");
assert_eq!(framed, "[System]\npersona text");
}

#[test]
fn test_framed_system_prompt_neither_is_none() {
assert!(framed_system_prompt(None, None).is_none());
assert!(framed_system_prompt("/", None, None).is_none());
}

#[test]
fn test_framed_system_prompt_absolute_cwd_prepends_workspace_before_base() {
let framed = framed_system_prompt("/Users/me/.buzz", Some("base text"), None)
.expect("base yields Some");
assert!(
framed.starts_with("[Workspace]\n"),
"workspace section must lead: {framed}"
);
assert!(framed.contains("`/Users/me/.buzz`"));
assert!(
framed.contains("\n\n[Base]\nbase text"),
"base must follow the workspace section: {framed}"
);
}

#[test]
fn test_framed_system_prompt_persona_only_omits_workspace() {
// The workspace section grounds the base prompt's layout; a persona-only
// agent never received that layout, so no [Workspace] anchor is emitted.
let framed = framed_system_prompt("/Users/me/.buzz", None, Some("persona text"))
.expect("persona yields Some");
assert_eq!(framed, "[System]\npersona text");
}

#[test]
fn test_framed_system_prompt_root_cwd_omits_workspace() {
// The "/" fallback must never be named — it would invite a $HOME scan.
let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some");
assert_eq!(framed, "[Base]\nbase text");
}

#[test]
fn test_workspace_section_relative_cwd_is_none() {
assert!(workspace_section("relative/path").is_none());
assert!(workspace_section("").is_none());
}

// ── with_core tests ──────────────────────────────────────────────────────
Expand Down
13 changes: 11 additions & 2 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,20 @@ const rules = [
// file is broken up. Tracked as a follow-up.
const overrides = new Map([
["src-tauri/src/commands/agents.rs", 1294],
["src-tauri/src/managed_agents/nest.rs", 1420],
// Residual repos_dir integration in ensure_nest_at: REPOS is provisioned
// outside NEST_DIRS (it may be a symlink), so it needs its own create +
// chmod-only-when-real-dir handling plus integration test coverage. The
// self-contained repos_dir functions and their unit tests live in repos.rs;
// this is the seam that must stay in nest.rs. Approved override; still queued
// to split with the rest of this list.
["src-tauri/src/managed_agents/nest.rs", 1447],
["src-tauri/src/managed_agents/runtime.rs", 1953],
["src-tauri/src/managed_agents/personas.rs", 1080],
["src-tauri/src/managed_agents/persona_card.rs", 1050],
["src/shared/api/tauri.ts", 1196],
// applyWorkspace reposDir parameter threaded through the Tauri invoke for
// configurable repos_dir — a 3-line overage from load-bearing parameter
// plumbing, not generic debt growth. Approved override; still queued to split.
["src/shared/api/tauri.ts", 1198],
["src-tauri/src/nostr_convert.rs", 1126],
["src/shared/api/relayClientSession.ts", 1022],
["src-tauri/src/migration.rs", 1295],
Expand Down
44 changes: 41 additions & 3 deletions desktop/src-tauri/src/commands/workspace.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
use nostr::Keys;
use serde::Serialize;
use tauri::{AppHandle, State};
use tauri::{AppHandle, Emitter, State};

use crate::app_state::AppState;
use crate::managed_agents::try_regenerate_nest;
use crate::managed_agents::{ensure_repos_symlink, nest_dir, try_regenerate_nest};
use crate::relay;

#[derive(Serialize)]
Expand All @@ -26,11 +26,20 @@ pub fn get_active_workspace(state: State<'_, AppState>) -> Result<ActiveWorkspac
/// Apply a workspace's configuration to the backend session.
///
/// Called by the frontend on app init (after reload) to configure the
/// Tauri backend with the selected workspace's relay URL and keys.
/// Tauri backend with the selected workspace's relay URL, keys, and repos
/// directory.
///
/// Validation runs before any state mutation: an invalid `repos_dir` (bad
/// path) rejects cleanly with nothing applied. The `REPOS` symlink itself is
/// a filesystem *side-effect* — its failure (e.g. a non-empty real `REPOS`
/// refusing a downgrade, or a renamed external target on a later launch) is
/// non-fatal: relay/keys still apply, the command returns `Ok`, and a
/// `repos-dir-error` event surfaces the failure to the frontend.
#[tauri::command]
pub fn apply_workspace(
relay_url: String,
nsec: Option<String>,
repos_dir: Option<String>,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
Expand All @@ -42,6 +51,24 @@ pub fn apply_workspace(
None => None,
};

// Normalize repos_dir to a trimmed non-empty value. `None`/empty clears
// the override (REPOS falls back to a real dir). A bad path is rejected
// here — before any mutation — so the dialog sees a clean Err.
let repos_dir = repos_dir
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(dir) = repos_dir.as_deref() {
let nest = nest_dir().ok_or("cannot resolve home directory for nest")?;
// Validate without mutating the filesystem. Keeps the command's
// "validate-first, nothing below can fail" contract honest. Also emit
// the error so it surfaces even at the init call site (which swallows
// the returned Err to console for the relay/keys path).
if let Err(error) = crate::managed_agents::validate_repos_dir(&nest, dir) {
let _ = app.emit("repos-dir-error", error.clone());
return Err(error);
}
}

// ── Apply all state changes (nothing below can fail) ──────────────────
{
let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?;
Expand All @@ -53,6 +80,17 @@ pub fn apply_workspace(
*keys_guard = keys;
}

// ── Filesystem side-effect (non-fatal) ────────────────────────────────
// Re-point REPOS to match repos_dir. Failure here (downgrade refused,
// external target gone) must NOT fail the command — relay/keys are already
// applied. Surface it via a `repos-dir-error` event the frontend toasts.
if let Some(nest) = nest_dir() {
if let Err(error) = ensure_repos_symlink(&nest, repos_dir.as_deref()) {
eprintln!("buzz-desktop: repos dir setup failed: {error}");
let _ = app.emit("repos-dir-error", error);
}
}

try_regenerate_nest(&app);

Ok(())
Expand Down
9 changes: 9 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,15 @@ pub fn run() {
eprintln!("buzz-desktop: failed to create nest: {error}");
}

// Carry the agent's knowledge from the legacy nest (~/.sprout) into
// the live nest (~/.buzz) after it exists. Must run after
// ensure_nest() so the destination is present. Non-fatal.
// On a real migration, emit a one-time hint so the user can delete
// the now-inert ~/.sprout; the frontend dedupes the toast.
if migration::migrate_legacy_nest() {
let _ = app_handle.emit("legacy-nest-migrated", ());
}

// Create/update the local CLI symlink pointing to the
// bundled CLI binary. Non-fatal: agents find CLI via PATH.
if let Ok(exe) = std::env::current_exe() {
Expand Down
2 changes: 2 additions & 0 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ mod personas;
mod process_lifecycle;
#[cfg(feature = "mesh-llm")]
mod relay_mesh;
mod repos;
mod restore;
mod runtime;
mod storage;
Expand All @@ -26,6 +27,7 @@ pub use personas::*;
pub use process_lifecycle::*;
#[cfg(feature = "mesh-llm")]
pub use relay_mesh::*;
pub use repos::{ensure_repos_symlink, validate_repos_dir};
pub use restore::*;
pub use runtime::*;
pub use storage::*;
Expand Down
33 changes: 31 additions & 2 deletions desktop/src-tauri/src/managed_agents/nest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,22 @@ use tauri::{AppHandle, Manager};
use crate::managed_agents::discovery::known_skill_dirs;

/// Subdirectories created inside the nest.
/// `REPOS` is intentionally absent: it is provisioned by
/// [`super::repos::ensure_repos_symlink`], which makes it either a real directory (default)
/// or a symlink to a user-configured `repos_dir`. Creating it here
/// unconditionally would race a future symlink re-point.
const NEST_DIRS: &[&str] = &[
"GUIDES",
"RESEARCH",
"PLANS",
"WORK_LOGS",
"REPOS",
"OUTBOX",
".scratch",
];

/// Default AGENTS.md content written on first init.
/// Fully static — no runtime interpolation, no secrets, no user paths.
const AGENTS_MD: &str = include_str!("nest_agents.md");
pub(crate) const AGENTS_MD: &str = include_str!("nest_agents.md");

/// Default SKILL.md content for the buzz-cli skill.
/// Written to ~/.buzz/.agents/skills/buzz-cli/SKILL.md on first init.
Expand Down Expand Up @@ -109,6 +112,11 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> {
fs::create_dir_all(&path).map_err(|e| format!("create {}: {e}", path.display()))?;
}

// REPOS is provisioned separately from NEST_DIRS: it may be a symlink to a
// user-configured repos_dir (applied later via apply_workspace), so setup
// must not clobber an existing configured symlink. See repos.rs.
super::repos::ensure_repos_setup_default(root)?;

// Write AGENTS.md only if it doesn't already exist.
// Uses create_new (O_CREAT|O_EXCL) to atomically check-and-create,
// closing the TOCTOU gap that exists() + write() would leave open.
Expand Down Expand Up @@ -184,6 +192,18 @@ pub fn ensure_nest_at(root: &Path) -> Result<(), String> {
.map_err(|e| format!("set permissions on {}: {e}", path.display()))?;
}
}
// REPOS is provisioned outside NEST_DIRS (it may be a symlink). Only
// chmod it when it is a real directory — chmod on a symlink would
// affect the user's external repos_dir target.
let repos_path = root.join("REPOS");
let repos_is_symlink = repos_path
.symlink_metadata()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
if !repos_is_symlink {
fs::set_permissions(&repos_path, perms.clone())
.map_err(|e| format!("set permissions on {}: {e}", repos_path.display()))?;
}
// Skill directory trees inside root get 700.
// Build the list from canonical path + all known provider skill dirs.
let mut skill_perm_dirs = Vec::new();
Expand Down Expand Up @@ -618,6 +638,9 @@ mod tests {
for dir in NEST_DIRS {
assert!(root.join(dir).is_dir(), "{dir}/ should exist");
}
// REPOS is provisioned separately (may be a symlink); with no
// repos_dir configured it lands as a real directory.
assert!(root.join("REPOS").is_dir(), "REPOS/ should exist");

// AGENTS.md was written with default content.
let content = fs::read_to_string(root.join("AGENTS.md")).unwrap();
Expand All @@ -633,6 +656,12 @@ mod tests {
let mode = fs::metadata(root.join(dir)).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o700, "{dir}/ should be 700");
}
let repos_mode = fs::metadata(root.join("REPOS"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(repos_mode, 0o700, "REPOS/ should be 700");
}
}

Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/managed_agents/nest_agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Your persistent workspace. Created once by the Buzz desktop app. The static cont
| `RESEARCH/` | Findings, notes, and reference material |
| `WORK_LOGS/` | Session logs — what was tried, learned, decided |
| `OUTBOX/` | Shareable docs for external readers (no frontmatter) |
| `REPOS/` | Cloned repositories (clone freely here for exploration) |
| `REPOS/` | Source checkouts. Work in an existing local checkout when one exists; clone here only when none does |
| `.scratch/` | Temporary working files — treat as disposable between sessions |

Filenames: `ALL_CAPS_WITH_UNDERSCORES.md` (e.g., `OAUTH_FLOW_NOTES.md`).
Expand Down
Loading
Loading