From 3f79b283e212aae7341834dda06c7b83d3c48f5a Mon Sep 17 00:00:00 2001 From: asepharyana Date: Thu, 16 Jul 2026 16:41:32 +0700 Subject: [PATCH] chore: hapus entitas settings/app_config/memory/edit_log lama di zesdex-entities yang sudah digantikan zesdex-cms Co-Authored-By: Claude Sonnet 5 --- .../src/seaorm/common/app_config.rs | 216 -------------- .../src/seaorm/common/edit_log.rs | 107 ------- .../src/seaorm/common/memory.rs | 282 ------------------ .../zesdex-entities/src/seaorm/common/mod.rs | 8 - .../src/seaorm/common/settings.rs | 136 --------- 5 files changed, 749 deletions(-) delete mode 100644 crates/zesdex-entities/src/seaorm/common/app_config.rs delete mode 100644 crates/zesdex-entities/src/seaorm/common/edit_log.rs delete mode 100644 crates/zesdex-entities/src/seaorm/common/memory.rs delete mode 100644 crates/zesdex-entities/src/seaorm/common/settings.rs diff --git a/crates/zesdex-entities/src/seaorm/common/app_config.rs b/crates/zesdex-entities/src/seaorm/common/app_config.rs deleted file mode 100644 index 64c5de1..0000000 --- a/crates/zesdex-entities/src/seaorm/common/app_config.rs +++ /dev/null @@ -1,216 +0,0 @@ -#![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, - pub model_roles: HashMap, - 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, - pub default_model: Option, - pub default_api_key: Option, -} - -/// 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, - pub context_window: Option, - pub temperature: Option, -} - -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, - #[serde(alias = "ANTHROPIC_API_KEY")] - anthropic_api_key: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct ClaudeSettings { - env: Option, -} - -/// Return a `ProviderConfig` for the Claude provider, checking both -/// `~/.claude/settings.json` and the process environment. -fn detect_claude_settings_provider() -> Option { - 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 `/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 `/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(()) - } -} diff --git a/crates/zesdex-entities/src/seaorm/common/edit_log.rs b/crates/zesdex-entities/src/seaorm/common/edit_log.rs deleted file mode 100644 index 9e5303f..0000000 --- a/crates/zesdex-entities/src/seaorm/common/edit_log.rs +++ /dev/null @@ -1,107 +0,0 @@ -#![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, - 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 { - 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 = Vec::new(); - for line in reader.lines() { - let Ok(line) = line else { continue }; - if let Ok(entry) = serde_json::from_str::(&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() - } -} diff --git a/crates/zesdex-entities/src/seaorm/common/memory.rs b/crates/zesdex-entities/src/seaorm/common/memory.rs deleted file mode 100644 index 404bc2f..0000000 --- a/crates/zesdex-entities/src/seaorm/common/memory.rs +++ /dev/null @@ -1,282 +0,0 @@ -#![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, - pub lifecycle: String, - pub scope: Option, - pub before_snippet: Option, - pub after_snippet: Option, - pub provenances: Vec, -} - -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 { - 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::>() - .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 { - 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 { - 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 = 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 { - 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 - }) -} diff --git a/crates/zesdex-entities/src/seaorm/common/mod.rs b/crates/zesdex-entities/src/seaorm/common/mod.rs index fc69d9d..3ed5c55 100644 --- a/crates/zesdex-entities/src/seaorm/common/mod.rs +++ b/crates/zesdex-entities/src/seaorm/common/mod.rs @@ -2,27 +2,19 @@ //! 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; diff --git a/crates/zesdex-entities/src/seaorm/common/settings.rs b/crates/zesdex-entities/src/seaorm/common/settings.rs deleted file mode 100644 index eca88d5..0000000 --- a/crates/zesdex-entities/src/seaorm/common/settings.rs +++ /dev/null @@ -1,136 +0,0 @@ -#![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, - pub max_tokens: Option, - pub temperature: Option, - pub review_max_lessons_per_run: usize, - pub adaptive_review_max_skip: u32, - pub verify_command: Option, - 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, - /// 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 `/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 `/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(()) - } -}