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:
asepharyana
2026-07-20 09:04:57 +07:00
parent bceba665c0
commit da2ed6da25
454 changed files with 13979 additions and 29539 deletions
+127
View File
@@ -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,
}
}
}
+157
View File
@@ -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>>,
}
+15
View File
@@ -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;
+81
View File
@@ -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()
}
}
+51
View File
@@ -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,
}
}
}
+109
View File
@@ -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
})
}
}
+52
View File
@@ -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;
+126
View File
@@ -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>;
}
+64
View File
@@ -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>;
}
+120
View File
@@ -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,
}
}
}