Skip to content
Closed
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
22 changes: 22 additions & 0 deletions crates/buzz-persona/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
//! Parser and loader for Buzz persona pack files (`.persona.md`).
//!
//! A persona pack bundles one or more agent personas — each a YAML frontmatter
//! header plus a markdown system prompt — together with a `.plugin/plugin.json`
//! manifest, optional shared MCP config, and pack-level behavioral defaults.
//!
//! This crate provides parsing, loading, merge/precedence resolution, ACP-ready
//! projection, and validation of persona packs.

#![warn(missing_docs)]

/// Pack manifest types and `.plugin/plugin.json` parser.
pub mod manifest;

/// Precedence resolution for persona behavioral config.
pub mod merge;

/// Pack directory loader.
pub mod pack;

/// Core persona types and `.persona.md` parser.
pub mod persona;

/// Pack resolution producing fully resolved, ACP-ready output.
pub mod resolve;

/// Pack validation (`buzz pack validate`).
pub mod validate;
20 changes: 20 additions & 0 deletions crates/buzz-persona/src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,18 @@ use serde::{Deserialize, Serialize};

use crate::persona::RespondTo;

/// Errors returned while reading or parsing a pack manifest.
#[derive(Debug, thiserror::Error)]
pub enum ManifestError {
/// Failed to read the manifest file from disk.
#[error("failed to read file: {0}")]
Io(#[from] std::io::Error),

/// Failed to parse the manifest JSON.
#[error("failed to parse JSON: {0}")]
Json(#[from] serde_json::Error),

/// A required manifest field was missing or empty.
#[error("missing required field: {0}")]
MissingField(String),
}
Expand All @@ -47,24 +51,31 @@ pub struct Engines {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct BehavioralDefaults {
/// Default model string in `"provider:model-id"` format.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,

/// Default sampling temperature.
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f64>,

/// Default maximum context window in tokens.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_context_tokens: Option<u64>,

/// Default channels to monitor.
#[serde(skip_serializing_if = "Option::is_none")]
pub subscribe: Option<Vec<String>>,

/// Default message matching triggers (legacy alias: `respond_to`).
#[serde(skip_serializing_if = "Option::is_none", alias = "respond_to")]
pub triggers: Option<RespondTo>,

/// Default reply-in-thread behavior.
#[serde(skip_serializing_if = "Option::is_none")]
pub thread_replies: Option<bool>,

/// Default broadcast-replies behavior.
#[serde(skip_serializing_if = "Option::is_none")]
pub broadcast_replies: Option<bool>,
}
Expand All @@ -77,25 +88,34 @@ pub struct BehavioralDefaults {
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub struct PackManifest {
/// Unique pack identifier (slug).
pub id: String,
/// Human-readable pack name.
pub name: String,
/// Semver version string.
pub version: String,

/// Optional short description of the pack.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,

/// Optional pack author.
#[serde(skip_serializing_if = "Option::is_none")]
pub author: Option<String>,

/// Optional SPDX license identifier.
#[serde(skip_serializing_if = "Option::is_none")]
pub license: Option<String>,

/// Optional homepage URL.
#[serde(skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,

/// Discovery keywords.
#[serde(default)]
pub keywords: Vec<String>,

/// Runtime engine constraints.
#[serde(skip_serializing_if = "Option::is_none")]
pub engines: Option<Engines>,

Expand Down
33 changes: 24 additions & 9 deletions crates/buzz-persona/src/merge.rs
Original file line number Diff line number Diff line change
@@ -1,37 +1,52 @@
/// Precedence resolution for persona behavioral config.
///
/// Handles levels 3–5 of the 5-level precedence model:
/// 3. Per-persona frontmatter (wins)
/// 4. Pack-level defaults (from plugin.json `defaults`)
/// 5. Built-in defaults (hardcoded fallbacks)
///
/// Levels 1–2 (operator env vars, desktop UI) are resolved at runtime.

//! Precedence resolution for persona behavioral config.
//!
//! Handles levels 3–5 of the 5-level precedence model:
//! 3. Per-persona frontmatter (wins)
//! 4. Pack-level defaults (from plugin.json `defaults`)
//! 5. Built-in defaults (hardcoded fallbacks)
//!
//! Levels 1–2 (operator env vars, desktop UI) are resolved at runtime.

/// Resolved message-matching triggers.
#[derive(Debug, Clone, PartialEq)]
pub struct TriggersData {
/// Whether the persona responds when mentioned.
pub mentions: bool,
/// Keywords that trigger a response.
pub keywords: Vec<String>,
/// Whether the persona responds to every message in subscribed channels.
pub all_messages: bool,
}

/// Resolved lifecycle hook paths (pack-relative).
#[derive(Debug, Clone, PartialEq)]
pub struct HooksData {
/// Hook invoked when the persona starts.
pub on_start: Option<String>,
/// Hook invoked when the persona stops.
pub on_stop: Option<String>,
/// Hook invoked on each incoming message.
pub on_message: Option<String>,
}

/// The fully resolved behavioral config for a single persona.
#[derive(Debug, Clone, PartialEq)]
pub struct ResolvedConfig {
/// Model string in `"provider:model-id"` format.
pub model: Option<String>,
/// Sampling temperature.
pub temperature: Option<f64>,
/// Maximum context window in tokens.
pub max_context_tokens: Option<u64>,
/// `None` = absent (no persona or pack value) → caller uses its own default
/// `Some([])` = intentional "subscribe to nothing"
/// `Some([..])` = explicit channel list
pub subscribe: Option<Vec<String>>,
/// Message-matching triggers, if any.
pub triggers: Option<TriggersData>,
/// Whether replies go in-thread.
pub thread_replies: bool,
/// Whether replies are broadcast to the channel.
pub broadcast_replies: bool,
}

Expand Down
47 changes: 45 additions & 2 deletions crates/buzz-persona/src/pack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,35 +22,56 @@ use crate::manifest::{self, ManifestError};
use crate::merge::{resolve_persona_config, HooksData, TriggersData};
use crate::persona::{self, PersonaConfig};

/// Errors returned while loading a persona pack.
#[derive(Debug, thiserror::Error)]
pub enum PackError {
/// The `.plugin/plugin.json` manifest was not found.
#[error("manifest not found at {0}")]
ManifestNotFound(PathBuf),

/// Failed to read a file from disk.
#[error("failed to read {path}: {source}")]
Io {
/// Path of the file that could not be read.
path: PathBuf,
/// The underlying I/O error.
#[source]
source: std::io::Error,
},

/// Failed to parse the pack manifest.
#[error("failed to parse manifest: {0}")]
ManifestParse(String),

/// A referenced persona file was not found.
#[error("persona file not found: {0}")]
PersonaNotFound(PathBuf),

/// A file failed to parse.
#[error("invalid file {path}: {reason}")]
FileParse { path: PathBuf, reason: String },
FileParse {
/// Path of the file that failed to parse.
path: PathBuf,
/// Human-readable parse failure reason.
reason: String,
},

/// A path contained a `..` traversal component or absolute prefix.
#[error("path traversal rejected: {0}")]
PathTraversal(String),

/// A canonicalized path escaped the pack root.
#[error("path escapes pack root: {0}")]
PathEscape(PathBuf),

/// Failed to parse the shared `.mcp.json` config.
#[error("failed to parse .mcp.json at {path}: {reason}")]
McpConfigParse { path: PathBuf, reason: String },
McpConfigParse {
/// Path to the `.mcp.json` file.
path: PathBuf,
/// Human-readable parse failure reason.
reason: String,
},
}

impl From<ManifestError> for PackError {
Expand All @@ -62,7 +83,9 @@ impl From<ManifestError> for PackError {
/// A fully loaded persona pack.
#[derive(Debug)]
pub struct LoadedPack {
/// The parsed pack manifest data.
pub manifest: PackManifestData,
/// Fully loaded personas with resolved config.
pub personas: Vec<LoadedPersona>,
/// Content of instructions.md, if present.
pub pack_instructions: Option<String>,
Expand All @@ -75,23 +98,37 @@ pub struct LoadedPack {
/// A persona with its resolved effective config.
#[derive(Debug)]
pub struct LoadedPersona {
/// Path to the source `.persona.md` file.
pub source_path: PathBuf,
/// Machine name (slug).
pub name: String,
/// Human-readable display name.
pub display_name: String,
/// One-line description.
pub description: String,
/// Pack-relative path to an avatar image, if any.
pub avatar: Option<String>,
/// Model string in `"provider:model-id"` format, if set.
pub model: Option<String>,
/// Preferred ACP runtime ID from the persona config (e.g., 'goose', 'claude').
pub runtime: Option<String>,
/// Sampling temperature, if set.
pub temperature: Option<f64>,
/// Maximum context window in tokens, if set.
pub max_context_tokens: Option<u64>,
/// Channels to monitor (empty if none).
pub subscribe: Vec<String>,
/// Message-matching triggers, if any.
pub triggers: Option<TriggersData>,
/// Whether replies go in-thread.
pub thread_replies: bool,
/// Whether replies are broadcast to the channel.
pub broadcast_replies: bool,
/// Pack-relative skill directories claimed by this persona.
pub skills: Vec<String>,
/// Raw MCP server configs.
pub mcp_servers: Vec<serde_json::Value>,
/// Lifecycle hooks, if any.
pub hooks: Option<HooksData>,
/// The markdown body (system prompt).
pub prompt: String,
Expand All @@ -100,13 +137,19 @@ pub struct LoadedPersona {
/// Minimal manifest data needed by the pack loader.
#[derive(Debug)]
pub struct PackManifestData {
/// Unique pack identifier (slug).
pub id: String,
/// Human-readable pack name.
pub name: String,
/// Semver version string.
pub version: String,
/// Optional short description of the pack.
pub description: Option<String>,
/// Relative paths to .persona.md files.
pub personas: Vec<String>,
/// Path to the pack-level instructions file, if declared.
pub pack_instructions: Option<String>,
/// Path to the shared `.mcp.json`, if declared.
pub mcp_config: Option<String>,
// hooks_config is intentionally omitted: hooks are a runtime concern loaded
// separately by buzz-acp, not a pack-parsing concern.
Expand Down
Loading