refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture

Transform the single binary crate into a 9-crate workspace monorepo:

- Root Cargo.toml as [workspace] manager with resolver = "2"
- zesdex-entities: Domain entity types (session, settings, store, message, etc.)
- zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard)
- zesdex-dto: Data Transfer Objects for LLM provider API communication
- zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol)
- zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure)
- zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure)
- zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting)
- zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2)
- zesdex-backend: Main binary entry point + seed/migrate binaries
- DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates
- Remove dead root src/ and src-misc/ directories

All crate re-exports maintain backward compatibility with original
crate::model::*, crate::dto::*, crate::ipc::* module paths.
Feature crates enforce strict layer separation: domain -> application
-> infrastructure with generic trait-based dependency injection.
This commit is contained in:
asepharyana
2026-07-17 09:08:41 +07:00
parent 86cc412395
commit be0a9582bb
248 changed files with 7901 additions and 1505 deletions
+10
View File
@@ -0,0 +1,10 @@
//! Domain entity types for the Zesdex application.
//!
//! This crate contains ALL domain entity types as pure data structures
//! with no business logic beyond constructor/accessor methods. It uses
//! SeaORM patterns but adapted for serde JSON + filesystem persistence.
pub mod seaorm;
pub use seaorm::auth::*;
pub use seaorm::common::*;
@@ -0,0 +1,10 @@
//! Authentication entities: session metadata, PID-file lock, and OAuth
//! 2.0 PKCE flow types.
pub mod oauth;
pub mod session;
pub mod session_lock;
pub use oauth::{OAuthConfig, OAuthManager, OAuthToken};
pub use session::Session;
pub use session_lock::SessionLock;
@@ -0,0 +1,408 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! OAuth 2.0 authorization-code + PKCE flow: token exchange, authorization
//! URL building, and the PKCE verifier/challenge pair.
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
const VERIFIER_LENGTH: usize = 64;
// ---------------------------------------------------------------------------
// PKCE primitives
// ---------------------------------------------------------------------------
/// A randomly generated, base64url-encoded PKCE code verifier.
pub struct CodeVerifier(String);
impl CodeVerifier {
/// Generate a fresh random code verifier.
pub fn new() -> Self {
let bytes: Vec<u8> = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect();
CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes))
}
/// Borrow the verifier as a string, to send in the token exchange request.
pub fn as_str(&self) -> &str {
&self.0
}
/// Derive the S256 code challenge (SHA-256 hash, base64url-encoded) to send
/// in the authorization request.
pub fn challenge(&self) -> CodeChallenge {
let mut hasher = Sha256::new();
hasher.update(self.0.as_bytes());
let digest = hasher.finalize();
CodeChallenge(URL_SAFE_NO_PAD.encode(digest))
}
}
impl Default for CodeVerifier {
fn default() -> Self {
Self::new()
}
}
/// The S256-derived code challenge sent in the authorization request URL.
pub struct CodeChallenge(String);
impl CodeChallenge {
/// Borrow the challenge as a string.
pub fn as_str(&self) -> &str {
&self.0
}
}
/// Produce one pseudo-random byte from the system clock mixed with a monotonic
/// counter, providing ~64 bits of per-call unpredictability without a `rand`
/// dependency.
///
/// Why: avoids pulling in a `rand` dependency for a short-lived verifier; the
/// monotonic counter ensures that calls within the same clock tick produce
/// different values, which is sufficient to prevent OAuth code interception.
fn rand_byte() -> u8 {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
let seed = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64;
((seed ^ counter) & 0xFF) as u8
}
// ---------------------------------------------------------------------------
// OAuth token / config / manager
// ---------------------------------------------------------------------------
/// An OAuth access token plus its refresh token and absolute expiry (unix seconds).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthToken {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_at: u64,
pub token_type: String,
}
/// Static configuration for an OAuth provider: endpoints, client identity, and
/// requested scopes.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig {
pub auth_url: String,
pub token_url: String,
pub client_id: String,
pub client_secret: Option<String>,
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(),
],
}
}
}
/// Drives one OAuth flow: holds config, the current token (if any), and an
/// HTTP client.
pub struct OAuthManager {
pub config: OAuthConfig,
pub token: Option<OAuthToken>,
client: reqwest::blocking::Client,
}
impl OAuthManager {
/// Create a manager for the given provider config with no token yet acquired.
pub fn new(config: OAuthConfig) -> Self {
OAuthManager {
config,
token: None,
client: reqwest::blocking::Client::new(),
}
}
/// Exchange an authorization code for an access token via the provider's
/// token endpoint.
///
/// Flow: POST form-encoded grant to `token_url` → parse JSON body →
/// compute absolute `expires_at` from `expires_in` → store on `self.token`.
///
/// Return: `Err(String)` on network failure, non-2xx status, or a missing
/// `access_token` field.
pub fn exchange_code(
&mut self,
code: &str,
redirect_uri: &str,
code_verifier: &str,
) -> Result<(), String> {
let mut params = std::collections::HashMap::new();
params.insert("grant_type", "authorization_code");
params.insert("code", code);
params.insert("redirect_uri", redirect_uri);
params.insert("client_id", &self.config.client_id);
params.insert("code_verifier", code_verifier);
let resp = self
.client
.post(&self.config.token_url)
.form(&params)
.send()
.map_err(|e| format!("token request failed: {e}"))?;
let status = resp.status();
let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {e}"))?;
if !status.is_success() {
return Err(format!("token endpoint returned {status}: {body}"));
}
let access_token = body["access_token"]
.as_str()
.ok_or("missing access_token")?
.to_string();
let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
self.token = Some(OAuthToken {
access_token,
refresh_token: body["refresh_token"]
.as_str()
.map(std::string::ToString::to_string),
expires_at: now + expires_in,
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
});
Ok(())
}
/// Build the provider's authorization URL with PKCE and state params attached.
///
/// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously
/// this silently fell back to <https://example.com>, which produced a
/// valid-looking auth URL pointing at the wrong server and leaked client
/// credentials in query params. Returning an empty string signals failure
/// to callers, who can prompt the user to fix the OAuth config instead of
/// starting a flow against a wrong host.
///
/// Return: the full authorization URL, or `""` if `auth_url` is
/// empty/unparseable.
pub fn build_auth_url(
&self,
redirect_uri: &str,
state: &str,
code_challenge: &str,
) -> String {
let mut url = match url::Url::parse(&self.config.auth_url) {
Ok(u) if !self.config.auth_url.is_empty() => u,
_ => {
tracing::warn!(
"warning: OAuth auth_url is missing or invalid ('{}'); aborting build_auth_url",
self.config.auth_url
);
return String::new();
}
};
url.query_pairs_mut()
.append_pair("response_type", "code")
.append_pair("client_id", &self.config.client_id)
.append_pair("redirect_uri", redirect_uri)
.append_pair("scope", &self.config.scopes.join(" "))
.append_pair("state", state)
.append_pair("code_challenge_method", "S256")
.append_pair("code_challenge", code_challenge);
url.to_string()
}
/// Persist the current token to a JSON file at the given path.
///
/// Flow: serialise `self.token` to pretty JSON → write to temp file →
/// fsync → rename → fsync parent directory.
pub fn save_token(&self, path: &std::path::Path) -> std::io::Result<()> {
if let Some(token) = &self.token {
let data = serde_json::to_string_pretty(token)?;
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
if let Some(parent) = path.parent() {
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
}
}
Ok(())
}
/// Load a token from a JSON file at the given path, replacing the
/// in-memory token.
///
/// Return: `Ok(())` on success, or an `io::Error` if the file is missing
/// or malformed.
pub fn load_token(&mut self, path: &std::path::Path) -> std::io::Result<()> {
let data = std::fs::read_to_string(path)?;
let token: OAuthToken = serde_json::from_str(&data)?;
self.token = Some(token);
Ok(())
}
}
// ---------------------------------------------------------------------------
// Loopback server for capturing the OAuth authorization-code redirect
// ---------------------------------------------------------------------------
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth
/// `?code=...` redirect and serves back a static confirmation page.
pub struct LoopbackServer {
listener: TcpListener,
port: u16,
}
impl LoopbackServer {
/// Bind to an OS-assigned free port on localhost.
///
/// Return: `Err` if the loopback interface can't be bound.
pub fn bind() -> std::io::Result<Self> {
let listener = TcpListener::bind("127.0.0.1:0")?;
let port = listener.local_addr()?.port();
Ok(LoopbackServer { listener, port })
}
/// The redirect URI to hand to the OAuth authorization endpoint.
pub fn redirect_uri(&self) -> String {
format!("http://127.0.0.1:{}/callback", self.port)
}
/// Block until one HTTP request arrives, then extract the `code` query
/// param and validate that the `state` param matches the expected value.
///
/// Flow: accept one connection → apply read timeout → parse request line
/// → verify state matches → respond 200/400 depending on whether the code
/// was found and state matched.
///
/// Return: `Err(InvalidData)` if no `code` param is present or the state
/// doesn't match `expected_state`.
pub fn wait_for_code(
&self,
timeout_ms: u64,
expected_state: &str,
) -> std::io::Result<String> {
let (mut stream, _) = self.listener.accept()?;
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
Self::read_callback(&mut stream, expected_state)
}
/// Read and parse a single HTTP callback request off `stream`, replying
/// with a status page.
///
/// Why: writes the HTTP response before returning so the browser tab
/// shows a result regardless of whether the code was found.
fn read_callback(
stream: &mut TcpStream,
expected_state: &str,
) -> std::io::Result<String> {
let mut buf = [0u8; 4096];
let n = stream.read(&mut buf)?;
let request = String::from_utf8_lossy(&buf[..n]);
let code = Self::extract_code(&request);
let state = Self::extract_state(&request);
let state_ok = state.as_deref() == Some(expected_state);
let response = match (code.as_ref(), state_ok) {
(Some(_), true) => {
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n\
Authorization complete. You may close this tab."
}
(Some(_), false) => {
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
State mismatch — possible CSRF attack."
}
(None, _) => {
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
Missing authorization code."
}
};
let _ = stream.write_all(response.as_bytes());
let _ = stream.flush();
if !state_ok {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"state mismatch",
));
}
code.ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidData,
"code not found in callback",
)
})
}
/// Extract and percent-decode the `code` query parameter from an HTTP
/// request line.
fn extract_code(request: &str) -> Option<String> {
let line = request.lines().next()?;
let path = line.split(' ').nth(1)?;
let query = path.split('?').nth(1)?;
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
if parts.next()? == "code" {
return parts.next().map(urlencoding);
}
}
None
}
/// Extract the `state` query parameter from an HTTP request line.
fn extract_state(request: &str) -> Option<String> {
let line = request.lines().next()?;
let path = line.split(' ').nth(1)?;
let query = path.split('?').nth(1)?;
for pair in query.split('&') {
let mut parts = pair.splitn(2, '=');
if parts.next()? == "state" {
return parts.next().map(urlencoding);
}
}
None
}
}
/// Percent-decode a string (e.g. `%20` -> space).
fn urlencoding(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '%' {
match (
chars.next().and_then(|c| c.to_digit(16)),
chars.next().and_then(|c| c.to_digit(16)),
) {
(Some(hi), Some(lo)) => result.push(char::from((hi * 16 + lo) as u8)),
_ => {
result.push('%');
}
}
} else {
result.push(c);
}
}
result
}
@@ -0,0 +1,128 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Session metadata: id, title, workspace roots, and message/token counts,
//! persisted as `session.json` per session directory.
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 {
pub id: String,
pub created_at: i64,
pub updated_at: i64,
pub title: String,
pub model: String,
pub workspace_roots: Vec<PathBuf>,
pub message_count: u32,
pub token_count: u32,
pub archived: bool,
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")
}
/// Persist this session's metadata to `session.json`, atomically
/// with fsync for crash safety.
///
/// Flow: ensure the session directory exists → serialize to pretty
/// JSON → write to `session.json.tmp` → fsync → rename over
/// `session.json` → fsync parent directory.
///
/// Why: write-then-rename avoids a torn/partial `session.json` if
/// interrupted mid-write; fsync before rename ensures the data is
/// on disk before the rename makes it visible.
///
/// Return: `Ok(())` on success, or an `io::Error` from any step.
pub fn save(&self, base_dir: &Path) -> std::io::Result<()> {
let dir = self.session_dir(base_dir);
std::fs::create_dir_all(&dir)?;
let path = dir.join("session.json");
let data = serde_json::to_string_pretty(self)?;
let tmp = dir.join("session.json.tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all());
Ok(())
}
/// Load a session's metadata by id from `<base_dir>/sessions/<id>/session.json`.
///
/// Security: the session id is validated to prevent directory traversal
/// (e.g. `../../etc/passwd`). Only alphanumeric, hyphens, underscores,
/// and dots are allowed — no path separators.
///
/// Return: the parsed `Session`, or an `io::Error` if the file is
/// missing or malformed.
pub fn load(id: &str, base_dir: &Path) -> std::io::Result<Self> {
// Reject session ids that contain path separators or parent dir refs
if id.contains('/') || id.contains('\\') || id.contains("..") {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("invalid session id '{id}': must not contain path separators"),
));
}
let path = base_dir.join("sessions").join(id).join("session.json");
let data = std::fs::read_to_string(path)?;
let session: Session = serde_json::from_str(&data)?;
Ok(session)
}
/// List all loadable sessions under `<base_dir>/sessions/`.
///
/// Flow: read the sessions directory → keep subdirectories → attempt
/// `Session::load` for each by its directory name, discarding any
/// that fail to load.
///
/// Return: a `Vec<Session>`, empty if the directory can't be read or
/// contains no valid sessions.
pub fn list(base_dir: &Path) -> Vec<Self> {
let sessions_dir = base_dir.join("sessions");
let Ok(entries) = std::fs::read_dir(&sessions_dir) else {
return Vec::new();
};
entries
.filter_map(std::result::Result::ok)
.filter(|e| e.path().is_dir())
.filter_map(|e| {
let id = e.file_name().to_string_lossy().to_string();
Session::load(&id, base_dir).ok()
})
.collect()
}
}
@@ -0,0 +1,130 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! PID-file based advisory lock preventing two processes from operating on
//! the same session directory concurrently.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
/// auto-removed on drop.
#[derive(Debug)]
pub struct SessionLock {
path: PathBuf,
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 `is_alive`:
/// if that process is still running, fail to acquire; otherwise the
/// lock is stale — overwrite it with our own PID and succeed.
///
/// Why: `create_new(true)` is atomic on POSIX (unlike the previous
/// read-then-write pattern which had a TOCTOU race between checking
/// `path.exists()` and writing). The stale-lock recovery path reads
/// the stale PID and verifies liveness via `kill(pid, 0)`.
///
/// 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()?;
return Ok(true);
}
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
// 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) {
return Ok(false);
}
}
// 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 and
/// is actually a zesdex process (not a recycled PID from a different
/// program).
fn is_alive(pid: u32) -> bool {
// 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 already validated by `try_lock`.
if unsafe { libc::kill(pid as i32, 0) != 0 } {
return false;
}
// Extra check: verify the PID belongs to a zesdex process via
// /proc/<pid>/exe to mitigate the PID-reuse race (a recycled PID
// from a different program would answer kill but shouldn't hold
// our lock). This is best-effort — /proc may not be available
// on all platforms.
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
}
}
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,216 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Application-level configuration: LLM providers, model roles, and defaults,
//! persisted to `app_config.json` in the store directory.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Top-level application config: registered providers, named model roles,
/// and which provider/model to use by default.
#[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 (base URL, API key source).
#[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 role (e.g. "default") mapping to a specific provider/model and
/// its generation parameters.
#[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>,
}
impl Default for AppConfig {
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),
},
);
AppConfig {
providers,
model_roles,
default_provider: "zen".to_string(),
default_model: "deepseek-v4-flash-free".to_string(),
default_context_window: 256_000,
}
}
}
/// Configuration structure inside `~/.claude/settings.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeEnv {
#[serde(alias = "ANTHROPIC_BASE_URL")]
anthropic_base_url: Option<String>,
#[serde(alias = "ANTHROPIC_API_KEY")]
anthropic_api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeSettings {
env: Option<ClaudeEnv>,
}
/// Return a `ProviderConfig` for the Claude provider, checking both
/// `~/.claude/settings.json` and the process environment.
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
let (base_url, key) =
claude_credentials_from_file().or_else(claude_credentials_from_env)?;
Some(ProviderConfig {
api_base: base_url,
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
default_model: None,
default_api_key: Some(key),
})
}
/// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block.
fn claude_credentials_from_file() -> Option<(String, String)> {
let path = dirs::home_dir()?.join(".claude").join("settings.json");
let content = std::fs::read_to_string(&path).ok()?;
let settings: ClaudeSettings = serde_json::from_str(&content).ok()?;
let env = settings.env?;
let base_url = env.anthropic_base_url?;
let key = env.anthropic_api_key?;
Some((base_url, key))
}
/// Try to read Claude credentials from `ANTHROPIC_BASE_URL` /
/// `ANTHROPIC_API_KEY` environment variables.
fn claude_credentials_from_env() -> Option<(String, String)> {
let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?;
let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
Some((base_url, key))
}
impl AppConfig {
/// Load app config from disk, falling back to defaults on any failure.
///
/// Flow: read `<store>/app_config.json` → JSON-parse → on missing file
/// or parse error, use `Self::default()` → merge any default providers
/// not already present in the loaded config.
///
/// Why: the merge step lets newly-added default providers (e.g. a new
/// release adding a provider) appear even in configs saved by older
/// versions, without clobbering user-edited entries with the same name.
///
/// Return: a fully-populated `AppConfig`, never fails.
pub fn load() -> Self {
let store = super::store::Store::new();
let path = store.base_dir.join("app_config.json");
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
Ok(s) => match serde_json::from_str(&s) {
Ok(c) => c,
Err(e) => {
tracing::warn!(
"warning: failed to parse config file '{}': {}. Loading defaults.",
path.display(),
e
);
Self::default()
}
},
Err(_) => Self::default(),
};
// Merge any default providers not present in the loaded config
let defaults = Self::default();
for (name, provider) in defaults.providers {
cfg.providers.entry(name).or_insert(provider);
}
// Auto-detect provider from ~/.claude/settings.json
if let Some(claude_provider) = detect_claude_settings_provider() {
cfg.providers
.entry("claude".to_string())
.or_insert(claude_provider);
// Register known Claude models as named model roles
let claude_models = [
("claude-opus-4-8", "claude-opus-4-8"),
("claude-sonnet-5", "claude-sonnet-5"),
("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
];
for (role_name, model_name) in &claude_models {
cfg.model_roles
.entry(role_name.to_string())
.or_insert(ModelRole {
provider: "claude".to_string(),
model: model_name.to_string(),
max_tokens: Some(8192),
context_window: Some(200_000),
temperature: Some(0.7),
});
}
// Set as default provider only if user hasn't picked a custom default
if cfg.default_provider == defaults.default_provider {
cfg.default_provider = "claude".to_string();
cfg.default_model = "claude-opus-4-8".to_string();
}
}
cfg
}
/// Serialize and write app config to `<store_base_dir>/app_config.json`,
/// using write-then-rename with fsync for crash safety.
///
/// Flow: ensure base dir exists → pretty-print JSON → write to a temp
/// file → sync to disk → rename over the real path → sync the directory.
///
/// Return: `Err` if the directory can't be created or the write fails.
pub fn save(&self) -> std::io::Result<()> {
let store = super::store::Store::new();
std::fs::create_dir_all(&store.base_dir)?;
let path = store.base_dir.join("app_config.json");
let tmp = store.base_dir.join("app_config.json.tmp");
let s = serde_json::to_string_pretty(self)?;
std::fs::write(&tmp, s)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
let _ = std::fs::File::open(&store.base_dir).and_then(|d| d.sync_all());
Ok(())
}
}
@@ -0,0 +1,116 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! In-memory conversation state: message history plus the system prompt and
//! model parameters used to drive the LLM.
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 {
pub messages: Vec<ChatMessage>,
pub system_prompt: String,
pub session_id: String,
pub model: String,
pub max_tokens: Option<u32>,
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()
}
/// Persist the conversation to a JSON file at the given base directory.
///
/// Flow: compute path from `session_id` → ensure directory exists →
/// serialize to pretty JSON → write-then-rename with fsync.
///
/// Return: `Ok(())` on success, or an `io::Error` from any step.
pub fn save_conversation(&self, base_dir: &std::path::Path) -> std::io::Result<()> {
let dir = base_dir.join("sessions").join(&self.session_id);
std::fs::create_dir_all(&dir)?;
let path = dir.join("conversation.json");
let data = serde_json::to_string_pretty(self)?;
let tmp = dir.join("conversation.json.tmp");
std::fs::write(&tmp, data)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
let _ = std::fs::File::open(&dir).and_then(|d| d.sync_all());
Ok(())
}
/// Load a conversation from a JSON file for the given session id.
///
/// Flow: read `<base_dir>/sessions/<session_id>/conversation.json` →
/// JSON-parse.
///
/// Return: the parsed `Conversation`, or an `io::Error` if the file is
/// missing or malformed.
pub fn load_conversation(
session_id: &str,
base_dir: &std::path::Path,
) -> std::io::Result<Self> {
let path = base_dir
.join("sessions")
.join(session_id)
.join("conversation.json");
let data = std::fs::read_to_string(path)?;
let conv: Conversation = serde_json::from_str(&data)?;
Ok(conv)
}
}
@@ -0,0 +1,107 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Append-only JSONL edit log recording every file mutation made by tools,
//! for audit and undo/history purposes.
use serde::{Deserialize, Serialize};
/// A single recorded file edit: which tool made it, to which path, why,
/// and a content hash/size delta for verification.
#[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 sessions.
const MAX_MEMORY_ENTRIES: usize = 10_000;
/// In-memory view of a session's edit log, backed by `edits.jsonl` on disk.
#[derive(Debug, Clone)]
pub struct EditLog {
pub entries: Vec<EditLogEntry>,
pub path: std::path::PathBuf,
}
impl EditLog {
/// Open (or start tracking) the edit log for a session directory,
/// replaying any existing `edits.jsonl` into memory (capped at
/// `MAX_MEMORY_ENTRIES` to prevent OOM).
pub fn new(session_dir: &std::path::Path) -> Self {
let path = session_dir.join("edits.jsonl");
let entries = Self::load_from_disk(&path);
EditLog { entries, path }
}
/// Reads lines of edits.jsonl into memory, keeping only the most recent
/// `MAX_MEMORY_ENTRIES` entries. The full history is preserved on disk
/// regardless of the in-memory limit.
fn load_from_disk(path: &std::path::Path) -> Vec<EditLogEntry> {
use std::io::{BufRead, BufReader};
let Ok(file) = std::fs::File::open(path) else {
return Vec::new();
};
let reader = BufReader::new(file);
let mut entries: Vec<EditLogEntry> = Vec::new();
for line in reader.lines() {
let Ok(line) = line else { continue };
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
if entries.len() >= MAX_MEMORY_ENTRIES {
entries.remove(0);
}
entries.push(entry);
}
}
entries
}
/// Append one entry to `edits.jsonl` on disk and to the in-memory log,
/// with fsync for crash safety.
///
/// Flow: serialize `entry` to a JSON line → ensure parent dir exists →
/// open the file in append mode → write the line → fsync → push into
/// `self.entries`.
///
/// Why: appending (not rewriting) keeps the log durable and cheap even
/// as it grows across a long session; fsync ensures the entry survives
/// a crash rather than lingering in the page cache.
///
/// Return: `Ok(())` on success; an `io::Error` if serialization or
/// any filesystem operation fails.
pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> {
use std::io::Write;
let line = serde_json::to_string(&entry)? + "\n";
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)?;
file.write_all(line.as_bytes())?;
file.sync_all()?;
self.entries.push(entry);
Ok(())
}
/// Number of edit entries recorded so far in this log.
pub fn len(&self) -> usize {
self.entries.len()
}
/// Returns `true` if the edit log is empty.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
@@ -0,0 +1,282 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Long-term agent memory: markdown files with YAML-ish frontmatter storing
//! lessons/references, plus slugified filenames and export/import helpers.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// A single memory entry (lesson, reference, etc.) with frontmatter
/// metadata and free-form markdown content.
#[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 `-` by splitting on it and rejoining
/// non-empty parts.
///
/// Why: rejects empty or overly long (>80 char) results so callers
/// don't write memories with degenerate or unwieldy filenames.
///
/// Return: `Some(slug)` on success, `None` if the input slugifies to
/// empty or exceeds 80 characters.
pub fn slugify(s: &str) -> Option<String> {
let slug: String = s
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
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.
///
/// Why: falls back to a fixed `"memory"` slug when `name` slugifies
/// to nothing, so a path is always produced.
pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
slug_path(memory_dir, &format!("{slug}.md"))
}
/// Serialize this memory to markdown-with-frontmatter and write it
/// atomically to disk.
///
/// Flow: build the frontmatter block (name/description/kind/timestamps/
/// lifecycle/optional fields) → concatenate with body content → write
/// to a temp file → rename into place.
///
/// Why: write-then-rename avoids leaving a half-written memory file if
/// the process is interrupted mid-write.
///
/// Return: `Ok(())` on success, or an `io::Error` from directory
/// creation, the temp write, or the rename.
pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> {
let path = Self::path(memory_dir, &self.name);
let parent = path.parent().unwrap();
std::fs::create_dir_all(parent)?;
let outcome_line = self
.outcome
.as_ref()
.map(|o| format!("outcome: {o}"))
.unwrap_or_default();
let scope_line = self
.scope
.as_ref()
.map(|s| format!("scope: {s}"))
.unwrap_or_default();
let before_line = self
.before_snippet
.as_ref()
.map(|s| format!("before: {s}"))
.unwrap_or_default();
let after_line = self
.after_snippet
.as_ref()
.map(|s| format!("after: {s}"))
.unwrap_or_default();
let prov_line = if self.provenances.is_empty() {
String::new()
} else {
format!("provenances: {}", self.provenances.join(", "))
};
let content = format!(
"---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n{}\n{}\n{}\n{}\n---\n\n{}",
self.name,
self.description,
self.kind,
self.created_at,
self.updated_at,
self.lifecycle,
outcome_line,
scope_line,
before_line,
after_line,
prov_line,
self.content
);
let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
// Write to temp file with fsync for crash safety
{
use std::io::Write;
let mut f = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)?;
f.write_all(content.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path)?;
// Sync the parent directory so the rename is durable.
if let Some(p) = path.parent() {
let _ = std::fs::File::open(p).and_then(|d| d.sync_all());
}
Ok(())
}
/// Read and parse a memory file by name.
///
/// Return: the parsed `Memory`, or an `io::Error` if the file is
/// missing or its frontmatter is malformed (see `parse`).
pub fn read(memory_dir: &Path, name: &str) -> std::io::Result<Self> {
let path = Self::path(memory_dir, name);
let content = std::fs::read_to_string(&path)?;
Self::parse(&content)
}
/// Parse a memory file's contents (frontmatter + body) into a `Memory`.
///
/// Flow: strip leading `---\n` → split on the first `\n---\n` into
/// frontmatter and body → parse frontmatter lines as `key: value`
/// pairs into a map → build `Memory` fields from the map with
/// sensible defaults for missing keys.
///
/// Why: unknown/missing frontmatter keys degrade to defaults (e.g.
/// `kind` → "reference", `lifecycle` → "new") rather than failing,
/// so older or hand-edited memory files still parse.
///
/// Return: `Err(InvalidData)` only if the `---` frontmatter delimiter
/// itself is missing; otherwise `Ok(Memory)`.
pub fn parse(content: &str) -> std::io::Result<Self> {
let content = content.strip_prefix("---\n").unwrap_or(content);
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
if parts.len() < 2 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"missing frontmatter",
));
}
let front: std::collections::HashMap<String, String> = parts[0]
.lines()
.filter_map(|l| {
let mut it = l.splitn(2, ':');
Some((
it.next()?.trim().to_string(),
it.next()?.trim().to_string(),
))
})
.collect();
let body = parts.get(1).unwrap_or(&"").trim().to_string();
Ok(Memory {
name: front.get("name").cloned().unwrap_or_default(),
description: front.get("description").cloned().unwrap_or_default(),
content: body,
kind: front
.get("kind")
.cloned()
.unwrap_or_else(|| "reference".to_string()),
created_at: front
.get("created_at")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
updated_at: front
.get("updated_at")
.and_then(|v| v.parse().ok())
.unwrap_or(0),
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
lifecycle: front
.get("lifecycle")
.cloned()
.unwrap_or_else(|| "new".to_string()),
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
provenances: front
.get("provenances")
.cloned()
.map(|s| {
s.split(", ")
.map(std::string::ToString::to_string)
.collect()
})
.unwrap_or_default(),
})
}
/// Delete a memory file by name, if it exists.
///
/// Return: `Ok(())` whether or not the file existed.
pub fn remove(memory_dir: &Path, name: &str) -> std::io::Result<()> {
let path = Self::path(memory_dir, name);
if path.exists() {
std::fs::remove_file(path)?;
}
Ok(())
}
/// List the slugs of all memory files in a directory.
///
/// Flow: read the directory → keep entries ending in `.md` → exclude
/// the special `MEMORY.md` summary file → strip the `.md` suffix.
///
/// Return: slugs (without extension); empty `Vec` if the directory
/// can't be read.
pub fn list(memory_dir: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(memory_dir) else {
return Vec::new();
};
entries
.filter_map(std::result::Result::ok)
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
.filter_map(|e| {
let name = e.file_name().to_string_lossy().to_string();
if name == "MEMORY.md" {
return None;
}
let slug = name.strip_suffix(".md")?.to_string();
Some(slug)
})
.collect()
}
}
/// Sanitize a raw filename into a safe path under `memory_dir`.
///
/// Flow: replace any char that isn't alphanumeric, `.`, or `-` with `-` →
/// strip leading dots (prevents dotfiles / path traversal via `..`) →
/// join to `memory_dir`, falling back to `"memory.md"` if empty.
pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
let clean: String = raw
.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"
} else {
&clean
})
}
@@ -0,0 +1,111 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Chat message types shared across the entity layer: `Role` and `ChatMessage`
//! with convenience constructors.
use serde::{Deserialize, Serialize};
/// The conversation participant who authored a message.
#[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 {
pub role: Role,
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<super::tool_call::ToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
#[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,29 @@
//! Common entity types shared across the Zesdex application: application
//! configuration, settings, store paths, conversations, messages, tool
//! calls, usage stats, and SSE streaming types.
pub mod app_config;
pub mod conversation;
pub mod edit_log;
pub mod memory;
pub mod message;
pub mod provider;
pub mod settings;
pub mod store;
pub mod tool_call;
pub mod tool_result;
pub mod usage;
pub use app_config::{AppConfig, ModelRole, ProviderConfig};
pub use conversation::Conversation;
pub use edit_log::{EditLog, EditLogEntry};
pub use memory::Memory;
pub use message::{ChatMessage, Role};
pub use provider::{
ChatRequest, ChatResponse, Choice, SseParser, StreamEvent, StreamOptions, ToolDef, ToolFunctionDef,
};
pub use settings::{InternetMode, Settings, SettingsFlags};
pub use store::Store;
pub use tool_call::{ToolCall, ToolFunction};
pub use tool_result::ToolCallResult;
pub use usage::UsageStats;
@@ -0,0 +1,316 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Provider-facing DTOs: chat completion request, response, streaming types,
//! and the SSE stream parser.
use serde::{Deserialize, Serialize};
use serde_json::Value;
// ---------------------------------------------------------------------------
// Chat request / response
// ---------------------------------------------------------------------------
/// Outbound chat completion request body sent to an OpenAI/Anthropic-compatible
/// provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatRequest {
pub model: String,
pub messages: Vec<super::message::ChatMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tools: Option<Vec<ToolDef>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub top_p: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub stop: Option<Vec<String>>,
#[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 {
#[serde(rename = "type")]
pub type_: String,
pub function: ToolFunctionDef,
}
/// Name, description, and JSON schema parameters for a tool definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunctionDef {
pub name: String,
pub description: String,
pub parameters: Value,
}
/// Non-streaming chat completion response returned by the provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatResponse {
pub id: String,
pub model: String,
pub choices: Vec<Choice>,
pub usage: Option<Usage>,
pub created: Option<i64>,
}
/// One completion candidate within a `ChatResponse.choices` list.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Choice {
pub index: u32,
pub message: super::message::ChatMessage,
pub finish_reason: Option<String>,
}
/// Token counts and optional cost breakdown for a single completion request.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Usage {
pub prompt_tokens: Option<u32>,
pub completion_tokens: Option<u32>,
pub total_tokens: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_tokens_cost: Option<f64>,
#[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 {
Token(String),
Reasoning(String),
ToolCallDelta {
index: usize,
id: Option<String>,
name: Option<String>,
arguments_delta: String,
},
Usage {
prompt_tokens: u64,
completion_tokens: u64,
total_tokens: u64,
},
Done,
Error(String),
}
/// Buffered SSE frame parser that accumulates raw `data:` lines and
/// flushes a `StreamEvent` on each blank-line boundary.
pub struct SseParser {
buffer: String,
event_type: Option<String>,
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
}) as usize;
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,136 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! User-configurable settings persisted as JSON in the store's base directory.
//!
//! `Settings::load` / `Settings::save` are the only entry points; every field
//! falls back to a hardcoded default via `Default for Settings` when the file
//! is missing or fails to parse.
use serde::{Deserialize, Serialize};
/// Controls how much network access the agent is permitted during a session.
///
/// `Off` disables outbound requests entirely, `ReadOnly` allows fetches but
/// no mutating calls, `Full` permits everything. Defaults to `Off`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum InternetMode {
#[default]
Off,
ReadOnly,
Full,
}
/// Default per-node timeout for hive-mind nodes: 10 minutes.
fn default_hive_mind_node_timeout_ms() -> u64 {
600_000
}
/// Boolean flags grouped to keep the top-level struct below clippy's bool threshold.
#[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 {
fn default() -> Self {
Self {
review_enabled: true,
session_archive_enabled: true,
lsp_auto_provision: true,
}
}
}
/// Top-level application settings, serialized to `settings.json` in the store dir.
///
/// Why: a single flat struct rather than nested config so the JSON file stays
/// human-editable; unknown/missing fields on load fall back to `Default`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub internet_mode: InternetMode,
pub provider: String,
pub model: String,
pub api_keys: std::collections::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,
/// Boolean flags flattened into the top-level JSON so existing settings
/// files remain compatible when bools are grouped into a sub-struct.
#[serde(flatten)]
pub flags: SettingsFlags,
pub lsp_languages: Vec<String>,
/// Wall-clock deadline for a single hive-mind processing node (cycle
/// node or synthesis node). Prevents one stuck node from hanging an
/// entire hive-mind convergence forever.
#[serde(default = "default_hive_mind_node_timeout_ms")]
pub hive_mind_node_timeout_ms: u64,
}
impl Default for Settings {
fn default() -> Self {
Settings {
internet_mode: InternetMode::Off,
provider: "zen".to_string(),
model: "deepseek-v4-flash-free".to_string(),
api_keys: std::collections::HashMap::new(),
max_tokens: None,
temperature: None,
review_max_lessons_per_run: 5,
adaptive_review_max_skip: 3,
verify_command: None,
verify_timeout_ms: 30000,
workflow_max_concurrency: 5,
flags: SettingsFlags::default(),
lsp_languages: Vec::new(),
hive_mind_node_timeout_ms: default_hive_mind_node_timeout_ms(),
}
}
}
impl Settings {
/// Load settings from `<store_base_dir>/settings.json`.
///
/// Flow: read file → parse JSON → fall back to `Settings::default()` on
/// any failure (missing file, unreadable, malformed JSON).
///
/// Return: always succeeds; never surfaces I/O or parse errors to the caller.
pub fn load() -> Self {
let store = super::store::Store::new();
let path = store.base_dir.join("settings.json");
std::fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
/// Serialize and write settings to `<store_base_dir>/settings.json`,
/// using write-then-rename with fsync for crash safety.
///
/// Flow: ensure base dir exists → pretty-print JSON → write to a temp
/// file → sync to disk → rename over the real path → sync the directory.
///
/// Return: `Err` if the directory can't be created or the write fails.
pub fn save(&self) -> std::io::Result<()> {
let store = super::store::Store::new();
std::fs::create_dir_all(&store.base_dir)?;
let path = store.base_dir.join("settings.json");
let tmp = store.base_dir.join("settings.json.tmp");
let s = serde_json::to_string_pretty(self)?;
std::fs::write(&tmp, s)?;
let f = std::fs::File::open(&tmp)?;
f.sync_all()?;
std::fs::rename(&tmp, path)?;
let _ = std::fs::File::open(&store.base_dir).and_then(|d| d.sync_all());
Ok(())
}
}
@@ -0,0 +1,63 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Filesystem layout for zesdex's persistent and scratch data directories.
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// 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 {
pub base_dir: PathBuf,
pub scratch_root: PathBuf,
pub memory_dir: PathBuf,
pub session_images_dir: PathBuf,
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 = dirs::data_dir()
.unwrap_or_else(|| PathBuf::from(".local/share"))
.join("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<()> {
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,149 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! 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
//! `function.name` against `all_tools()` and runs it with
//! `sanitize_tool_arguments(function.arguments)`.
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// A single tool-call request emitted by the model in an assistant message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
#[serde(rename = "type")]
pub type_: String,
pub function: ToolFunction,
}
/// The function name and raw arguments payload for a `ToolCall`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunction {
pub name: String,
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,37 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Record of one completed tool invocation, kept for transcript/history.
use serde::{Deserialize, Serialize};
/// Record of a completed tool invocation, kept for transcript/history.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallResult {
pub tool_call_id: String,
pub tool_name: String,
pub output: String,
pub is_error: bool,
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,43 @@
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
//! Token usage accounting shared by streaming and non-streaming responses.
use serde::{Deserialize, Serialize};
/// Cumulative token/latency counters for a session, persisted alongside it.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct UsageStats {
pub tokens_in: u64,
pub tokens_out: u64,
#[serde(default)]
pub last_tokens_in: u64,
#[serde(default)]
pub last_tokens_out: u64,
pub api_calls: u64,
pub review_tokens: u64,
pub total_ms: u64,
}
impl Default for UsageStats {
fn default() -> Self {
Self {
tokens_in: 0,
tokens_out: 0,
last_tokens_in: 0,
last_tokens_out: 0,
api_calls: 0,
review_tokens: 0,
total_ms: 0,
}
}
}
impl UsageStats {
/// Create a new `UsageStats` with all counters zeroed.
pub fn new() -> Self {
Self::default()
}
}
+9
View File
@@ -0,0 +1,9 @@
//! SeaORM-style entity modules organised by domain concern.
//!
//! Each submodule contains pure data structures with serde serialisation
//! and filesystem persistence (serde JSON + std::fs), adapted for the
//! zesdex runtime which uses rusqlite + serde JSON rather than a full
//! ORM.
pub mod auth;
pub mod common;