chore: hapus entitas settings/app_config/memory/edit_log lama di zesdex-entities yang sudah digantikan zesdex-cms
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9618ef413b
commit
3f79b283e2
@@ -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<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(())
|
||||
}
|
||||
}
|
||||
@@ -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<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()
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user