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
|
//! configuration, settings, store paths, conversations, messages, tool
|
||||||
//! calls, usage stats, and SSE streaming types.
|
//! calls, usage stats, and SSE streaming types.
|
||||||
|
|
||||||
pub mod app_config;
|
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod edit_log;
|
|
||||||
pub mod memory;
|
|
||||||
pub mod message;
|
pub mod message;
|
||||||
pub mod provider;
|
pub mod provider;
|
||||||
pub mod settings;
|
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod tool_call;
|
pub mod tool_call;
|
||||||
pub mod tool_result;
|
pub mod tool_result;
|
||||||
pub mod usage;
|
pub mod usage;
|
||||||
|
|
||||||
pub use app_config::{AppConfig, ModelRole, ProviderConfig};
|
|
||||||
pub use conversation::Conversation;
|
pub use conversation::Conversation;
|
||||||
pub use edit_log::{EditLog, EditLogEntry};
|
|
||||||
pub use memory::Memory;
|
|
||||||
pub use message::{ChatMessage, Role};
|
pub use message::{ChatMessage, Role};
|
||||||
pub use provider::{
|
pub use provider::{
|
||||||
ChatRequest, ChatResponse, Choice, SseParser, StreamEvent, StreamOptions, ToolDef, ToolFunctionDef,
|
ChatRequest, ChatResponse, Choice, SseParser, StreamEvent, StreamOptions, ToolDef, ToolFunctionDef,
|
||||||
};
|
};
|
||||||
pub use settings::{InternetMode, Settings, SettingsFlags};
|
|
||||||
pub use store::Store;
|
pub use store::Store;
|
||||||
pub use tool_call::{ToolCall, ToolFunction};
|
pub use tool_call::{ToolCall, ToolFunction};
|
||||||
pub use tool_result::ToolCallResult;
|
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