Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
283 lines
10 KiB
Rust
283 lines
10 KiB
Rust
#![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
|
|
})
|
|
}
|