Enhance tool documentation and add new features
- Added module-level documentation for memory tools (`remember`, `recall`, `forget`) to clarify their purpose. - Improved documentation in `recall.rs` and `remember.rs` to describe the functionality and flow of memory entry operations. - Updated `mod.rs` to include descriptions for the tool trait and execution context. - Enhanced `plan.rs` with detailed comments on plan-mode signaling tools. - Documented text search tools in `search.rs` to explain their functionality. - Improved sequential-thinking tool documentation in `seqthink.rs`. - Added safety filter documentation in `shell_filter` for credential and git operations. - Enhanced utility tools documentation, including `cd`, `dir_cache_update`, and `todowrite`. - Improved rendering documentation in view modules (`chat`, `markdown`, `status`, `workflow`) to clarify rendering flows and purposes.
This commit is contained in:
@@ -1,5 +1,17 @@
|
||||
//! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner).
|
||||
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
/// Build the fixed list of built-in agent definitions shipped with zesdex.
|
||||
///
|
||||
/// Flow: construct each `AgentDefinition` with a name, system prompt, and
|
||||
/// allowed tool list, then collect into a `Vec`.
|
||||
///
|
||||
/// Why: these agents are always available regardless of global/session
|
||||
/// config, giving users a baseline set of roles out of the box.
|
||||
///
|
||||
/// Return: a freshly-built `Vec<AgentDefinition>` (coder, reviewer,
|
||||
/// researcher, planner).
|
||||
pub fn builtin_agents() -> Vec<AgentDefinition> {
|
||||
vec![
|
||||
AgentDefinition::new(
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
//! Load, save, and remove user-defined agent definitions stored globally
|
||||
//! (under the store's `agents/` directory), independent of any session.
|
||||
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
/// Load all globally-registered agent definitions from disk.
|
||||
///
|
||||
/// Flow: resolve `<store>/agents/` → read directory → parse each `*.json`
|
||||
/// file into an `AgentDefinition`, skipping any that fail to read or parse.
|
||||
///
|
||||
/// Why: missing directory or unreadable/invalid files are silently
|
||||
/// skipped rather than failing the whole load, so one corrupt file
|
||||
/// doesn't break agent loading.
|
||||
///
|
||||
/// Return: a `Vec<AgentDefinition>`, empty if the directory doesn't exist
|
||||
/// or contains no valid definitions.
|
||||
pub fn load_global_agents() -> Vec<AgentDefinition> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
@@ -22,6 +36,16 @@ pub fn load_global_agents() -> Vec<AgentDefinition> {
|
||||
agents
|
||||
}
|
||||
|
||||
/// Persist a global agent definition as `<store>/agents/<name>.json`.
|
||||
///
|
||||
/// Flow: ensure the `agents/` directory exists → serialize `def` to
|
||||
/// pretty JSON → write to a file named after `def.name`.
|
||||
///
|
||||
/// Why: writing by name overwrites any existing definition with the
|
||||
/// same name, acting as an upsert.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an error if directory creation,
|
||||
/// serialization, or the write fails.
|
||||
pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let agents_dir = store.base_dir.join("agents");
|
||||
@@ -32,6 +56,14 @@ pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a global agent definition by name, if it exists.
|
||||
///
|
||||
/// Flow: resolve `<store>/agents/<name>.json` → remove the file if present.
|
||||
///
|
||||
/// Why: a no-op (not an error) when the file is already absent.
|
||||
///
|
||||
/// Return: `Ok(())` whether or not the file existed; `Err` only on an
|
||||
/// actual filesystem removal failure.
|
||||
pub fn remove_global_agent(name: &str) -> anyhow::Result<()> {
|
||||
let store = crate::model::store::Store::new();
|
||||
let path = store.base_dir.join("agents").join(format!("{}.json", name));
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Agent definition sources: built-in defaults, global (user-wide), and
|
||||
//! per-session overrides.
|
||||
|
||||
pub mod builtin;
|
||||
pub mod global;
|
||||
pub mod session;
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
//! Load, save, add, and remove agent definitions scoped to a single
|
||||
//! session (`<session_dir>/agents.json`).
|
||||
|
||||
use std::path::Path;
|
||||
use crate::app::subagent::spawn::AgentDefinition;
|
||||
|
||||
/// Load agent definitions saved for a specific session.
|
||||
///
|
||||
/// Flow: check `<session_dir>/agents.json` exists → read → JSON-decode
|
||||
/// into `Vec<AgentDefinition>`.
|
||||
///
|
||||
/// Why: a missing file or a parse failure both degrade gracefully to an
|
||||
/// empty list (parse errors are logged via `tracing::warn!`), so a
|
||||
/// corrupt session file doesn't crash agent loading.
|
||||
///
|
||||
/// Return: the session's agent definitions, or an empty `Vec` if none
|
||||
/// exist or the file is malformed.
|
||||
pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
if !agents_file.exists() {
|
||||
@@ -17,6 +31,13 @@ pub fn load_session_agents(session_dir: &Path) -> Vec<AgentDefinition> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Overwrite `<session_dir>/agents.json` with the given agent list.
|
||||
///
|
||||
/// Flow: serialize `agents` to pretty JSON → write to
|
||||
/// `<session_dir>/agents.json`.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an error if serialization or the
|
||||
/// write fails.
|
||||
pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> anyhow::Result<()> {
|
||||
let agents_file = session_dir.join("agents.json");
|
||||
let content = serde_json::to_string_pretty(agents)?;
|
||||
@@ -24,6 +45,14 @@ pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> an
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add or replace a session agent definition by name.
|
||||
///
|
||||
/// Flow: load existing session agents → drop any with the same name as
|
||||
/// `def` → push `def` → save the updated list.
|
||||
///
|
||||
/// Why: name-based dedup makes this an upsert rather than an append.
|
||||
///
|
||||
/// Return: `Ok(())` on success, propagating any load/save error.
|
||||
pub fn add_session_agent(session_dir: &Path, def: AgentDefinition) -> anyhow::Result<()> {
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
agents.retain(|a| a.name != def.name);
|
||||
@@ -31,6 +60,12 @@ pub fn add_session_agent(session_dir: &Path, def: AgentDefinition) -> anyhow::Re
|
||||
save_session_agents(session_dir, &agents)
|
||||
}
|
||||
|
||||
/// Remove a session agent definition by name, if present.
|
||||
///
|
||||
/// Flow: load existing session agents → filter out entries matching
|
||||
/// `name` → save the updated list.
|
||||
///
|
||||
/// Return: `Ok(())` whether or not an entry with `name` existed.
|
||||
pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result<()> {
|
||||
let mut agents = load_session_agents(session_dir);
|
||||
agents.retain(|a| a.name != name);
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
//! 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>,
|
||||
@@ -9,6 +14,7 @@ pub struct AppConfig {
|
||||
pub default_model: String,
|
||||
}
|
||||
|
||||
/// Connection details for a single LLM provider (base URL, API key source).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub api_base: String,
|
||||
@@ -17,6 +23,8 @@ pub struct ProviderConfig {
|
||||
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,
|
||||
@@ -57,6 +65,17 @@ impl Default for AppConfig {
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! In-memory conversation state: message history plus the system prompt and
|
||||
//! model parameters used to drive the LLM.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single conversation's message history and generation settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Conversation {
|
||||
pub messages: Vec<crate::dto::chat::message::ChatMessage>,
|
||||
@@ -11,6 +15,8 @@ pub struct Conversation {
|
||||
}
|
||||
|
||||
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(),
|
||||
@@ -22,10 +28,17 @@ impl Conversation {
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a message to the conversation history.
|
||||
pub fn push(&mut self, msg: crate::dto::chat::message::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| {
|
||||
@@ -33,6 +46,11 @@ impl Conversation {
|
||||
});
|
||||
}
|
||||
|
||||
/// 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<crate::dto::chat::message::ChatMessage> {
|
||||
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
|
||||
msgs.push(crate::dto::chat::message::ChatMessage::system(&self.system_prompt));
|
||||
@@ -40,6 +58,8 @@ impl Conversation {
|
||||
msgs
|
||||
}
|
||||
|
||||
/// Number of messages in the conversation history (excluding the
|
||||
/// synthesized system message).
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
//! 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,
|
||||
@@ -12,6 +17,7 @@ pub struct EditLogEntry {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
@@ -19,6 +25,8 @@ pub struct EditLog {
|
||||
}
|
||||
|
||||
impl EditLog {
|
||||
/// Open (or start tracking) the edit log for a session directory,
|
||||
/// replaying any existing `edits.jsonl` into memory.
|
||||
pub fn new(session_dir: &std::path::Path) -> Self {
|
||||
let path = session_dir.join("edits.jsonl");
|
||||
let entries = Self::load_from_disk(&path);
|
||||
@@ -40,6 +48,17 @@ impl EditLog {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Append one entry to `edits.jsonl` on disk and to the in-memory log.
|
||||
///
|
||||
/// Flow: serialize `entry` to a JSON line → ensure parent dir exists →
|
||||
/// open the file in append mode → write the line → push into
|
||||
/// `self.entries`.
|
||||
///
|
||||
/// Why: appending (not rewriting) keeps the log durable and cheap even
|
||||
/// as it grows across a long session.
|
||||
///
|
||||
/// Return: `Ok(())` on success; an `io::Error` if serialization or
|
||||
/// any filesystem operation fails.
|
||||
pub fn append(&mut self, entry: EditLogEntry) -> std::io::Result<()> {
|
||||
let line = serde_json::to_string(&entry)? + "\n";
|
||||
let parent = self.path.parent().unwrap();
|
||||
@@ -54,6 +73,7 @@ impl EditLog {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Number of edit entries recorded so far in this log.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
//! Long-term agent memory: markdown files with YAML-ish frontmatter storing
|
||||
//! lessons/references, plus slugified filenames and export/import helpers.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 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,
|
||||
@@ -18,6 +23,17 @@ pub struct Memory {
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -35,11 +51,27 @@ impl Memory {
|
||||
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!("{}.md", slug))
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -65,12 +97,29 @@ impl Memory {
|
||||
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();
|
||||
@@ -103,6 +152,9 @@ impl Memory {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
@@ -111,6 +163,13 @@ impl Memory {
|
||||
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 entries = match std::fs::read_dir(memory_dir) {
|
||||
Ok(e) => e,
|
||||
@@ -129,6 +188,14 @@ impl Memory {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Why: leading-dot stripping specifically blocks accidental hidden
|
||||
/// files and `..`-style traversal attempts embedded in `raw`.
|
||||
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 { '-' })
|
||||
@@ -137,6 +204,14 @@ pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf {
|
||||
memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean })
|
||||
}
|
||||
|
||||
/// Export all memories in `memory_dir` to a single JSON file.
|
||||
///
|
||||
/// Flow: list memory slugs → read+parse each into a `Memory` (skipping
|
||||
/// any that fail) → serialize the collected `Vec<Memory>` to pretty JSON
|
||||
/// → write to `output`.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an `io::Error` from serialization or
|
||||
/// the write.
|
||||
pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
|
||||
let names = Memory::list(memory_dir);
|
||||
let lessons: Vec<Memory> = names.iter()
|
||||
@@ -147,6 +222,17 @@ pub fn export_lessons(memory_dir: &Path, output: &Path) -> std::io::Result<()> {
|
||||
std::fs::write(output, data)?;
|
||||
Ok(())
|
||||
}
|
||||
/// Import memories from a JSON export file into `memory_dir`, skipping
|
||||
/// duplicates.
|
||||
///
|
||||
/// Flow: read+JSON-decode `input` into `Vec<Memory>` → build a set of
|
||||
/// existing slugs in `memory_dir` → for each lesson not already present
|
||||
/// (by slug), write it to disk and count it.
|
||||
///
|
||||
/// Why: slug-based dedup makes repeated imports idempotent — re-running
|
||||
/// import on the same file won't overwrite or duplicate existing memories.
|
||||
///
|
||||
/// Return: the number of memories actually imported (skips existing ones).
|
||||
pub fn import_lessons(memory_dir: &Path, input: &Path) -> std::io::Result<usize> {
|
||||
let data = std::fs::read_to_string(input)?;
|
||||
let lessons: Vec<Memory> = serde_json::from_str(&data)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Persistence and domain model layer: sessions, conversations, memory,
|
||||
//! message log (SQLite), edit log, and app/settings config.
|
||||
|
||||
pub mod app_config;
|
||||
pub mod editlog;
|
||||
pub mod memory;
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
//! Binary blob storage in the message-log SQLite database (e.g. images,
|
||||
//! attachments), keyed by session id and an arbitrary blob key.
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Insert or overwrite a blob for a session under `blob_key`.
|
||||
///
|
||||
/// Flow: compute current timestamp → `INSERT OR REPLACE` into `blobs`
|
||||
/// keyed on `(session_id, blob_key)`.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or the underlying SQLite error.
|
||||
pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u8], mime_type: Option<&str>) -> Result<()> {
|
||||
let created_at = chrono::Utc::now().timestamp_millis();
|
||||
conn.execute(
|
||||
@@ -10,6 +19,10 @@ pub fn store_blob(conn: &Connection, session_id: &str, blob_key: &str, data: &[u
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch a blob's bytes for a session by key.
|
||||
///
|
||||
/// Return: `Ok(Some(data))` if found, `Ok(None)` if no matching row
|
||||
/// exists, `Err` for any other SQLite failure.
|
||||
pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<Option<Vec<u8>>> {
|
||||
let result = conn.query_row(
|
||||
"SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2",
|
||||
@@ -23,6 +36,10 @@ pub fn retrieve_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Res
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a blob for a session by key.
|
||||
///
|
||||
/// Return: `Ok(true)` if a row was deleted, `Ok(false)` if no matching
|
||||
/// row existed.
|
||||
#[allow(dead_code)]
|
||||
pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Result<bool> {
|
||||
let rows = conn.execute(
|
||||
@@ -32,6 +49,10 @@ pub fn delete_blob(conn: &Connection, session_id: &str, blob_key: &str) -> Resul
|
||||
Ok(rows > 0)
|
||||
}
|
||||
|
||||
/// List all blob keys stored for a session, oldest first.
|
||||
///
|
||||
/// Return: `Ok(Vec<String>)` of keys ordered by `created_at`, or the
|
||||
/// underlying SQLite error.
|
||||
pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result<Vec<String>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
//! SQLite-backed message log: per-session `messages.sqlite` storing chat
|
||||
//! messages, blobs, and archive/summary metadata.
|
||||
|
||||
pub mod blobs;
|
||||
pub mod query;
|
||||
pub mod schema;
|
||||
@@ -5,6 +8,14 @@ pub mod schema;
|
||||
pub use blobs::store_blob;
|
||||
pub use query::insert_message;
|
||||
|
||||
/// Open (creating if needed) a session's `messages.sqlite` and ensure its
|
||||
/// schema is initialized.
|
||||
///
|
||||
/// Flow: resolve `<session_dir>/messages.sqlite` → create parent dirs →
|
||||
/// open a SQLite connection → run `schema::init_schema`.
|
||||
///
|
||||
/// Return: an open, schema-ready `Connection`, or an error if any step
|
||||
/// fails.
|
||||
pub fn open_or_create(session_dir: &std::path::Path) -> anyhow::Result<rusqlite::Connection> {
|
||||
let path = session_dir.join("messages.sqlite");
|
||||
if let Some(parent) = path.parent() {
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
//! Insert queries against the message log's `messages` table.
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use anyhow::Result;
|
||||
use crate::dto::chat::message::{ChatMessage, Role};
|
||||
|
||||
/// Insert a chat message into the session's message log.
|
||||
///
|
||||
/// Flow: extract optional content/tool_call_id/tool_name → serialize
|
||||
/// `tool_calls` to a JSON string if present → map `Role` to its string
|
||||
/// column value → `INSERT` the row with the current timestamp.
|
||||
///
|
||||
/// Return: the new row's `rowid` on success, or the underlying error.
|
||||
pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Result<i64> {
|
||||
let content = msg.content.as_deref();
|
||||
let tool_call_id = msg.tool_call_id.as_deref();
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
//! SQLite schema definition for the message log database.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use anyhow::Result;
|
||||
|
||||
/// Create the message log's tables and indexes if they don't already
|
||||
/// exist (`messages`, `archives`, `blobs`).
|
||||
///
|
||||
/// Why: idempotent via `CREATE TABLE/INDEX IF NOT EXISTS`, so it's safe
|
||||
/// to call on every `open_or_create`.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or the underlying SQLite error.
|
||||
pub fn init_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! Session archive/summary metadata tracked alongside the message log
|
||||
//! (title, model, counts, and a rolling text summary).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Summary metadata for one archived/summarized session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SummaryRecord {
|
||||
pub session_id: String,
|
||||
@@ -13,6 +17,8 @@ pub struct SummaryRecord {
|
||||
}
|
||||
|
||||
impl SummaryRecord {
|
||||
/// Create a fresh summary record with zeroed counts and an empty
|
||||
/// summary, timestamped to now.
|
||||
pub fn new(session_id: String, title: String, model: String) -> Self {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
SummaryRecord {
|
||||
@@ -27,11 +33,13 @@ impl SummaryRecord {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace the summary text and bump `updated_at`.
|
||||
pub fn update_summary(&mut self, summary: String) {
|
||||
self.summary = summary;
|
||||
self.updated_at = chrono::Utc::now().timestamp_millis();
|
||||
}
|
||||
|
||||
/// Add to the running message/token counts and bump `updated_at`.
|
||||
pub fn increment_counts(&mut self, messages: usize, tokens: usize) {
|
||||
self.message_count += messages;
|
||||
self.token_count += tokens;
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
//! Session metadata: id, title, workspace roots, and message/token counts,
|
||||
//! persisted as `session.json` per session directory.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use chrono::Utc;
|
||||
|
||||
/// 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,
|
||||
@@ -17,6 +22,8 @@ pub struct Session {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -33,14 +40,25 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Flow: ensure the session directory exists → serialize to pretty
|
||||
/// JSON → write to `session.json.tmp` → rename over `session.json`.
|
||||
///
|
||||
/// Why: write-then-rename avoids a torn/partial `session.json` if
|
||||
/// interrupted mid-write.
|
||||
///
|
||||
/// 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)?;
|
||||
@@ -52,6 +70,10 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a session's metadata by id from `<base_dir>/sessions/<id>/session.json`.
|
||||
///
|
||||
/// 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> {
|
||||
let path = base_dir.join("sessions").join(id).join("session.json");
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
@@ -59,6 +81,14 @@ impl Session {
|
||||
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 entries = match std::fs::read_dir(&sessions_dir) {
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
//! PID-file based advisory lock preventing two processes from operating on
|
||||
//! the same session directory concurrently.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::fs;
|
||||
|
||||
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
|
||||
/// auto-removed on drop.
|
||||
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"),
|
||||
@@ -14,6 +21,19 @@ impl SessionLock {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to acquire the session lock.
|
||||
///
|
||||
/// Flow: if `.lock` exists, read the PID inside it and check
|
||||
/// `is_alive` — if that process is still running, fail to acquire →
|
||||
/// otherwise (no lock file, unreadable PID, or dead owner) write our
|
||||
/// own PID into `.lock` and succeed.
|
||||
///
|
||||
/// Why: a stale lock file from a crashed process must not permanently
|
||||
/// block new sessions, so liveness is re-checked via `kill(pid, 0)`
|
||||
/// rather than trusting the file's mere existence.
|
||||
///
|
||||
/// 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> {
|
||||
if self.path.exists() {
|
||||
let content = fs::read_to_string(&self.path).unwrap_or_default();
|
||||
@@ -27,10 +47,12 @@ impl SessionLock {
|
||||
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.
|
||||
fn is_alive(&self, 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
|
||||
@@ -40,6 +62,8 @@ impl SessionLock {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
//! 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)]
|
||||
#[derive(Default)]
|
||||
pub enum InternetMode {
|
||||
@@ -11,6 +21,10 @@ pub enum InternetMode {
|
||||
|
||||
|
||||
|
||||
/// 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,
|
||||
@@ -49,6 +63,12 @@ impl Default for Settings {
|
||||
}
|
||||
|
||||
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");
|
||||
@@ -58,6 +78,11 @@ impl Settings {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Serialize and write settings to `<store_base_dir>/settings.json`.
|
||||
///
|
||||
/// Flow: ensure base dir exists → pretty-print JSON → write to disk.
|
||||
///
|
||||
/// 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)?;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
//! Filesystem layout for zesdex's persistent and scratch data directories.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 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,
|
||||
@@ -11,6 +17,12 @@ pub struct Store {
|
||||
}
|
||||
|
||||
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"))
|
||||
@@ -25,6 +37,9 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)?;
|
||||
|
||||
Reference in New Issue
Block a user