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,30 @@
|
||||
//! Command types for IAM domain operations.
|
||||
//!
|
||||
//! Following the `NewXxx` / command 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.
|
||||
|
||||
/// Command to create a new session.
|
||||
///
|
||||
/// Carries only the data needed to construct a session entity — the
|
||||
/// service generates the UUID and timestamp internally.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewSession {
|
||||
/// Human-readable session title.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
impl From<String> for NewSession {
|
||||
fn from(title: String) -> Self {
|
||||
Self { title }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for NewSession {
|
||||
fn from(title: &str) -> Self {
|
||||
Self {
|
||||
title: title.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Domain error types for the IAM (auth) module.
|
||||
//!
|
||||
//! Typed error enums replace `anyhow::Result` in domain traits and
|
||||
//! application services, enabling callers to match on specific error
|
||||
//! variants (e.g. `NotFound` vs `Conflict`) rather than string-checking.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O)
|
||||
//! - [`ServiceError`] — use-case / orchestration errors (config, state
|
||||
//! mismatch, provider failures)
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crate::error::DomainError;
|
||||
|
||||
/// Shared repository error type for IAM persistence operations.
|
||||
pub type RepositoryError = DomainError;
|
||||
|
||||
/// Errors from service / use-case operations in the IAM domain.
|
||||
#[derive(Debug)]
|
||||
pub enum ServiceError {
|
||||
/// A repository operation failed.
|
||||
Repository(DomainError),
|
||||
/// The provided configuration is invalid.
|
||||
InvalidConfig(String),
|
||||
/// OAuth state mismatch — possible CSRF attack.
|
||||
StateMismatch,
|
||||
/// The OAuth provider returned an error.
|
||||
OAuthProvider(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::InvalidConfig(msg) => write!(f, "invalid configuration: {msg}"),
|
||||
ServiceError::StateMismatch => {
|
||||
write!(f, "OAuth state mismatch — possible CSRF attack")
|
||||
}
|
||||
ServiceError::OAuthProvider(msg) => write!(f, "OAuth provider error: {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,12 @@
|
||||
//! IAM Session re-export.
|
||||
//!
|
||||
//! Re-exports `Session` from the auth module for consistent IAM-boundary
|
||||
//! imports. Consumers of the IAM module import `Session` from here rather
|
||||
//! than from the core session module directly, keeping the dependency
|
||||
//! internal and allowing the IAM crate to own its domain vocabulary.
|
||||
|
||||
pub use super::session::Session;
|
||||
|
||||
/// Alias for `Session` used in IAM contexts to distinguish from other
|
||||
/// session types in the system.
|
||||
pub type IamSession = Session;
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Authentication domain entities, commands, errors, and repository/service traits.
|
||||
//!
|
||||
//! Combines the session types from `zesdex-entities` (auth sub-module) with the
|
||||
//! IAM domain types (commands, OAuth, repository/service traits) from `zesdex-iam`.
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
//! - [`session`] — `Session` entity (session metadata)
|
||||
//! - [`session_id`] — `SessionId` value object (validated newtype)
|
||||
//! - [`session_lock`] — `SessionLock` RAII guard (PID-file lock)
|
||||
//! - [`oauth`] — `OAuthToken`, `OAuthConfig` entities
|
||||
//! - [`iam_session`] — Re-export of `Session` for IAM-boundary consistency
|
||||
//! - [`commands`] — `NewSession` command type
|
||||
//! - [`error`] — `RepositoryError`, `ServiceError` types
|
||||
//! - [`repository`] — `SessionRepository`, `SessionLockRepository`, `OAuthRepository`
|
||||
//! - [`service`] — `SessionService`, `OAuthService` traits
|
||||
|
||||
pub mod commands;
|
||||
pub mod error;
|
||||
pub mod iam_session;
|
||||
pub mod oauth;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
pub mod session;
|
||||
pub mod session_id;
|
||||
pub mod session_lock;
|
||||
|
||||
pub use commands::NewSession;
|
||||
pub use error::{RepositoryError, ServiceError};
|
||||
pub use iam_session::IamSession;
|
||||
pub use oauth::{OAuthConfig, OAuthToken};
|
||||
pub use repository::{OAuthRepository, SessionLockRepository, SessionRepository};
|
||||
pub use service::{OAuthService, SessionService};
|
||||
pub use session::Session;
|
||||
pub use session_id::SessionId;
|
||||
pub use session_lock::SessionLock;
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Pure OAuth entities — no HTTP or persistence logic.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - [`OAuthToken`] — access token with optional refresh token, epoch expiry
|
||||
//! - [`OAuthConfig`] — provider configuration (auth URL, token URL, client id,
|
||||
//! optional client secret, scopes)
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An OAuth 2.0 access token with optional refresh token and absolute
|
||||
/// expiry time (epoch seconds).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthToken {
|
||||
/// The OAuth 2.0 access token string.
|
||||
pub access_token: String,
|
||||
/// Optional refresh token for long-lived access.
|
||||
pub refresh_token: Option<String>,
|
||||
/// Absolute expiry timestamp (epoch seconds since UNIX_EPOCH).
|
||||
pub expires_at: u64,
|
||||
/// Token type, e.g. `"Bearer"`.
|
||||
pub token_type: String,
|
||||
}
|
||||
|
||||
/// Static configuration for an OAuth provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OAuthConfig {
|
||||
/// Authorization endpoint URL.
|
||||
pub auth_url: String,
|
||||
/// Token exchange endpoint URL.
|
||||
pub token_url: String,
|
||||
/// OAuth client identifier.
|
||||
pub client_id: String,
|
||||
/// Optional client secret (not all flows require it).
|
||||
pub client_secret: Option<String>,
|
||||
/// Space-separated list of requested scopes.
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for OAuthConfig {
|
||||
fn default() -> Self {
|
||||
OAuthConfig {
|
||||
auth_url: String::new(),
|
||||
token_url: String::new(),
|
||||
client_id: String::new(),
|
||||
client_secret: None,
|
||||
scopes: vec![
|
||||
"openid".to_string(),
|
||||
"profile".to_string(),
|
||||
"email".to_string(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Repository trait definitions (pure — no impls, no concrete persistence).
|
||||
//!
|
||||
//! Defines the repository contracts that infrastructure adapters implement.
|
||||
//! Following clean architecture, domain code depends only on these traits,
|
||||
//! not on concrete persistence libraries.
|
||||
//!
|
||||
//! # Traits
|
||||
//!
|
||||
//! - [`SessionRepository`] — CRUD for session metadata
|
||||
//! - [`SessionLockRepository`] — acquire/release/liveness for session locks
|
||||
//! - [`OAuthRepository`] — persist/load OAuth tokens
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use crate::auth::error::RepositoryError;
|
||||
use crate::auth::oauth::OAuthToken;
|
||||
use crate::auth::session::Session;
|
||||
use crate::auth::session_id::SessionId;
|
||||
|
||||
/// Repository for loading, saving, listing, and deleting sessions.
|
||||
pub trait SessionRepository {
|
||||
/// List all loadable sessions under `<base_dir>/sessions/`.
|
||||
fn list_sessions(&self, base_dir: &Path) -> Result<Vec<Session>, RepositoryError>;
|
||||
|
||||
/// Load a single session by id.
|
||||
fn load_session(&self, base_dir: &Path, id: &SessionId) -> Result<Session, RepositoryError>;
|
||||
|
||||
/// Save a session's metadata to disk.
|
||||
fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Delete a session directory and all its contents.
|
||||
fn delete_session(&self, base_dir: &Path, id: &SessionId) -> Result<(), RepositoryError>;
|
||||
}
|
||||
|
||||
/// Repository for per-session PID-file advisory locks.
|
||||
pub trait SessionLockRepository {
|
||||
/// Try to acquire the lock for a session directory.
|
||||
/// Returns `true` if the lock was acquired, `false` if another live
|
||||
/// process holds it.
|
||||
fn try_lock(&self, session_dir: &Path) -> Result<bool, RepositoryError>;
|
||||
|
||||
/// Release the lock by removing the lock file.
|
||||
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Check whether a process with the given PID is alive.
|
||||
fn is_alive(&self, pid: u32) -> bool;
|
||||
}
|
||||
|
||||
/// Repository for persisting and loading OAuth tokens.
|
||||
pub trait OAuthRepository {
|
||||
/// Persist an OAuth token to a JSON file.
|
||||
fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError>;
|
||||
|
||||
/// Load an OAuth token from a JSON file, returning `None` if the file
|
||||
/// does not exist.
|
||||
fn load_token(&self, path: &Path) -> Result<Option<OAuthToken>, RepositoryError>;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Service trait definitions — use-case interfaces for session management
|
||||
//! and OAuth flows.
|
||||
//!
|
||||
//! These traits define the boundary between the application orchestration
|
||||
//! layer and the domain. Implementations live in the application layer.
|
||||
//!
|
||||
//! # Traits
|
||||
//!
|
||||
//! - [`SessionService`] — create, list, archive sessions
|
||||
//! - [`OAuthService`] — start PKCE flow, complete code exchange, retrieve token
|
||||
|
||||
use crate::auth::error::ServiceError;
|
||||
use crate::auth::oauth::{OAuthConfig, OAuthToken};
|
||||
use crate::auth::session::Session;
|
||||
use crate::auth::session_id::SessionId;
|
||||
|
||||
/// Session management use-case boundary.
|
||||
pub trait SessionService {
|
||||
/// Create a new session with a generated UUID and the given title.
|
||||
fn create_session(&self, title: &str) -> Result<Session, ServiceError>;
|
||||
|
||||
/// List all available sessions.
|
||||
fn list_all(&self) -> Result<Vec<Session>, ServiceError>;
|
||||
|
||||
/// Archive a session by id (sets `archived = true`).
|
||||
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError>;
|
||||
}
|
||||
|
||||
/// OAuth flow use-case boundary.
|
||||
pub trait OAuthService {
|
||||
/// Start an OAuth authorization-code + PKCE flow for the given
|
||||
/// `redirect_uri` (the caller is responsible for actually listening on
|
||||
/// it — e.g. a bound `LoopbackServer`). Returns `(auth_url, state)`:
|
||||
/// the URL to send the user to, and the CSRF state token that must be
|
||||
/// passed back into `complete_flow` unchanged.
|
||||
fn start_flow(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
) -> Result<(String, String), ServiceError>;
|
||||
|
||||
/// Complete the OAuth flow: validates `state` against the value
|
||||
/// persisted during `start_flow` (bailing on mismatch — this is the
|
||||
/// CSRF check), then exchanges `code` for a token using the same
|
||||
/// `redirect_uri` passed to `start_flow`.
|
||||
fn complete_flow(
|
||||
&self,
|
||||
config: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
code: &str,
|
||||
state: &str,
|
||||
) -> Result<OAuthToken, ServiceError>;
|
||||
|
||||
/// Retrieve the currently stored OAuth token (if any).
|
||||
fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Session metadata: id, title, workspace roots, and message/token counts,
|
||||
//! persisted as `session.json` per session directory.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Created via [`Session::new`] → mutated in-memory → persisted via repository.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `Session` struct — fields for all session metadata
|
||||
//! - `new` — timestamped constructor
|
||||
//! - `session_dir` / `conversation_path` — pure path computation
|
||||
use chrono::Utc;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Metadata for one conversation session (distinct from the message
|
||||
/// history itself, which lives in `Conversation`/the msglog).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
/// Unique session identifier (validated against path traversal in `load`).
|
||||
pub id: String,
|
||||
/// Epoch-millis timestamp of creation (`Utc::now().timestamp_millis()`).
|
||||
pub created_at: i64,
|
||||
/// Epoch-millis timestamp of last update.
|
||||
pub updated_at: i64,
|
||||
/// Human-readable title for the conversation.
|
||||
pub title: String,
|
||||
/// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`.
|
||||
pub model: String,
|
||||
/// Workspace root directories associated with this session.
|
||||
pub workspace_roots: Vec<PathBuf>,
|
||||
/// Running count of messages in the conversation.
|
||||
pub message_count: u32,
|
||||
/// Running count of tokens consumed.
|
||||
pub token_count: u32,
|
||||
/// Soft-delete flag — archived sessions are hidden from the default list.
|
||||
pub archived: bool,
|
||||
/// Optional AI-generated conversation summary (used for compact context).
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Create a new session with the given id/title, defaulting the
|
||||
/// model, workspace root (current dir), and counters.
|
||||
pub fn new(id: String, title: String) -> Self {
|
||||
let now = Utc::now().timestamp_millis();
|
||||
Session {
|
||||
id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
title,
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
|
||||
message_count: 0,
|
||||
token_count: 0,
|
||||
archived: false,
|
||||
summary: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute this session's directory under `<base_dir>/sessions/<id>`.
|
||||
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
|
||||
base_dir.join("sessions").join(&self.id)
|
||||
}
|
||||
|
||||
/// Compute this session's `conversation.json` path.
|
||||
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
|
||||
self.session_dir(base_dir).join("conversation.json")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Validated session identifier newtype.
|
||||
//!
|
||||
//! [`SessionId`] wraps a `String` that has been checked for path-traversal
|
||||
//! characters. Construction via `SessionId::new(str)` validates the input
|
||||
//! once; the guarantee is then enforced by the type system for all
|
||||
//! downstream use.
|
||||
//!
|
||||
//! # Validation rules
|
||||
//!
|
||||
//! - Must not be empty
|
||||
//! - Must only contain alphanumeric characters, hyphens, and underscores
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A validated session identifier.
|
||||
///
|
||||
/// Guarantees the inner string is non-empty and contains no path-traversal
|
||||
/// characters (`/`, `\\`, `..`) or other unsafe delimiters.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub struct SessionId(String);
|
||||
|
||||
impl SessionId {
|
||||
/// Validate and construct a `SessionId`.
|
||||
///
|
||||
/// Returns `Err(msg)` if the input contains path separators, `..`, or
|
||||
/// is empty.
|
||||
pub fn new(id: &str) -> Result<Self, String> {
|
||||
if id.is_empty() {
|
||||
return Err("session id must not be empty".to_string());
|
||||
}
|
||||
if id.contains('/') || id.contains('\\') || id.contains("..") {
|
||||
return Err(format!(
|
||||
"session id '{id}' must not contain path separators"
|
||||
));
|
||||
}
|
||||
Ok(SessionId(id.to_string()))
|
||||
}
|
||||
|
||||
/// Return the underlying string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Return the underlying owned string.
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
/// Append this session id as a component of `base_dir`, yielding
|
||||
/// `base_dir / self.0`.
|
||||
///
|
||||
/// Safe because the id has been validated to contain no path separators.
|
||||
pub fn join_to(&self, base_dir: &Path) -> PathBuf {
|
||||
base_dir.join(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for SessionId {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SessionId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<SessionId> for String {
|
||||
fn from(sid: SessionId) -> Self {
|
||||
sid.0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_valid_uuids() {
|
||||
assert!(SessionId::new("550e8400-e29b-41d4-a716-446655440000").is_ok());
|
||||
assert!(SessionId::new("my-session_123").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_path_traversal() {
|
||||
assert!(SessionId::new("../etc/passwd").is_err());
|
||||
assert!(SessionId::new("foo/../../bar").is_err());
|
||||
assert!(SessionId::new("foo\\..\\bar").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rejects_empty() {
|
||||
assert!(SessionId::new("").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_into_string() {
|
||||
let sid = SessionId::new("abc-123").unwrap();
|
||||
assert_eq!(sid.into_string(), "abc-123");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! PID-file based advisory lock preventing two processes from operating on
|
||||
//! the same session directory concurrently.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! [`SessionLock::new`] creates a handle → [`SessionLock::try_lock`] attempts
|
||||
//! atomic `O_CREAT|O_EXCL` creation. If the lock file already exists, the
|
||||
//! owning PID is checked via liveness verification. Stale locks are
|
||||
//! overwritten atomically (temp-file + rename + fsync). On [`Drop`],
|
||||
//! the lock file is removed automatically.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `SessionLock` — RAII guard wrapping a lock file path and PID
|
||||
//! - `try_lock` — three-phase atomic acquire with stale-lock recovery
|
||||
//! - `unlock` / `Drop` — explicit and implicit release
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing;
|
||||
|
||||
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
|
||||
/// auto-removed on drop.
|
||||
#[derive(Debug)]
|
||||
pub struct SessionLock {
|
||||
/// Path to the `.lock` file inside the session directory.
|
||||
pub(crate) path: PathBuf,
|
||||
/// Process ID that holds (or will hold) this lock.
|
||||
pub(crate) pid: u32,
|
||||
}
|
||||
|
||||
impl SessionLock {
|
||||
/// Construct a lock handle for a session directory (does not acquire
|
||||
/// the lock yet — call `try_lock`).
|
||||
pub fn new(session_dir: &Path) -> Self {
|
||||
SessionLock {
|
||||
path: session_dir.join(".lock"),
|
||||
pid: std::process::id(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to acquire the session lock using an atomic file creation.
|
||||
///
|
||||
/// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that
|
||||
/// succeeds, the lock is ours — write our PID and return ok. If the
|
||||
/// file already exists, read the PID inside it and check whether that
|
||||
/// PID is still alive: if the process is still running, fail to acquire;
|
||||
/// otherwise the lock is stale — overwrite it with our own PID and succeed.
|
||||
///
|
||||
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
|
||||
/// process holds it, `Err` on I/O failure.
|
||||
pub fn try_lock(&self) -> std::io::Result<bool> {
|
||||
// Phase 1: try atomic create. If it succeeds, the lock is ours.
|
||||
match fs::OpenOptions::new()
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&self.path)
|
||||
{
|
||||
Ok(mut file) => {
|
||||
write!(file, "{}", self.pid)?;
|
||||
file.sync_all()?;
|
||||
tracing::debug!(path = %self.path.display(), pid = self.pid, "session lock acquired");
|
||||
return Ok(true);
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
tracing::debug!(path = %self.path.display(), "session lock already exists, checking staleness");
|
||||
// Lock file exists — check if it's stale.
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
|
||||
// Phase 2: lock file exists — check liveness of the owning process.
|
||||
let content = fs::read_to_string(&self.path).unwrap_or_default();
|
||||
if let Ok(pid) = content.trim().parse::<u32>() {
|
||||
if Self::is_alive(pid) {
|
||||
tracing::warn!(stale = pid, path = %self.path.display(), "session lock held by live process");
|
||||
return Ok(false);
|
||||
}
|
||||
tracing::debug!(stale = pid, "stale lock detected, overwriting");
|
||||
}
|
||||
|
||||
// Phase 3: stale lock — overwrite it atomically (best-effort).
|
||||
// Use a temp file + rename to avoid partial writes corrupting the lock.
|
||||
let tmp = self.path.with_extension("lock.tmp");
|
||||
{
|
||||
let mut tmp_file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)?;
|
||||
write!(tmp_file, "{}", self.pid)?;
|
||||
tmp_file.sync_all()?;
|
||||
}
|
||||
fs::rename(&tmp, &self.path)?;
|
||||
// Sync the parent directory so the rename survives a crash.
|
||||
if let Some(parent) = self.path.parent() {
|
||||
let _ = fs::File::open(parent).and_then(|d| d.sync_all());
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Explicitly release the lock by removing the lock file.
|
||||
pub fn unlock(&self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
|
||||
/// Check whether a process with the given PID is currently alive.
|
||||
///
|
||||
/// Uses `kill(pid, 0)` on Unix via the `nix` or `libc` crate in production;
|
||||
/// here we provide a best-effort check using the process table.
|
||||
/// On non-Unix platforms this always returns `true` (conservative).
|
||||
fn is_alive(pid: u32) -> bool {
|
||||
// On Unix, signal 0 checks process existence without sending a signal.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
|
||||
// whether the process exists and the caller has permission to signal it.
|
||||
// The integer argument is a PID validated by `try_lock`.
|
||||
let pid_signed: i32 = match pid.try_into() {
|
||||
Ok(p) => p,
|
||||
Err(_) => return false,
|
||||
};
|
||||
if unsafe { libc::kill(pid_signed, 0) != 0 } {
|
||||
return false;
|
||||
}
|
||||
// Extra check: verify the PID belongs to a zesdex process via
|
||||
// /proc/<pid>/exe to mitigate the PID-reuse race.
|
||||
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
|
||||
if let Ok(target) = std::fs::read_link(&proc_exe) {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if target != exe {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Fallback: always assume alive (conservative).
|
||||
let _ = pid;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SessionLock {
|
||||
/// Release the lock automatically when the guard goes out of scope,
|
||||
/// so an ungracefully-exited process doesn't leave a dangling lock.
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! In-memory conversation state: message history plus the system prompt and
|
||||
//! model parameters used to drive the LLM.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! [`Conversation::new`] → [`push`](Conversation::push) to add messages →
|
||||
//! [`to_api_messages`](Conversation::to_api_messages) to format for the LLM
|
||||
//! API (system prompt prepended).
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `Conversation` — message vector + session metadata + generation params
|
||||
//! - `push` / `rebuild_system` — mutation helpers
|
||||
//! - `to_api_messages` — formats messages for API consumption
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::message::{ChatMessage, Role};
|
||||
|
||||
/// A single conversation's message history and generation settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Conversation {
|
||||
/// Ordered list of chat messages (user, assistant, tool, system).
|
||||
pub messages: Vec<ChatMessage>,
|
||||
/// System prompt prepended at request time (see `to_api_messages`).
|
||||
pub system_prompt: String,
|
||||
/// Foreign key referencing the owning session.
|
||||
pub session_id: String,
|
||||
/// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`.
|
||||
pub model: String,
|
||||
/// Optional cap on output tokens.
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Optional temperature (0.0 – 2.0).
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl Conversation {
|
||||
/// Create an empty conversation with the given system prompt and
|
||||
/// session id, using default model/token/temperature settings.
|
||||
pub fn new(system_prompt: String, session_id: String) -> Self {
|
||||
Conversation {
|
||||
messages: Vec::new(),
|
||||
system_prompt,
|
||||
session_id,
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a message to the conversation history.
|
||||
pub fn push(&mut self, msg: ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
/// Replace the system prompt and strip any prior `System`-role
|
||||
/// messages from history.
|
||||
///
|
||||
/// Why: the system prompt is re-injected fresh at request time via
|
||||
/// `to_api_messages`, so stale `System` messages in `self.messages`
|
||||
/// would be redundant/conflicting if left in place.
|
||||
pub fn rebuild_system(&mut self, new_prompt: String) {
|
||||
self.system_prompt = new_prompt;
|
||||
self.messages.retain(|m| !matches!(m.role, Role::System));
|
||||
}
|
||||
|
||||
/// Build the message list to send to the LLM API, with the system
|
||||
/// prompt prepended.
|
||||
///
|
||||
/// Return: a new `Vec` (clone of history) with a synthesized system
|
||||
/// message at index 0.
|
||||
pub fn to_api_messages(&self) -> Vec<ChatMessage> {
|
||||
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
|
||||
msgs.push(ChatMessage::system(&self.system_prompt));
|
||||
msgs.extend(self.messages.iter().cloned());
|
||||
msgs
|
||||
}
|
||||
|
||||
/// Number of messages in the conversation history (excluding the
|
||||
/// synthesized system message).
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
/// Returns `true` if the conversation has no messages.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Chat message types shared across the entity layer.
|
||||
//!
|
||||
//! Provides [`Role`] (conversation participant) and [`ChatMessage`] (a single
|
||||
//! message with optional tool-call metadata). Includes convenience constructors
|
||||
//! for each role: `user`, `assistant`, `system`, `tool`/`tool_result`.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Messages are constructed via the typed constructors → pushed into
|
||||
//! [`Conversation`](super::conversation::Conversation) → serialized as JSON
|
||||
//! to `conversation.json`.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// The conversation participant who authored a message.
|
||||
///
|
||||
/// Variants: `User`, `Assistant`, `System`, `Tool`. Serialized as lowercase
|
||||
/// strings (e.g. `"user"`, `"assistant"`, `"system"`, `"tool"`).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Role {
|
||||
#[serde(rename = "user")]
|
||||
User,
|
||||
#[serde(rename = "assistant")]
|
||||
Assistant,
|
||||
#[serde(rename = "system")]
|
||||
System,
|
||||
#[serde(rename = "tool")]
|
||||
Tool,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
/// Return the role as a lowercase string.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
Role::Tool => "tool",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Role {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// A single message in a conversation, compatible with the OpenAI/Anthropic
|
||||
/// chat-completion API structures.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
/// Who sent this message (user, assistant, system, tool).
|
||||
pub role: Role,
|
||||
/// The message text content. `None` for assistant messages that only
|
||||
/// contain tool calls.
|
||||
pub content: Option<String>,
|
||||
/// Tool-call requests attached to an assistant message (OpenAI-style).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
|
||||
/// For tool-role messages: the `id` of the `ToolCall` being responded to.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
/// Optional function name for the tool invocation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
/// Build a user-role message with the given text content.
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::User,
|
||||
content: Some(content.into()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an assistant-role message with an optional text response.
|
||||
pub fn assistant(content: Option<String>) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::Assistant,
|
||||
content,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a system-role message with the given instruction text.
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::System,
|
||||
content: Some(content.into()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a tool-role result message referencing a prior tool call.
|
||||
pub fn tool(tool_call_id: String, content: String) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::Tool,
|
||||
content: Some(content),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_call_id),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Alias for `tool`, used throughout the codebase for tool results.
|
||||
pub fn tool_result(tool_call_id: String, content: String) -> Self {
|
||||
ChatMessage {
|
||||
role: Role::Tool,
|
||||
content: Some(content),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_call_id),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! Core domain entities shared across the Zesdex application.
|
||||
//!
|
||||
//! Contains pure data structures for conversations, messages, tool calls,
|
||||
//! usage statistics, provider API types, and store path configuration.
|
||||
//! All types derive `Serialize`/`Deserialize` for JSON persistence.
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
//! - [`conversation`] — Ordered conversation (vector of `ChatMessage`)
|
||||
//! - [`message`] — `ChatMessage` + `Role` enum
|
||||
//! - [`provider`] — LLM provider API types: `ChatRequest`, `ChatResponse`,
|
||||
//! `StreamEvent`, `SseParser`, `ToolDef`, etc.
|
||||
//! - [`store`] — `Store` paths for data directories
|
||||
//! - [`tool_call`] — `ToolCall` + `ToolFunction` (function-calling request)
|
||||
//! - [`tool_result`] — `ToolCallResult` (function-calling response)
|
||||
//! - [`usage`] — `UsageStats` (token counts, costs)
|
||||
|
||||
pub mod conversation;
|
||||
pub mod message;
|
||||
pub mod provider;
|
||||
pub mod store;
|
||||
pub mod tool_call;
|
||||
pub mod tool_result;
|
||||
pub mod usage;
|
||||
|
||||
pub use conversation::Conversation;
|
||||
pub use message::{ChatMessage, Role};
|
||||
pub use provider::{
|
||||
ChatRequest, ChatResponse, Choice, Delta, SseParser, StreamEvent, StreamOptions, TokenUsage,
|
||||
ToolDef, ToolFunctionDef,
|
||||
};
|
||||
pub use store::Store;
|
||||
pub use tool_call::{ToolCall, ToolFunction};
|
||||
pub use tool_result::ToolCallResult;
|
||||
pub use usage::UsageStats;
|
||||
@@ -0,0 +1,387 @@
|
||||
//! Provider-facing DTOs: chat completion request, response, streaming types,
|
||||
//! and the SSE stream parser.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! 1. **Request** — [`ChatRequest`] is built with model, messages, tools,
|
||||
//! streaming options and sent to the LLM provider.
|
||||
//! 2. **Response** — Non-streaming responses arrive as [`ChatResponse`] with
|
||||
//! [`Choice`]s containing the full [`ChatMessage`](super::message::ChatMessage).
|
||||
//! 3. **Streaming** — SSE chunks are fed into [`SseParser::feed`] which yields
|
||||
//! [`StreamEvent`]s: token/text, reasoning, tool-call deltas, usage, done.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `ChatRequest` / `StreamOptions` / `ToolDef` / `ToolFunctionDef` — outbound
|
||||
//! - `ChatResponse` / `Choice` / `Delta` / `TokenUsage` — non-streaming inbound
|
||||
//! - `StreamEvent` — one atomic streaming event (Token, Reasoning,
|
||||
//! ToolCallDelta, Usage, Done, Error)
|
||||
//! - `SseParser` — incremental SSE frame parser: `feed()` → `Vec<StreamEvent>`
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tracing;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat request / response
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible
|
||||
/// provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatRequest {
|
||||
/// Model identifier, e.g. `"anthropic/claude-opus-4-8"`.
|
||||
pub model: String,
|
||||
/// Full message history (system + user + assistant + tool turns).
|
||||
pub messages: Vec<super::message::ChatMessage>,
|
||||
/// Maximum number of output tokens.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_tokens: Option<u32>,
|
||||
/// Sampling temperature (0.0 – 2.0).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f32>,
|
||||
/// Tool definitions available to the model.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDef>>,
|
||||
/// Controls which (if any) function is called by the model.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<Value>,
|
||||
/// Whether to use SSE streaming (`true`) or a single response.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream: Option<bool>,
|
||||
/// Nucleus sampling threshold.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub top_p: Option<f32>,
|
||||
/// Sequences where the model should stop generation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stop: Option<Vec<String>>,
|
||||
/// Additional streaming options (e.g. `include_usage`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub stream_options: Option<StreamOptions>,
|
||||
}
|
||||
|
||||
/// Streaming options for the request; `include_usage` asks the provider to
|
||||
/// emit a final usage chunk in the SSE stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StreamOptions {
|
||||
pub include_usage: bool,
|
||||
}
|
||||
|
||||
/// Wire format for a single tool definition sent to the provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDef {
|
||||
/// The tool type discriminator, e.g. `"function"`.
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
/// The function definition (name, description, JSON schema).
|
||||
pub function: ToolFunctionDef,
|
||||
}
|
||||
|
||||
/// Name, description, and JSON schema parameters for a tool definition.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunctionDef {
|
||||
/// The function name the model may invoke.
|
||||
pub name: String,
|
||||
/// Human-readable description of what the function does.
|
||||
pub description: String,
|
||||
/// JSON Schema object describing the expected arguments.
|
||||
pub parameters: Value,
|
||||
}
|
||||
|
||||
/// Non-streaming chat completion response returned by the provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatResponse {
|
||||
/// Unique response identifier from the provider.
|
||||
pub id: String,
|
||||
/// Object type, e.g. `"chat.completion"`.
|
||||
pub object: Option<String>,
|
||||
/// Model identifier that produced this response.
|
||||
pub model: String,
|
||||
/// One or more completion candidates.
|
||||
pub choices: Vec<Choice>,
|
||||
/// Token usage statistics (prompt, completion, total).
|
||||
pub usage: Option<TokenUsage>,
|
||||
/// Unix-timestamp of response creation.
|
||||
pub created: Option<i64>,
|
||||
}
|
||||
|
||||
/// One completion candidate within a `ChatResponse.choices` list.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Choice {
|
||||
/// Zero-based index of this choice in the candidate list.
|
||||
pub index: u32,
|
||||
/// Full message (non-streaming response).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<super::message::ChatMessage>,
|
||||
/// Incremental delta (streaming response).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub delta: Option<Delta>,
|
||||
/// Why the model stopped: `"stop"`, `"tool_calls"`, `"length"`, etc.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub finish_reason: Option<String>,
|
||||
}
|
||||
|
||||
/// Incremental delta emitted in a streaming SSE chunk.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Delta {
|
||||
/// Role being set for the first streaming chunk.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<super::message::Role>,
|
||||
/// Incremental text content delta.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content: Option<String>,
|
||||
/// Incremental tool-call delta (partial name/arguments).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
|
||||
}
|
||||
|
||||
/// Token counts and optional cost breakdown for a single completion request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct TokenUsage {
|
||||
/// Tokens consumed by the prompt (input).
|
||||
pub prompt_tokens: u32,
|
||||
/// Tokens consumed by the completion (output).
|
||||
pub completion_tokens: u32,
|
||||
/// Sum of prompt + completion tokens.
|
||||
pub total_tokens: u32,
|
||||
/// Estimated cost for prompt tokens (provider-specific).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prompt_tokens_cost: Option<f64>,
|
||||
/// Estimated cost for completion tokens (provider-specific).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub completion_tokens_cost: Option<f64>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE streaming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// One atomic event extracted from an LLM streaming response stream.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum StreamEvent {
|
||||
/// An incremental text token.
|
||||
Token(String),
|
||||
/// An incremental reasoning token (Anthropic `reasoning_content`).
|
||||
Reasoning(String),
|
||||
/// An incremental tool-call delta (partial ID, name, or arguments).
|
||||
ToolCallDelta {
|
||||
/// Tool-call index (multiple calls in one response).
|
||||
index: usize,
|
||||
/// Optional tool-call ID (usually in the first delta for a call).
|
||||
id: Option<String>,
|
||||
/// Optional function name (usually in the first delta for a call).
|
||||
name: Option<String>,
|
||||
/// Partial JSON arguments delta for this tool call.
|
||||
arguments_delta: String,
|
||||
},
|
||||
/// Final usage chunk with token counts.
|
||||
Usage {
|
||||
prompt_tokens: u64,
|
||||
completion_tokens: u64,
|
||||
total_tokens: u64,
|
||||
},
|
||||
/// Stream complete (all tokens have been delivered).
|
||||
Done,
|
||||
/// A stream-level error occurred.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Buffered SSE frame parser that accumulates raw `data:` lines and
|
||||
/// flushes a `StreamEvent` on each blank-line boundary.
|
||||
pub struct SseParser {
|
||||
/// Leftover bytes from the last chunk that did not end with `\n`.
|
||||
buffer: String,
|
||||
/// The current `event:` type (set by `event:` lines, cleared on flush).
|
||||
event_type: Option<String>,
|
||||
/// Accumulated `data:` lines for the current event frame.
|
||||
data_lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl SseParser {
|
||||
/// Create a new parser with an empty buffer.
|
||||
pub fn new() -> Self {
|
||||
SseParser {
|
||||
buffer: String::new(),
|
||||
event_type: None,
|
||||
data_lines: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed a raw SSE chunk and produce any completed events.
|
||||
///
|
||||
/// Flow: append chunk to buffer → scan for '\n' → strip '\r' → on
|
||||
/// blank line, call `flush_event` to parse the accumulated data →
|
||||
/// on `event:` line, store the event type → on `data:` line, append
|
||||
/// to data accumulator → continue until buffer exhausted.
|
||||
///
|
||||
/// Edge case: a chunk may split mid-line; the remainder stays in the
|
||||
/// buffer for the next `feed()` call.
|
||||
///
|
||||
/// Return: all `StreamEvent`s completed by this chunk.
|
||||
pub fn feed(&mut self, chunk: &str) -> Vec<StreamEvent> {
|
||||
self.buffer.push_str(chunk);
|
||||
let mut events = Vec::new();
|
||||
while let Some(line_end) = self.buffer.find('\n') {
|
||||
let line = self.buffer[..line_end].trim_end_matches('\r').to_string();
|
||||
self.buffer = self.buffer[line_end + 1..].to_string();
|
||||
if line.is_empty() {
|
||||
events.extend(self.flush_event());
|
||||
} else if let Some(ty) = line.strip_prefix("event: ") {
|
||||
self.event_type = Some(ty.trim().to_string());
|
||||
} else if let Some(data) = line.strip_prefix("data:") {
|
||||
let data = data.trim_start().to_string();
|
||||
self.data_lines.push(data);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// Flush the current buffered `data:` lines as one or more `StreamEvent`s.
|
||||
///
|
||||
/// Flow: join data lines → handle `[DONE]` sentinel → JSON-parse →
|
||||
/// emit `Usage` if a usage object is present → else match `event_type`
|
||||
/// ("message.stop", "message.delta", etc.) → extract content,
|
||||
/// reasoning, tool-call deltas, or finish-reason from the delta
|
||||
/// structure (supporting both Anthropic-style top-level delta and
|
||||
/// OpenAI-style `choices` array).
|
||||
///
|
||||
/// Return: 0, 1, or more `StreamEvent`s from the flushed frame.
|
||||
fn flush_event(&mut self) -> Vec<StreamEvent> {
|
||||
let data = self.data_lines.join("\n");
|
||||
self.data_lines.clear();
|
||||
let event_type = self.event_type.take().unwrap_or_default();
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
if data == "[DONE]" {
|
||||
return vec![StreamEvent::Done];
|
||||
}
|
||||
return vec![];
|
||||
}
|
||||
let value: Value = match serde_json::from_str(&data) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("[stream] failed to parse chunk: {}", e);
|
||||
return vec![];
|
||||
}
|
||||
};
|
||||
|
||||
let mut events = Vec::new();
|
||||
|
||||
if let Some(usage) = value.get("usage") {
|
||||
if !usage.is_null() {
|
||||
let prompt_tokens = usage
|
||||
.get("prompt_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] prompt_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let completion_tokens = usage
|
||||
.get("completion_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] completion_tokens missing in usage chunk");
|
||||
0
|
||||
});
|
||||
let total_tokens = usage
|
||||
.get("total_tokens")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_else(|| {
|
||||
tracing::warn!("[stream] total_tokens missing in usage chunk");
|
||||
prompt_tokens + completion_tokens
|
||||
});
|
||||
events.push(StreamEvent::Usage {
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
total_tokens,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut other_events = match event_type.as_str() {
|
||||
"message.stop" => vec![StreamEvent::Done],
|
||||
"message.delta" | "" => {
|
||||
let mut d_events = Vec::new();
|
||||
if let Some(delta) = value.get("delta").or_else(|| value.get("choices")) {
|
||||
if let Some(choices) = delta.as_array() {
|
||||
if let Some(choice) = choices.first() {
|
||||
if let Some(d) = choice.get("delta") {
|
||||
// Content token
|
||||
if let Some(content) = d.get("content").and_then(|c| c.as_str()) {
|
||||
d_events.push(StreamEvent::Token(content.to_string()));
|
||||
}
|
||||
|
||||
// Reasoning token
|
||||
if let Some(reasoning) =
|
||||
d.get("reasoning_content").and_then(|r| r.as_str())
|
||||
{
|
||||
d_events.push(StreamEvent::Reasoning(reasoning.to_string()));
|
||||
}
|
||||
|
||||
// Tool calls — iterate ALL entries, not just first()
|
||||
if let Some(tool_calls) =
|
||||
d.get("tool_calls").and_then(|tc| tc.as_array())
|
||||
{
|
||||
for tc in tool_calls {
|
||||
let index =
|
||||
tc.get("index").and_then(Value::as_u64).unwrap_or_else(
|
||||
|| {
|
||||
tracing::warn!(
|
||||
"[stream] tool call delta missing index, \
|
||||
defaulting to 0"
|
||||
);
|
||||
0
|
||||
},
|
||||
);
|
||||
let index = usize::try_from(index).unwrap_or(0);
|
||||
let id = tc
|
||||
.get("id")
|
||||
.and_then(|i| i.as_str())
|
||||
.map(std::string::ToString::to_string);
|
||||
let name = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.map(std::string::ToString::to_string);
|
||||
let args_delta = tc
|
||||
.get("function")
|
||||
.and_then(|f| f.get("arguments"))
|
||||
.and_then(|a| a.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
d_events.push(StreamEvent::ToolCallDelta {
|
||||
index,
|
||||
id,
|
||||
name,
|
||||
arguments_delta: args_delta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Finish reason
|
||||
if let Some(reason) =
|
||||
choice.get("finish_reason").and_then(|r| r.as_str())
|
||||
{
|
||||
if reason == "stop" || reason == "tool_calls" {
|
||||
d_events.push(StreamEvent::Done);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(content) = delta.get("content").and_then(|c| c.as_str()) {
|
||||
d_events.push(StreamEvent::Token(content.to_string()));
|
||||
}
|
||||
}
|
||||
d_events
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
events.append(&mut other_events);
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SseParser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Filesystem layout for zesdex's persistent and scratch data directories.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! [`Store::new`] resolves all paths from OS data dir / temp dir →
|
||||
//! [`ensure_dirs`](Store::ensure_dirs) creates them on startup.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `Store` — resolved path bundle (base, memory, scratch, images, downloads)
|
||||
//! - `new` — path computation (no I/O)
|
||||
//! - `ensure_dirs` — creates all directories if missing
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tracing;
|
||||
|
||||
/// Resolved paths for all data directories zesdex reads from and writes to.
|
||||
///
|
||||
/// Why: centralizing path computation here means every consumer agrees on
|
||||
/// where memory, scratch, session images, and downloads live.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Store {
|
||||
/// Top-level data directory, e.g. `~/.local/share/zesdex`.
|
||||
pub base_dir: PathBuf,
|
||||
/// Temporary scratch root, usually under the OS temp dir.
|
||||
pub scratch_root: PathBuf,
|
||||
/// Directory for persistent memory files (`.md` summaries).
|
||||
pub memory_dir: PathBuf,
|
||||
/// Directory for per-session image snapshots.
|
||||
pub session_images_dir: PathBuf,
|
||||
/// Directory for downloaded files.
|
||||
pub download_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Compute the standard set of zesdex data directory paths.
|
||||
///
|
||||
/// Flow: OS data dir (or `.local/share` fallback) + "zesdex" → base dir;
|
||||
/// scratch root comes from the OS temp dir instead, since it's disposable.
|
||||
///
|
||||
/// Why: paths are computed, not created — call `ensure_dirs` before use.
|
||||
pub fn new() -> Self {
|
||||
let base = if let Some(data_dir) = std::env::var("XDG_DATA_HOME").ok()
|
||||
.or_else(|| std::env::var("HOME").ok().map(|h| format!("{h}/.local/share")))
|
||||
{
|
||||
PathBuf::from(data_dir).join("zesdex")
|
||||
} else {
|
||||
PathBuf::from(".local/share/zesdex")
|
||||
};
|
||||
let scratch = std::env::temp_dir().join("zesdex-scratch");
|
||||
Store {
|
||||
memory_dir: base.join("memory"),
|
||||
scratch_root: scratch,
|
||||
session_images_dir: base.join("session-images"),
|
||||
download_dir: base.join("downloads"),
|
||||
base_dir: base,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create all store directories (base, memory, scratch, session images,
|
||||
/// downloads) if missing.
|
||||
///
|
||||
/// Return: `Err` on the first directory that fails to create.
|
||||
pub fn ensure_dirs(&self) -> std::io::Result<()> {
|
||||
tracing::debug!(base = %self.base_dir.display(), "ensuring store directories exist");
|
||||
std::fs::create_dir_all(&self.base_dir)?;
|
||||
std::fs::create_dir_all(&self.memory_dir)?;
|
||||
std::fs::create_dir_all(&self.scratch_root)?;
|
||||
std::fs::create_dir_all(&self.session_images_dir)?;
|
||||
std::fs::create_dir_all(&self.download_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Store {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Tool-call DTOs embedded in assistant chat messages.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Provider response/stream carries `tool_calls` on an assistant message →
|
||||
//! deserialized into [`ToolCall`]/[`ToolFunction`] → harness resolves the
|
||||
//! function name against `all_tools()` and runs it after sanitizing arguments
|
||||
//! via [`sanitize_tool_arguments`] (which handles string-encoded JSON,
|
||||
//! control characters, and truncation).
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `ToolCall` — a single tool-invocation request (id + type + function)
|
||||
//! - `ToolFunction` — function name + raw arguments Value
|
||||
//! - `sanitize_tool_arguments` — normalizes argument shape, repairs truncation
|
||||
//! - `repair_json` — closes unclosed strings/braces/brackets in truncated JSON
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use tracing;
|
||||
|
||||
/// A single tool-call request emitted by the model in an assistant message.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCall {
|
||||
/// Unique identifier for this tool call (referenced by `ToolCallResult`).
|
||||
pub id: String,
|
||||
/// Discriminator, e.g. `"function"`.
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
/// The function to invoke (name + arguments).
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
/// The function name and raw arguments payload for a `ToolCall`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolFunction {
|
||||
/// The function/tool name to dispatch against.
|
||||
pub name: String,
|
||||
/// Arguments as a JSON Value (may be a string-encoded object before
|
||||
/// `sanitize_tool_arguments` normalises it).
|
||||
pub arguments: Value,
|
||||
}
|
||||
|
||||
/// Normalize tool-call arguments into a JSON object/value.
|
||||
///
|
||||
/// Flow: some providers send `arguments` as a JSON-encoded string rather
|
||||
/// than a nested object; if `args` is a string, attempt to parse it as
|
||||
/// JSON. Objects and other value types pass through unchanged.
|
||||
///
|
||||
/// Security: on parse failure we wrap the raw string in `{ "_raw": "..." }`
|
||||
/// instead of passing it through as a raw string, so tools that expect a
|
||||
/// JSON object (via `args.get("key")`) get `None` rather than unexpectedly
|
||||
/// receiving a plain string value.
|
||||
///
|
||||
/// Attempt to fix truncated JSON by closing open strings, braces and brackets.
|
||||
pub fn sanitize_tool_arguments(args: &Value) -> Value {
|
||||
match args {
|
||||
Value::String(s) => {
|
||||
// Attempt 1: direct parse.
|
||||
if let Ok(v) = serde_json::from_str::<Value>(s) {
|
||||
return v;
|
||||
}
|
||||
// Attempt 2: strip control chars (0x00-0x1F except \t, \n)
|
||||
let cleaned: String = s
|
||||
.chars()
|
||||
.filter(|&c| !c.is_control() || c == '\t' || c == '\n' || c == '\r')
|
||||
.collect();
|
||||
if cleaned.len() != s.len() {
|
||||
if let Ok(v) = serde_json::from_str::<Value>(&cleaned) {
|
||||
tracing::warn!(
|
||||
"tool argument contained control characters — stripped \
|
||||
and reparsed successfully",
|
||||
);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
// Attempt 3: repair truncated JSON and retry.
|
||||
let input = if cleaned.len() == s.len() {
|
||||
s
|
||||
} else {
|
||||
&cleaned
|
||||
};
|
||||
let repaired = repair_json(input);
|
||||
match serde_json::from_str::<Value>(&repaired) {
|
||||
Ok(v) => {
|
||||
tracing::warn!("tool argument string was truncated — repaired successfully",);
|
||||
v
|
||||
}
|
||||
Err(e2) => {
|
||||
tracing::error!(
|
||||
"tool argument is a JSON string but failed to parse. \
|
||||
Wrapping in object. Error: {}. Raw (first 200): {}",
|
||||
e2,
|
||||
s.chars().take(200).collect::<String>(),
|
||||
);
|
||||
serde_json::json!({"_raw": s, "_parse_error": e2.to_string()})
|
||||
}
|
||||
}
|
||||
}
|
||||
obj @ Value::Object(_) => obj.clone(),
|
||||
other => other.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair truncated JSON by closing open strings, braces and brackets.
|
||||
///
|
||||
/// Flow: single-pass character scan tracking string/escape state with a
|
||||
/// LIFO stack for `{`/`[` → append missing `"`, `]`, `}` in the right
|
||||
/// (reverse nesting) order.
|
||||
pub fn repair_json(s: &str) -> String {
|
||||
let mut stack: Vec<char> = Vec::new();
|
||||
let mut in_string = false;
|
||||
let mut prev_was_backslash = false;
|
||||
let mut ends_with_unclosed_escape = false;
|
||||
|
||||
for c in s.chars() {
|
||||
if prev_was_backslash {
|
||||
prev_was_backslash = false;
|
||||
ends_with_unclosed_escape = false;
|
||||
continue;
|
||||
}
|
||||
if c == '\\' && in_string {
|
||||
prev_was_backslash = true;
|
||||
ends_with_unclosed_escape = true;
|
||||
continue;
|
||||
}
|
||||
ends_with_unclosed_escape = false;
|
||||
if c == '"' {
|
||||
in_string = !in_string;
|
||||
continue;
|
||||
}
|
||||
if in_string {
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'{' | '[' => stack.push(c),
|
||||
'}' | ']' => {
|
||||
stack.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut result = s.to_string();
|
||||
if ends_with_unclosed_escape {
|
||||
result.pop();
|
||||
}
|
||||
if in_string {
|
||||
result.push('"');
|
||||
}
|
||||
for &opener in stack.iter().rev() {
|
||||
match opener {
|
||||
'{' => result.push('}'),
|
||||
'[' => result.push(']'),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Record of one completed tool invocation, kept for transcript/history.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Tool harness completes execution → creates [`ToolCallResult`] with output,
|
||||
//! error flag, and wall-clock duration → appended to conversation history as
|
||||
//! a `Tool`-role [`ChatMessage`](super::message::ChatMessage).
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `ToolCallResult` — tool name + output + error flag + duration
|
||||
//! - `new` — convenience constructor
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Record of a completed tool invocation, kept for transcript/history.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolCallResult {
|
||||
/// The `id` of the `ToolCall` this result responds to.
|
||||
pub tool_call_id: String,
|
||||
/// The name of the tool that was invoked.
|
||||
pub tool_name: String,
|
||||
/// The text output produced by the tool (or error message).
|
||||
pub output: String,
|
||||
/// Whether the tool exited with an error.
|
||||
pub is_error: bool,
|
||||
/// Wall-clock execution duration in milliseconds.
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
impl ToolCallResult {
|
||||
/// Create a new tool call result.
|
||||
pub fn new(
|
||||
tool_call_id: String,
|
||||
tool_name: String,
|
||||
output: String,
|
||||
is_error: bool,
|
||||
duration_ms: u64,
|
||||
) -> Self {
|
||||
ToolCallResult {
|
||||
tool_call_id,
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
//! Token usage accounting shared by streaming and non-streaming responses.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Accumulated across all LLM API calls in a session. Each response updates
|
||||
//! the running totals; `last_*` fields capture the most recent call's values
|
||||
//! for interpolation display. Persisted alongside other session metadata.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `UsageStats` — cumulative token/latency counters
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cumulative token/latency counters for a session, persisted alongside it.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||
pub struct UsageStats {
|
||||
/// Total tokens consumed as input (prompt).
|
||||
pub tokens_in: u64,
|
||||
/// Total tokens generated as output (completion).
|
||||
pub tokens_out: u64,
|
||||
/// Most recent call's input tokens (for live interpolation display).
|
||||
#[serde(default)]
|
||||
pub last_tokens_in: u64,
|
||||
/// Most recent call's output tokens (for live interpolation display).
|
||||
#[serde(default)]
|
||||
pub last_tokens_out: u64,
|
||||
/// Total number of LLM API calls made this session.
|
||||
pub api_calls: u64,
|
||||
/// Tokens consumed by auto-review subagent calls.
|
||||
pub review_tokens: u64,
|
||||
/// Total wall-clock time spent on LLM API calls (milliseconds).
|
||||
pub total_ms: u64,
|
||||
}
|
||||
|
||||
impl UsageStats {
|
||||
/// Create a new `UsageStats` with all counters zeroed.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Shared domain error types for the entire domain layer.
|
||||
//!
|
||||
//! Provides [`DomainError`] — a unified repository-level error enum used
|
||||
//! by both the `auth` and `cms` modules (type-aliased as `RepositoryError`
|
||||
//! in each module). This avoids a dependency on `thiserror` while still
|
||||
//! giving callers distinct error variants to match on.
|
||||
//!
|
||||
//! # Flow
|
||||
//!
|
||||
//! Infrastructure adapters convert their native errors (I/O, serde, etc.)
|
||||
//! into `DomainError` via `From` impls. Domain service layers wrap
|
||||
//! `DomainError` in their own `ServiceError` enum via `From`.
|
||||
//!
|
||||
//! # Components
|
||||
//!
|
||||
//! - `DomainError` — 6 variants: `NotFound`, `Conflict`, `Io`, `Serde`,
|
||||
//! `InvalidId`, `Other`
|
||||
//! - `From<std::io::Error>` — converts I/O errors
|
||||
//! - `From<serde_json::Error>` — converts serialisation errors
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Unified repository-level error for domain operations.
|
||||
///
|
||||
/// Covers the common failure modes across all persistence adapters:
|
||||
/// missing entities, conflicts, I/O failures, serialization errors,
|
||||
/// invalid identifiers, and a catch-all `Other` variant.
|
||||
#[derive(Debug)]
|
||||
pub enum DomainError {
|
||||
/// The requested entity was not found.
|
||||
NotFound(String),
|
||||
/// An operation failed due to a conflict (e.g. duplicate key).
|
||||
Conflict(String),
|
||||
/// An I/O error occurred during persistence.
|
||||
Io(std::io::Error),
|
||||
/// A serialization / deserialization error occurred.
|
||||
Serde(String),
|
||||
/// An identifier was rejected as invalid (e.g. path traversal).
|
||||
InvalidId(String),
|
||||
/// A generic / uncategorised error.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for DomainError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
DomainError::NotFound(msg) => write!(f, "not found: {msg}"),
|
||||
DomainError::Conflict(msg) => write!(f, "conflict: {msg}"),
|
||||
DomainError::Io(err) => write!(f, "I/O error: {err}"),
|
||||
DomainError::Serde(msg) => write!(f, "serialization error: {msg}"),
|
||||
DomainError::InvalidId(msg) => write!(f, "invalid id: {msg}"),
|
||||
DomainError::Other(msg) => write!(f, "{msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DomainError {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
DomainError::Io(err) => Some(err),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for DomainError {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
DomainError::Io(err)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for DomainError {
|
||||
fn from(err: serde_json::Error) -> Self {
|
||||
DomainError::Serde(err.to_string())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! # Zesdex Domain Layer
|
||||
//!
|
||||
//! Pure domain entities, value objects, repository traits, and service traits
|
||||
//! for the Zesdex application. This crate has **zero framework dependencies**
|
||||
//! — it depends only on serialization (`serde`), timestamping (`chrono`),
|
||||
//! identity (`uuid`), and a few other narrowly-scoped utilities.
|
||||
//!
|
||||
//! ## Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! apps/domain
|
||||
//! ├── core/ Shared domain entities (Conversation, Message, Provider,
|
||||
//! │ Store, ToolCall, ToolResult, Usage)
|
||||
//! ├── auth/ Authentication domain (Session, SessionId, SessionLock,
|
||||
//! │ OAuth, commands, errors, repository/service traits)
|
||||
//! ├── cms/ CMS domain (AppConfig, Conversation, EditLog, Memory,
|
||||
//! │ Settings, commands, errors, repository/service traits)
|
||||
//! └── error.rs Unified DomainError type
|
||||
//! ```
|
||||
//!
|
||||
//! ## Key Design Principle
|
||||
//!
|
||||
//! All types are pure Rust structs and enums with `serde` derives. No I/O,
|
||||
//! no framework imports, no side effects. All persistence is expressed
|
||||
//! through repository traits that infrastructure adapters implement.
|
||||
|
||||
pub mod auth;
|
||||
pub mod cms;
|
||||
pub mod core;
|
||||
pub mod error;
|
||||
|
||||
// Re-export all public items from each module for ergonomic imports.
|
||||
// Consumers can do `use zesdex_domain::*` for common types.
|
||||
pub use auth::{
|
||||
IamSession, NewSession, OAuthConfig, OAuthToken, OAuthRepository, OAuthService,
|
||||
RepositoryError as AuthRepositoryError, ServiceError as AuthServiceError, Session,
|
||||
SessionId, SessionLock, SessionLockRepository, SessionRepository, SessionService,
|
||||
};
|
||||
pub use cms::{
|
||||
AppConfig, AppConfigRepository, Conversation as CmsConversation,
|
||||
ConversationRepository, ConversationService, EditLog, EditLogEntry,
|
||||
EditLogRepository, InternetMode, Memory, MemoryRepository, MemoryService,
|
||||
ModelRole, NewMemory, ProviderConfig, RepositoryError as CmsRepositoryError,
|
||||
ServiceError as CmsServiceError, Settings, SettingsFlags, SettingsPatch,
|
||||
SettingsRepository, SettingsService,
|
||||
};
|
||||
pub use core::{
|
||||
ChatMessage, ChatRequest, ChatResponse, Choice, Conversation, Delta, Role,
|
||||
SseParser, StreamEvent, StreamOptions, Store, TokenUsage, ToolCall,
|
||||
ToolCallResult, ToolDef, ToolFunction, ToolFunctionDef, UsageStats,
|
||||
};
|
||||
pub use error::DomainError;
|
||||
Reference in New Issue
Block a user