feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
//! Pure domain entity for application configuration.
|
||||
//!
|
||||
//! Defines `AppConfig`, `ProviderConfig`, and `ModelRole` — the data
|
||||
//! structures that describe which LLM providers are registered, which
|
||||
//! model roles exist, and which provider/model is the default.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! These are pure data structures with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `AppConfigRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Data Flow
|
||||
//! 1. `AppConfig` is deserialised from `app_config.json` at startup
|
||||
//! 2. The HTTP handler layer calls `SettingsService::update_provider()`
|
||||
//! to mutate the provider map
|
||||
//! 3. The modified `AppConfig` is serialised back to `app_config.json`
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Top-level application configuration.
|
||||
///
|
||||
/// Holds the registry of configured LLM providers, named model roles
|
||||
/// (logical profiles mapping to a provider+model pair), and the default
|
||||
/// provider/model selection.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `providers` — map of provider name → connection details
|
||||
/// - `model_roles` — map of role name → provider/model/temperature
|
||||
/// - `default_provider` — the provider to use when none is specified
|
||||
/// - `default_model` — the model to use when none is specified
|
||||
/// - `default_context_window` — fallback context window size in tokens
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub providers: HashMap<String, ProviderConfig>,
|
||||
pub model_roles: HashMap<String, ModelRole>,
|
||||
pub default_provider: String,
|
||||
pub default_model: String,
|
||||
pub default_context_window: u32,
|
||||
}
|
||||
|
||||
/// Connection details for a single LLM provider endpoint.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `api_base` — base URL for the provider API
|
||||
/// - `api_key_env` — optional environment variable name holding the API key
|
||||
/// - `default_model` — optional default model name for this provider
|
||||
/// - `default_api_key` — optional inline API key (less secure than env var)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub api_base: String,
|
||||
pub api_key_env: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
pub default_api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// A named model role mapping to a specific provider/model with parameters.
|
||||
///
|
||||
/// Roles allow the UI to present logical profiles (e.g. "fast", "reasoning")
|
||||
/// that abstract over concrete provider+model strings.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `provider` — which provider serves this role
|
||||
/// - `model` — which model to use for this role
|
||||
/// - `max_tokens` — optional maximum output token limit
|
||||
/// - `context_window` — optional context window override
|
||||
/// - `temperature` — optional generation temperature
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelRole {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub context_window: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
/// Returns the default AppConfig with built-in "zen" and "router" providers.
|
||||
impl Default for AppConfig {
|
||||
/// Construct an AppConfig with the default "zen" and "router" providers.
|
||||
///
|
||||
/// ## Defaults
|
||||
/// - Zen provider: `deepseek-v4-flash-free` model
|
||||
/// - Router provider: `claude-opus-4-8` model
|
||||
/// - Default role: "default" → zen / deepseek-v4-flash-free, temp 0.7
|
||||
/// - `default_context_window`: 256,000 tokens
|
||||
fn default() -> Self {
|
||||
let mut providers = HashMap::new();
|
||||
providers.insert(
|
||||
"zen".to_string(),
|
||||
ProviderConfig {
|
||||
api_base: "https://opencode.ai/zen/v1".to_string(),
|
||||
api_key_env: Some("API_KEY".to_string()),
|
||||
default_model: Some("deepseek-v4-flash-free".to_string()),
|
||||
default_api_key: None,
|
||||
},
|
||||
);
|
||||
providers.insert(
|
||||
"router".to_string(),
|
||||
ProviderConfig {
|
||||
api_base: "https://9router.asepharyana.my.id/v1".to_string(),
|
||||
api_key_env: Some("ROUTER_API_KEY".to_string()),
|
||||
default_model: Some("claude-opus-4-8".to_string()),
|
||||
default_api_key: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut model_roles = HashMap::new();
|
||||
model_roles.insert(
|
||||
"default".to_string(),
|
||||
ModelRole {
|
||||
provider: "zen".to_string(),
|
||||
model: "deepseek-v4-flash-free".to_string(),
|
||||
max_tokens: None,
|
||||
context_window: None,
|
||||
temperature: Some(0.7),
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
providers,
|
||||
model_roles,
|
||||
default_provider: "zen".to_string(),
|
||||
default_model: "deepseek-v4-flash-free".to_string(),
|
||||
default_context_window: 256_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
//! Command types for CMS domain operations.
|
||||
//!
|
||||
//! Following the `NewXxx` / `XxxPatch` pattern from clean architecture,
|
||||
//! these types encapsulate the input data for create/update operations
|
||||
//! on domain entities. They decouple presentation DTOs from the entity
|
||||
//! mutation surface and provide a clear boundary for validation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::settings::InternetMode;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Partial update command for `Settings`.
|
||||
///
|
||||
/// Every field is `Option`al — only non-`None` fields are applied to the
|
||||
/// existing settings instance. Use `apply_to()` to merge into a `Settings`
|
||||
/// value.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SettingsPatch {
|
||||
/// Override the internet access mode.
|
||||
pub internet_mode: Option<String>,
|
||||
/// Override the active provider name.
|
||||
pub provider: Option<String>,
|
||||
/// Override the active model name.
|
||||
pub model: Option<String>,
|
||||
/// Replace the entire API-keys map.
|
||||
pub api_keys: Option<HashMap<String, String>>,
|
||||
/// Override the max tokens for completions.
|
||||
pub max_tokens: Option<Option<u32>>,
|
||||
/// Override the temperature for completions.
|
||||
pub temperature: Option<Option<f32>>,
|
||||
/// Override the review max lessons per run.
|
||||
pub review_max_lessons_per_run: Option<usize>,
|
||||
/// Override the adaptive review max skip count.
|
||||
pub adaptive_review_max_skip: Option<u32>,
|
||||
/// Override the verify shell command.
|
||||
pub verify_command: Option<Option<String>>,
|
||||
/// Override the verify timeout in milliseconds.
|
||||
pub verify_timeout_ms: Option<u64>,
|
||||
/// Override the max concurrency for workflow execution.
|
||||
pub workflow_max_concurrency: Option<usize>,
|
||||
/// Override the review-enabled flag.
|
||||
pub review_enabled: Option<bool>,
|
||||
/// Override the session-archive-enabled flag.
|
||||
pub session_archive_enabled: Option<bool>,
|
||||
/// Override the LSP auto-provision flag.
|
||||
pub lsp_auto_provision: Option<bool>,
|
||||
/// Override the list of LSP-managed languages.
|
||||
pub lsp_languages: Option<Vec<String>>,
|
||||
/// Override the hive-mind node timeout in milliseconds.
|
||||
pub hive_mind_node_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl SettingsPatch {
|
||||
/// Merge this patch into `settings`, overwriting each non-`None` field.
|
||||
///
|
||||
/// Flow: for each optional field, if `Some`, assign it to the target.
|
||||
///
|
||||
/// ## Errors
|
||||
/// Returns `Err` with a message if `internet_mode` is set to an
|
||||
/// unrecognised value.
|
||||
pub fn apply_to(&self, settings: &mut super::settings::Settings) -> Result<(), String> {
|
||||
if let Some(ref val) = self.internet_mode {
|
||||
settings.internet_mode = match val.as_str() {
|
||||
"Off" => InternetMode::Off,
|
||||
"ReadOnly" => InternetMode::ReadOnly,
|
||||
"Full" => InternetMode::Full,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"invalid internet_mode '{val}'; expected Off, ReadOnly, or Full"
|
||||
))
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(ref val) = self.provider {
|
||||
settings.provider = val.clone();
|
||||
}
|
||||
if let Some(ref val) = self.model {
|
||||
settings.model = val.clone();
|
||||
}
|
||||
if let Some(ref val) = self.api_keys {
|
||||
settings.api_keys = val.clone();
|
||||
}
|
||||
if let Some(val) = self.max_tokens {
|
||||
settings.max_tokens = val;
|
||||
}
|
||||
if let Some(val) = self.temperature {
|
||||
settings.temperature = val;
|
||||
}
|
||||
if let Some(val) = self.review_max_lessons_per_run {
|
||||
settings.review_max_lessons_per_run = val;
|
||||
}
|
||||
if let Some(val) = self.adaptive_review_max_skip {
|
||||
settings.adaptive_review_max_skip = val;
|
||||
}
|
||||
if let Some(ref val) = self.verify_command {
|
||||
settings.verify_command = val.clone();
|
||||
}
|
||||
if let Some(val) = self.verify_timeout_ms {
|
||||
settings.verify_timeout_ms = val;
|
||||
}
|
||||
if let Some(val) = self.workflow_max_concurrency {
|
||||
settings.workflow_max_concurrency = val;
|
||||
}
|
||||
if let Some(val) = self.review_enabled {
|
||||
settings.flags.review_enabled = val;
|
||||
}
|
||||
if let Some(val) = self.session_archive_enabled {
|
||||
settings.flags.session_archive_enabled = val;
|
||||
}
|
||||
if let Some(val) = self.lsp_auto_provision {
|
||||
settings.flags.lsp_auto_provision = val;
|
||||
}
|
||||
if let Some(ref val) = self.lsp_languages {
|
||||
settings.lsp_languages = val.clone();
|
||||
}
|
||||
if let Some(val) = self.hive_mind_node_timeout_ms {
|
||||
settings.hive_mind_node_timeout_ms = val;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Command to create a new memory entry.
|
||||
///
|
||||
/// All required fields are non-optional; optional fields use `Option`
|
||||
/// and default to sensible values (empty or the service default).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewMemory {
|
||||
/// Unique name / slug for the memory.
|
||||
pub name: String,
|
||||
/// One-line summary of what the memory captures.
|
||||
pub description: String,
|
||||
/// The full memory content.
|
||||
pub content: String,
|
||||
/// Category kind (defaults to "reference" in the handler).
|
||||
pub kind: Option<String>,
|
||||
/// Outcome of the remembered action.
|
||||
pub outcome: Option<String>,
|
||||
/// Lifecycle stage (defaults to "new" in the handler).
|
||||
pub lifecycle: Option<String>,
|
||||
/// Scope context for the memory.
|
||||
pub scope: Option<String>,
|
||||
/// Code snippet captured before the action.
|
||||
pub before_snippet: Option<String>,
|
||||
/// Code snippet captured after the action.
|
||||
pub after_snippet: Option<String>,
|
||||
/// Source provenances (files, conversations, etc.).
|
||||
pub provenances: Option<Vec<String>>,
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Pure domain entity for conversations and chat messages.
|
||||
//!
|
||||
//! Re-exports the canonical `Conversation`, `ChatMessage`, and `Role`
|
||||
//! types from the core module to provide a consistent domain import
|
||||
//! boundary within the CMS module. All CMS code references conversation
|
||||
//! types through this module rather than depending on the core module
|
||||
//! directly.
|
||||
//!
|
||||
//! ## Re-exports
|
||||
//! - `Conversation` — top-level conversation container with message list
|
||||
//! - `ChatMessage` — a single message with role, content, and tool metadata
|
||||
//! - `Role` — message role enum (User, Assistant, System, Tool)
|
||||
|
||||
pub use crate::core::message::{ChatMessage, Role};
|
||||
pub use crate::core::conversation::Conversation;
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Pure domain entity for the edit log — an append-only log of file mutations.
|
||||
//!
|
||||
//! Records every file mutation made by any tool, enabling audit trails
|
||||
//! and potential undo operations. Each entry captures the tool name,
|
||||
//! target path, reason, content hash, and byte delta.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `EditLogRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Data Flow
|
||||
//! 1. Tools call `EditLog::push()` to record each mutation
|
||||
//! 2. The in-memory `EditLog` is periodically flushed to disk by the repo
|
||||
//! 3. Oldest entries are evicted from the in-memory cache when
|
||||
//! `MAX_MEMORY_ENTRIES` is exceeded (prevents unbounded growth)
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single recorded file edit event.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `ts` — Unix timestamp (seconds) when the edit occurred
|
||||
/// - `tool` — name of the tool that performed the edit (e.g. "Bash", "Edit")
|
||||
/// - `path` — absolute file path that was modified
|
||||
/// - `reason` — human-readable explanation of why the edit was made
|
||||
/// - `content_sha256` — SHA-256 hex digest of the content *after* the edit
|
||||
/// - `bytes_delta` — signed byte count change (+added, -removed)
|
||||
/// - `origin` — origin identifier (which agent / session context)
|
||||
/// - `session_id` — session in which this edit was performed
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditLogEntry {
|
||||
pub ts: i64,
|
||||
pub tool: String,
|
||||
pub path: String,
|
||||
pub reason: String,
|
||||
pub content_sha256: String,
|
||||
pub bytes_delta: i64,
|
||||
pub origin: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// Maximum number of edit entries held in memory at once.
|
||||
///
|
||||
/// Beyond this limit, old entries are dropped from the in-memory cache
|
||||
/// to prevent unbounded memory growth in long-running sessions.
|
||||
pub const MAX_MEMORY_ENTRIES: usize = 10_000;
|
||||
|
||||
/// In-memory view of a session's edit log.
|
||||
///
|
||||
/// Wraps a `Vec<EditLogEntry>` and provides basic query helpers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditLog {
|
||||
/// Ordered list of edit entries (newest appended last).
|
||||
pub entries: Vec<EditLogEntry>,
|
||||
}
|
||||
|
||||
impl EditLog {
|
||||
/// Create an empty edit log with no entries.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of in-memory entries.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Return `true` if the log contains no entries.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EditLog {
|
||||
/// Returns an empty `EditLog` via `EditLog::new()`.
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Domain error types for the CMS module.
|
||||
//!
|
||||
//! Typed error enums for repository and service operations.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O)
|
||||
//! - [`ServiceError`] — use-case / orchestration errors (invalid input, generic)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::DomainError;
|
||||
|
||||
/// Shared repository error type for CMS persistence operations.
|
||||
pub type RepositoryError = DomainError;
|
||||
|
||||
/// Errors from service / use-case operations in the CMS domain.
|
||||
#[derive(Debug)]
|
||||
pub enum ServiceError {
|
||||
/// A repository operation failed.
|
||||
Repository(DomainError),
|
||||
/// The provided input is invalid.
|
||||
InvalidInput(String),
|
||||
/// A generic error with a message.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl From<DomainError> for ServiceError {
|
||||
fn from(err: DomainError) -> Self {
|
||||
ServiceError::Repository(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ServiceError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ServiceError::Repository(err) => write!(f, "repository error: {err}"),
|
||||
ServiceError::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
|
||||
ServiceError::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ServiceError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
ServiceError::Repository(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! Pure domain entity for long-term agent memory.
|
||||
//!
|
||||
//! A `Memory` entry stores a named, kinded piece of information (lesson,
|
||||
//! reference, fact) with frontmatter metadata and free-form markdown
|
||||
//! content. Memories are persisted as individual `.md` files with YAML
|
||||
//! frontmatter.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `MemoryRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Utility Functions
|
||||
//! - `slugify()` — converts a name string into a filesystem-safe slug
|
||||
//! - `path()` — computes the on-disk path for a given memory name
|
||||
//!
|
||||
//! Both are pure computations that take parameters and perform no I/O.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single memory entry with frontmatter metadata and markdown content.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `name` — unique identifier / title for this memory
|
||||
/// - `description` — short summary of what this memory contains
|
||||
/// - `content` — free-form markdown body
|
||||
/// - `kind` — category/tag (e.g. "lesson", "reference", "fact")
|
||||
/// - `created_at` — Unix timestamp of creation
|
||||
/// - `updated_at` — Unix timestamp of last modification
|
||||
/// - `outcome` — optional outcome of applying this memory
|
||||
/// - `lifecycle` — lifecycle stage (e.g. "active", "archived")
|
||||
/// - `scope` — optional scope qualifier (which session/context this applies to)
|
||||
/// - `before_snippet` — optional context snapshot before memory was applied
|
||||
/// - `after_snippet` — optional context snapshot after memory was applied
|
||||
/// - `provenances` — list of origin identifiers that created or confirmed this memory
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Memory {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub kind: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub outcome: Option<String>,
|
||||
pub lifecycle: String,
|
||||
pub scope: Option<String>,
|
||||
pub before_snippet: Option<String>,
|
||||
pub after_snippet: Option<String>,
|
||||
pub provenances: Vec<String>,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
/// Convert an arbitrary string into a filesystem-safe slug.
|
||||
///
|
||||
/// Flow: lowercase → replace non-alphanumeric chars with `-` →
|
||||
/// collapse/trim repeated `-`.
|
||||
///
|
||||
/// Returns `None` if the result is empty or exceeds 80 characters.
|
||||
pub fn slugify(s: &str) -> Option<String> {
|
||||
// Phase 1: replace every non-alphanumeric character with '-'
|
||||
let slug: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
// Phase 2: collapse consecutive '-' separators
|
||||
let slug: String = slug
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if slug.is_empty() || slug.len() > 80 {
|
||||
return None;
|
||||
}
|
||||
Some(slug)
|
||||
}
|
||||
|
||||
/// Compute the on-disk path for a memory of the given name.
|
||||
///
|
||||
/// ## Parameters
|
||||
/// - `memory_dir` — the base directory for memory storage
|
||||
/// - `name` — the memory name (will be slugified internally)
|
||||
///
|
||||
/// Falls back to `"memory.md"` when the name slugifies to an empty
|
||||
/// or invalid string.
|
||||
///
|
||||
/// ## Pure Computation
|
||||
/// This function performs **no I/O** — it only computes a path.
|
||||
pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
|
||||
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
|
||||
let clean: String = format!("{slug}.md")
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let clean = clean.trim_start_matches('.').to_string();
|
||||
memory_dir.join(if clean.is_empty() {
|
||||
"memory.md".to_string()
|
||||
} else {
|
||||
clean
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Domain layer for CMS — pure entities, value objects, repository traits,
|
||||
//! and service interfaces.
|
||||
//!
|
||||
//! This is the innermost layer of the Clean Architecture onion. It has **zero
|
||||
//! infrastructure dependencies** — all I/O is expressed through repository
|
||||
//! traits defined in [`repository`], and business operations through service
|
||||
//! traits in [`service`].
|
||||
//!
|
||||
//! ## Sub-modules
|
||||
//! - `app_config` — provider configuration model (`AppConfig`, `ProviderConfig`, `ModelRole`)
|
||||
//! - `conversation` — conversation entity + chat message model (re-exported from core)
|
||||
//! - `edit_log` — edit log model (`EditLog`, `EditLogEntry`)
|
||||
//! - `memory` — memory file model (`Memory`)
|
||||
//! - `settings` — application settings model (`Settings`, `InternetMode`, `SettingsFlags`)
|
||||
//! - `repository` — trait definitions for all persistence adapters
|
||||
//! - `service` — trait definitions for all application services
|
||||
//!
|
||||
//! ## Key Design Principle
|
||||
//! Domain types are plain Rust structs with `serde` for serialisation.
|
||||
//! They contain no I/O, no framework imports, and no side effects.
|
||||
|
||||
pub mod app_config;
|
||||
pub mod commands;
|
||||
pub mod conversation;
|
||||
pub mod edit_log;
|
||||
pub mod error;
|
||||
pub mod memory;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
pub mod settings;
|
||||
|
||||
pub use app_config::AppConfig;
|
||||
pub use app_config::ModelRole;
|
||||
pub use app_config::ProviderConfig;
|
||||
pub use conversation::Conversation;
|
||||
pub use edit_log::EditLog;
|
||||
pub use edit_log::EditLogEntry;
|
||||
pub use error::{RepositoryError, ServiceError};
|
||||
pub use memory::Memory;
|
||||
pub use repository::AppConfigRepository;
|
||||
pub use repository::ConversationRepository;
|
||||
pub use repository::EditLogRepository;
|
||||
pub use repository::MemoryRepository;
|
||||
pub use repository::RewindBlobRepository;
|
||||
pub use repository::SettingsRepository;
|
||||
pub use service::ConversationService;
|
||||
pub use service::MemoryService;
|
||||
pub use service::SettingsService;
|
||||
pub use commands::{NewMemory, SettingsPatch};
|
||||
pub use settings::InternetMode;
|
||||
pub use settings::Settings;
|
||||
pub use settings::SettingsFlags;
|
||||
@@ -0,0 +1,126 @@
|
||||
//! Repository traits — pure abstraction boundaries for persistence.
|
||||
//!
|
||||
//! Each trait defines load / save / query operations that infrastructure
|
||||
//! adapters implement. The domain and application layers depend **only**
|
||||
//! on these traits, never on concrete persistence implementations.
|
||||
//!
|
||||
//! ## Traits
|
||||
//! - `SettingsRepository` — load/save `Settings` from/to a base directory
|
||||
//! - `AppConfigRepository` — load/save `AppConfig` from/to a base directory
|
||||
//! - `ConversationRepository` — load/save `Conversation` from/to a session directory
|
||||
//! - `MemoryRepository` — list/load/save/delete `Memory` entries
|
||||
//! - `RewindBlobRepository` — store/retrieve/list binary blobs per session
|
||||
//! - `EditLogRepository` — open/append/query edit log entries per session
|
||||
//!
|
||||
//! ## Dependency Inversion
|
||||
//! Application services accept these traits as generic type parameters,
|
||||
//! allowing the composition root to inject concrete implementations
|
||||
//! (file-based, SQLite-backed, etc.) without changing business logic.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::app_config::AppConfig;
|
||||
use super::conversation::Conversation;
|
||||
use super::edit_log::{EditLog, EditLogEntry};
|
||||
use super::error::RepositoryError;
|
||||
use super::memory::Memory;
|
||||
use super::settings::Settings;
|
||||
|
||||
/// Persistence contract for `Settings` (application settings model).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
|
||||
pub trait SettingsRepository {
|
||||
/// Load `Settings` from the given base directory.
|
||||
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError>;
|
||||
|
||||
/// Persist `Settings` to the given base directory.
|
||||
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `AppConfig` (provider and model configuration).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
|
||||
pub trait AppConfigRepository {
|
||||
/// Load `AppConfig` from the given base directory.
|
||||
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError>;
|
||||
|
||||
/// Persist `AppConfig` to the given base directory.
|
||||
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `Conversation` (session conversation data).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
|
||||
pub trait ConversationRepository {
|
||||
/// Load a `Conversation` from the given session directory.
|
||||
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
|
||||
|
||||
/// Persist a `Conversation` to the given session directory.
|
||||
fn save(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
conversation: &Conversation,
|
||||
) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `Memory` (long-term agent memory entries).
|
||||
///
|
||||
/// Implementors provide the actual I/O logic (e.g. per-memory markdown files).
|
||||
pub trait MemoryRepository {
|
||||
/// List all memory slugs (filenames without extension) in the memory directory.
|
||||
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError>;
|
||||
|
||||
/// Load a single `Memory` by name from the memory directory.
|
||||
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError>;
|
||||
|
||||
/// Save (create or overwrite) a `Memory` in the memory directory.
|
||||
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Delete a `Memory` by name from the memory directory.
|
||||
fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for rewind-snapshot binary blobs.
|
||||
///
|
||||
/// Blobs are keyed by an arbitrary caller-supplied key (e.g. a tool-call ID)
|
||||
/// within a session. They capture file snapshots for the "rewind" feature.
|
||||
pub trait RewindBlobRepository {
|
||||
/// Store (or overwrite) a binary blob under `blob_key` for this session.
|
||||
fn store_blob(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
blob_key: &str,
|
||||
data: &[u8],
|
||||
mime_type: Option<&str>,
|
||||
) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Retrieve a blob's raw bytes by key, or `None` if not found.
|
||||
fn retrieve_blob(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
blob_key: &str,
|
||||
) -> Result<Option<Vec<u8>>, RepositoryError>;
|
||||
|
||||
/// List all blob keys for this session, ordered oldest-first.
|
||||
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `EditLog` (append-only file mutation log).
|
||||
///
|
||||
/// Implementors manage an append-only log of `EditLogEntry` items per session,
|
||||
/// typically persisted to a file for audit and potential undo.
|
||||
pub trait EditLogRepository {
|
||||
/// Open (or initialise) the edit log for a session directory.
|
||||
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError>;
|
||||
|
||||
/// Append one entry to the log and persist immediately (write-through).
|
||||
fn append(
|
||||
&self,
|
||||
session_dir: &Path,
|
||||
log: &mut EditLog,
|
||||
entry: EditLogEntry,
|
||||
) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Return a cloned copy of all in-memory entries for inspection.
|
||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry>;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Service trait definitions — use-case boundaries for CMS operations.
|
||||
//!
|
||||
//! These traits define the public API of the application use-cases.
|
||||
//! They are implemented by concrete types in the `application` layer
|
||||
//! and consumed by infrastructure adapters (HTTP handlers, CLI commands).
|
||||
//!
|
||||
//! ## Traits
|
||||
//! - `SettingsService` — load/save settings, update provider config
|
||||
//! - `ConversationService` — load/save conversations, add messages
|
||||
//! - `MemoryService` — list/save/delete session memories
|
||||
//!
|
||||
//! ## Dependency Inversion
|
||||
//! Application service implementations accept repository traits as generic
|
||||
//! type parameters. Infrastructure adapters depend only on these service
|
||||
//! traits, never on concrete implementations.
|
||||
|
||||
use super::conversation::{ChatMessage, Conversation};
|
||||
use super::error::ServiceError;
|
||||
use super::memory::Memory;
|
||||
use super::settings::Settings;
|
||||
|
||||
/// Use-cases for application settings.
|
||||
pub trait SettingsService {
|
||||
/// Load the current `Settings` from the default store location.
|
||||
fn load_settings(&self) -> Result<Settings, ServiceError>;
|
||||
|
||||
/// Persist updated `Settings` to the default store location.
|
||||
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError>;
|
||||
|
||||
/// Update (or insert) a provider configuration entry.
|
||||
fn update_provider(
|
||||
&self,
|
||||
name: &str,
|
||||
config: &super::app_config::ProviderConfig,
|
||||
) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// Use-cases for conversation (session message) management.
|
||||
pub trait ConversationService {
|
||||
/// Load a `Conversation` for the given session ID.
|
||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError>;
|
||||
|
||||
/// Persist a `Conversation` to its session storage.
|
||||
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>;
|
||||
|
||||
/// Append a single `ChatMessage` to the conversation and persist.
|
||||
fn add_message(
|
||||
&self,
|
||||
conv: &mut Conversation,
|
||||
msg: ChatMessage,
|
||||
) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// Use-cases for long-term memory management.
|
||||
pub trait MemoryService {
|
||||
/// List all memory slugs (filenames without extension).
|
||||
fn list_memories(&self) -> Result<Vec<String>, ServiceError>;
|
||||
|
||||
/// Save (create or overwrite) a `Memory`.
|
||||
fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError>;
|
||||
|
||||
/// Delete a `Memory` by its slug/name.
|
||||
fn delete_memory(&self, name: &str) -> Result<(), ServiceError>;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Pure domain entity for application settings.
|
||||
//!
|
||||
//! Defines `Settings` (top-level user configuration), `SettingsFlags`
|
||||
//! (grouped boolean toggles), and `InternetMode` (network access level).
|
||||
//! Serialised to `settings.json` by the infrastructure layer.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in `SettingsRepository` (domain::repository).
|
||||
//!
|
||||
//! ## Settings Fields
|
||||
//! - `internet_mode` — network access policy (Off / ReadOnly / Full)
|
||||
//! - `provider` / `model` — default LLM provider and model name
|
||||
//! - `api_keys` — per-provider API key overrides (name → key)
|
||||
//! - `max_tokens` / `temperature` — generation parameter defaults
|
||||
//! - `review_max_lessons_per_run` — max lessons per auto-review pass
|
||||
//! - `verify_command` — optional shell command to run for verification
|
||||
//! - `workflow_max_concurrency` — max parallel hive-mind nodes
|
||||
//! - `hive_mind_node_timeout_ms` — per-node timeout for hive-mind orchestration
|
||||
//! - `flags` — grouped boolean feature toggles
|
||||
//! - `lsp_languages` — list of language IDs for LSP auto-provisioning
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Controls how much network access the agent is permitted during a session.
|
||||
///
|
||||
/// ## Variants
|
||||
/// - `Off` — no network access
|
||||
/// - `ReadOnly` — HTTP GET / HEAD only
|
||||
/// - `Full` — any HTTP method permitted
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum InternetMode {
|
||||
/// No network access permitted.
|
||||
#[default]
|
||||
Off,
|
||||
/// HTTP GET / HEAD requests only.
|
||||
ReadOnly,
|
||||
/// Any HTTP method permitted.
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Grouped boolean feature toggles for the application.
|
||||
///
|
||||
/// Kept as a separate struct to avoid clippy's
|
||||
/// `default-too-many-fields` threshold on `Settings`.
|
||||
///
|
||||
/// ## Fields
|
||||
/// - `review_enabled` — enable automatic inline review after edits
|
||||
/// - `session_archive_enabled` — enable periodic session archiving
|
||||
/// - `lsp_auto_provision` — auto-provision LSP language servers on project open
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsFlags {
|
||||
pub review_enabled: bool,
|
||||
pub session_archive_enabled: bool,
|
||||
pub lsp_auto_provision: bool,
|
||||
}
|
||||
|
||||
impl Default for SettingsFlags {
|
||||
/// Returns the default flags with all features enabled.
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
review_enabled: true,
|
||||
session_archive_enabled: true,
|
||||
lsp_auto_provision: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default hive-mind node timeout (600 seconds).
|
||||
fn default_hive_mind_node_timeout_ms() -> u64 {
|
||||
600_000
|
||||
}
|
||||
|
||||
/// Top-level application settings model.
|
||||
///
|
||||
/// Serialised to `settings.json` by the infrastructure persistence layer.
|
||||
/// Holds LLM provider selection, generation parameters, feature flags,
|
||||
/// and workflow configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub internet_mode: InternetMode,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub api_keys: HashMap<String, String>,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub review_max_lessons_per_run: usize,
|
||||
pub adaptive_review_max_skip: u32,
|
||||
pub verify_command: Option<String>,
|
||||
pub verify_timeout_ms: u64,
|
||||
pub workflow_max_concurrency: usize,
|
||||
#[serde(flatten)]
|
||||
pub flags: SettingsFlags,
|
||||
pub lsp_languages: Vec<String>,
|
||||
#[serde(default = "default_hive_mind_node_timeout_ms")]
|
||||
pub hive_mind_node_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
internet_mode: InternetMode::Off,
|
||||
provider: "zen".to_string(),
|
||||
model: "deepseek-v4-flash-free".to_string(),
|
||||
api_keys: HashMap::new(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
review_max_lessons_per_run: 5,
|
||||
adaptive_review_max_skip: 3,
|
||||
verify_command: None,
|
||||
verify_timeout_ms: 30_000,
|
||||
workflow_max_concurrency: 5,
|
||||
flags: SettingsFlags::default(),
|
||||
lsp_languages: Vec::new(),
|
||||
hive_mind_node_timeout_ms: 600_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user