メモリーに関するクレート作成・ファイル構造の実装
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "memory"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1.89"
|
||||
chrono = { version = "0.4.44", features = ["serde"] }
|
||||
llm-worker = { version = "0.2.1", path = "../llm-worker" }
|
||||
manifest = { version = "0.1.0", path = "../manifest" }
|
||||
schemars = "1.2.1"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.149"
|
||||
serde_yaml = "0.9.34"
|
||||
thiserror = "2.0.18"
|
||||
tracing = "0.1.44"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
tokio = { version = "1.52.1", features = ["macros", "rt-multi-thread"] }
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Errors raised by the memory subsystem.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Top-level error for memory operations that don't fit the lint flow.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum MemoryError {
|
||||
#[error("path is not under the memory or knowledge tree: {}", .0.display())]
|
||||
OutsideMemoryTree(PathBuf),
|
||||
#[error("path is not absolute: {}", .0.display())]
|
||||
RelativePath(PathBuf),
|
||||
#[error("io error at {}: {source}", .path.display())]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
}
|
||||
|
||||
impl MemoryError {
|
||||
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
|
||||
Self::Io {
|
||||
path: path.into(),
|
||||
source,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single Linter violation. Multiple are aggregated in a [`LintReport`].
|
||||
///
|
||||
/// `Display` produces a one-line message used directly in the `ToolError`
|
||||
/// payload returned to the LLM.
|
||||
#[derive(Debug, Clone, Error, PartialEq, Eq)]
|
||||
pub enum LintError {
|
||||
#[error("path is not a valid memory record location: {}", .0.display())]
|
||||
InvalidPath(PathBuf),
|
||||
|
||||
#[error("path is for a different record kind than expected at this location: {}", .0.display())]
|
||||
WrongRecordKind(PathBuf),
|
||||
|
||||
#[error("invalid slug `{0}`: must match ^[a-z0-9](?:[a-z0-9-]{{0,62}}[a-z0-9])?$")]
|
||||
InvalidSlug(String),
|
||||
|
||||
#[error("malformed frontmatter: {0}")]
|
||||
MalformedFrontmatter(String),
|
||||
|
||||
#[error("frontmatter is missing or document is empty")]
|
||||
MissingFrontmatter,
|
||||
|
||||
#[error("missing required frontmatter field: `{0}`")]
|
||||
MissingField(&'static str),
|
||||
|
||||
#[error("invalid value for `{field}`: {message}")]
|
||||
InvalidField {
|
||||
field: &'static str,
|
||||
message: String,
|
||||
},
|
||||
|
||||
#[error("Decisions `status` must be one of open|resolved|replaced (got `{0}`)")]
|
||||
InvalidStatus(String),
|
||||
|
||||
#[error("Knowledge with model_invokation: true cannot have description longer than {limit} chars (got {actual})")]
|
||||
DescriptionTooLong { actual: usize, limit: usize },
|
||||
|
||||
#[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")]
|
||||
BodyTooLong { actual: usize, limit: usize },
|
||||
|
||||
#[error("write to `memory/workflow/` is forbidden via the memory tool — Workflows are human-edited")]
|
||||
WorkflowWriteForbidden,
|
||||
|
||||
#[error("slug `{0}` already exists; use the edit tool instead of creating a new record")]
|
||||
SlugAlreadyExists(String),
|
||||
|
||||
#[error("`{field}` references unknown {kind} slug `{slug}`")]
|
||||
UnknownReference {
|
||||
field: &'static str,
|
||||
kind: &'static str,
|
||||
slug: String,
|
||||
},
|
||||
|
||||
#[error("`replaced_by` chain forms a cycle: {chain}")]
|
||||
ReplacedByCycle { chain: String },
|
||||
|
||||
#[error("`replaced_by` must point to a different slug than the record itself")]
|
||||
ReplacedBySelf,
|
||||
}
|
||||
|
||||
/// A single Linter warning (non-blocking).
|
||||
///
|
||||
/// Warnings ride along in the `ToolOutput.summary` so the agent can act
|
||||
/// on them when convenient; they never abort the write.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LintWarning {
|
||||
/// Single-source record exceeds the importance/size threshold.
|
||||
LowImportanceLargeRecord { chars: usize },
|
||||
/// `sources` array has grown past the soft cap.
|
||||
SourcesOverflow { count: usize },
|
||||
/// Multiple slugs in the same kind are within Levenshtein distance 2.
|
||||
SimilarSlugs(Vec<String>),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LintWarning {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::LowImportanceLargeRecord { chars } => write!(
|
||||
f,
|
||||
"record is large ({chars} chars) but only has 1 source — consider splitting or trimming"
|
||||
),
|
||||
Self::SourcesOverflow { count } => write!(
|
||||
f,
|
||||
"`sources` has {count} entries — consider keeping only the most recent and relying on git log for the rest"
|
||||
),
|
||||
Self::SimilarSlugs(slugs) => {
|
||||
write!(f, "similar slugs detected (consider merging): ")?;
|
||||
for (i, s) in slugs.iter().enumerate() {
|
||||
if i > 0 {
|
||||
write!(f, ", ")?;
|
||||
}
|
||||
write!(f, "{s}")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Memory subsystem: persistence layer for `memory/*` and `knowledge/*` records.
|
||||
//!
|
||||
//! Self-contained: provides its own Tool implementations (read/write/edit)
|
||||
//! that target `<workspace>/memory/` and `<workspace>/knowledge/` only,
|
||||
//! with a pre-write Linter built in. Generic CRUD tools (in the `tools`
|
||||
//! crate) must not touch these directories — Pod is responsible for
|
||||
//! denying them at the Scope level when memory is enabled.
|
||||
|
||||
pub mod error;
|
||||
pub mod linter;
|
||||
pub mod schema;
|
||||
pub mod scope;
|
||||
pub mod slug;
|
||||
pub mod tool;
|
||||
pub mod workspace;
|
||||
|
||||
pub use error::{LintError, LintWarning, MemoryError};
|
||||
pub use linter::{LintReport, Linter};
|
||||
pub use scope::deny_write_rules;
|
||||
pub use slug::Slug;
|
||||
pub use workspace::WorkspaceLayout;
|
||||
@@ -0,0 +1,138 @@
|
||||
//! Walks `<workspace>/memory/{decisions,requests}/`, `memory/workflow/`,
|
||||
//! and `<workspace>/knowledge/` to collect the slug set the linter
|
||||
//! needs for reference-integrity and same-slug-duplication checks.
|
||||
//!
|
||||
//! No caching: each lint call walks fresh. Tree size is expected to
|
||||
//! stay small (hundreds of files, not thousands).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::schema::{
|
||||
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, WorkflowFrontmatter,
|
||||
split_frontmatter,
|
||||
};
|
||||
use crate::slug::Slug;
|
||||
use crate::workspace::{RecordKind, WorkspaceLayout};
|
||||
|
||||
/// Snapshot of every record currently on disk under the workspace.
|
||||
///
|
||||
/// Carries enough metadata to answer:
|
||||
/// - "does slug X of kind K exist?" (same-slug duplication, reference checks)
|
||||
/// - "what is X's `replaced_by`?" (cycle detection)
|
||||
/// - "what other slugs of kind K exist?" (similar-slug warning)
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct ExistingRecords {
|
||||
decisions: HashMap<Slug, DecisionMeta>,
|
||||
requests: HashSet<Slug>,
|
||||
knowledge: HashSet<Slug>,
|
||||
workflow: HashSet<Slug>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DecisionMeta {
|
||||
pub replaced_by: Option<Slug>,
|
||||
}
|
||||
|
||||
impl ExistingRecords {
|
||||
pub fn contains(&self, kind: RecordKind, slug: &Slug) -> bool {
|
||||
match kind {
|
||||
RecordKind::Decision => self.decisions.contains_key(slug),
|
||||
RecordKind::Request => self.requests.contains(slug),
|
||||
RecordKind::Knowledge => self.knowledge.contains(slug),
|
||||
RecordKind::Workflow => self.workflow.contains(slug),
|
||||
RecordKind::Summary => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decision(&self, slug: &Slug) -> Option<&DecisionMeta> {
|
||||
self.decisions.get(slug)
|
||||
}
|
||||
|
||||
pub fn slugs(&self, kind: RecordKind) -> Vec<&Slug> {
|
||||
match kind {
|
||||
RecordKind::Decision => self.decisions.keys().collect(),
|
||||
RecordKind::Request => self.requests.iter().collect(),
|
||||
RecordKind::Knowledge => self.knowledge.iter().collect(),
|
||||
RecordKind::Workflow => self.workflow.iter().collect(),
|
||||
RecordKind::Summary => Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk the workspace and collect every record.
|
||||
pub fn scan_existing(layout: &WorkspaceLayout) -> io::Result<ExistingRecords> {
|
||||
let mut out = ExistingRecords::default();
|
||||
|
||||
scan_dir(&layout.decisions_dir(), |path, slug| {
|
||||
let meta = read_decision_meta(path);
|
||||
out.decisions.insert(slug, meta);
|
||||
})?;
|
||||
scan_dir(&layout.requests_dir(), |path, slug| {
|
||||
// Parse to validate but discard contents — only slug existence
|
||||
// matters for reference checks. Parse failure is silently
|
||||
// ignored: existing record corruption isn't this write's
|
||||
// responsibility to fix.
|
||||
let _ = parse_silent::<RequestFrontmatter>(path);
|
||||
out.requests.insert(slug);
|
||||
})?;
|
||||
scan_dir(&layout.knowledge_dir(), |path, slug| {
|
||||
let _ = parse_silent::<KnowledgeFrontmatter>(path);
|
||||
out.knowledge.insert(slug);
|
||||
})?;
|
||||
scan_dir(&layout.workflow_dir(), |path, slug| {
|
||||
let _ = parse_silent::<WorkflowFrontmatter>(path);
|
||||
out.workflow.insert(slug);
|
||||
})?;
|
||||
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn scan_dir<F>(dir: &Path, mut visit: F) -> io::Result<()>
|
||||
where
|
||||
F: FnMut(&Path, Slug),
|
||||
{
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(e) => e,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
for entry in entries {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let stem = match path.file_stem().and_then(|s| s.to_str()) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
if ext != "md" {
|
||||
continue;
|
||||
}
|
||||
if let Ok(slug) = Slug::parse(stem) {
|
||||
visit(&path, slug);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_decision_meta(path: &Path) -> DecisionMeta {
|
||||
match parse_silent::<DecisionFrontmatter>(path) {
|
||||
Some(fm) => DecisionMeta {
|
||||
replaced_by: fm.replaced_by,
|
||||
},
|
||||
None => DecisionMeta { replaced_by: None },
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_silent<F>(path: &Path) -> Option<F>
|
||||
where
|
||||
F: serde::de::DeserializeOwned,
|
||||
{
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let (yaml, _) = split_frontmatter(&content).ok()?;
|
||||
serde_yaml::from_str::<F>(yaml).ok()
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//! YAML frontmatter parsing helpers shared by every kind.
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::LintError;
|
||||
|
||||
/// Strict YAML deserialization that maps serde errors into the linter's
|
||||
/// `MissingField` / `InvalidField` / `MalformedFrontmatter` taxonomy
|
||||
/// when possible.
|
||||
pub fn deserialize_strict<F: DeserializeOwned>(yaml: &str) -> Result<F, LintError> {
|
||||
serde_yaml::from_str::<F>(yaml).map_err(map_serde_error)
|
||||
}
|
||||
|
||||
fn map_serde_error(err: serde_yaml::Error) -> LintError {
|
||||
let msg = err.to_string();
|
||||
|
||||
// `missing field \`X\`` is the exact pattern serde uses for missing
|
||||
// required fields. Hoist into the typed variant so the LLM sees a
|
||||
// crisp message it can act on.
|
||||
if let Some(field) = parse_missing_field(&msg) {
|
||||
return LintError::MissingField(field);
|
||||
}
|
||||
if let Some((field, message)) = parse_invalid_status(&msg) {
|
||||
if field == "status" {
|
||||
return LintError::InvalidStatus(message);
|
||||
}
|
||||
return LintError::InvalidField { field, message };
|
||||
}
|
||||
LintError::MalformedFrontmatter(msg)
|
||||
}
|
||||
|
||||
fn parse_missing_field(msg: &str) -> Option<&'static str> {
|
||||
let needle = "missing field `";
|
||||
let start = msg.find(needle)? + needle.len();
|
||||
let end = msg[start..].find('`')? + start;
|
||||
let field_name = &msg[start..end];
|
||||
static FIELDS: &[&str] = &[
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"sources",
|
||||
"status",
|
||||
"kind",
|
||||
"description",
|
||||
"model_invokation",
|
||||
"user_invocable",
|
||||
"last_sources",
|
||||
"auto_invoke",
|
||||
"requires",
|
||||
];
|
||||
FIELDS.iter().copied().find(|n| *n == field_name)
|
||||
}
|
||||
|
||||
fn parse_invalid_status(msg: &str) -> Option<(&'static str, String)> {
|
||||
// serde renders enum failures as: "unknown variant `Foo`, expected one of ..."
|
||||
// We can't reliably attribute it to a specific field from the message
|
||||
// alone, so we conservatively label it as `status` only when the
|
||||
// message mentions one of the DecisionStatus variants in the
|
||||
// expected set.
|
||||
if msg.contains("unknown variant") && msg.contains("`open`") {
|
||||
let needle = "unknown variant `";
|
||||
let start = msg.find(needle)? + needle.len();
|
||||
let end = msg[start..].find('`')? + start;
|
||||
let bad = msg[start..end].to_string();
|
||||
return Some(("status", bad));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Pre-write Linter for the memory subsystem.
|
||||
//!
|
||||
//! The linter is pure: given a [`WorkspaceLayout`], a target path, and
|
||||
//! the proposed file content (raw bytes), it returns a [`LintReport`]
|
||||
//! aggregating every applicable rule violation. The memory tool calls
|
||||
//! this *before* committing to fs and surfaces a non-empty `errors`
|
||||
//! collection back to the LLM as `ToolError::InvalidArgument`.
|
||||
//!
|
||||
//! Reference-integrity checks (`replaced_by` / `requires` existence,
|
||||
//! cycle detection) walk the whole `memory/` and `knowledge/` trees
|
||||
//! each call. No caching; the trees are expected to be small.
|
||||
|
||||
mod existing;
|
||||
mod frontmatter;
|
||||
mod references;
|
||||
mod size;
|
||||
mod warnings;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::error::{LintError, LintWarning};
|
||||
use crate::schema::{
|
||||
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, SummaryFrontmatter,
|
||||
WorkflowFrontmatter, split_frontmatter,
|
||||
};
|
||||
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
|
||||
|
||||
pub use existing::{ExistingRecords, scan_existing};
|
||||
|
||||
/// Aggregated linter result. `errors` empty ⇒ write proceeds.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct LintReport {
|
||||
pub errors: Vec<LintError>,
|
||||
pub warnings: Vec<LintWarning>,
|
||||
}
|
||||
|
||||
impl LintReport {
|
||||
pub fn has_errors(&self) -> bool {
|
||||
!self.errors.is_empty()
|
||||
}
|
||||
|
||||
pub fn extend_errors(&mut self, more: impl IntoIterator<Item = LintError>) {
|
||||
self.errors.extend(more);
|
||||
}
|
||||
|
||||
pub fn push_error(&mut self, err: LintError) {
|
||||
self.errors.push(err);
|
||||
}
|
||||
|
||||
pub fn push_warning(&mut self, w: LintWarning) {
|
||||
self.warnings.push(w);
|
||||
}
|
||||
}
|
||||
|
||||
/// Operation context: is this a brand-new file or an update of an
|
||||
/// existing one? Affects same-slug duplication check.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum WriteMode {
|
||||
Create,
|
||||
Update,
|
||||
}
|
||||
|
||||
/// Stateless entry point holding the workspace layout. Cheap to clone.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Linter {
|
||||
layout: WorkspaceLayout,
|
||||
}
|
||||
|
||||
impl Linter {
|
||||
pub fn new(layout: WorkspaceLayout) -> Self {
|
||||
Self { layout }
|
||||
}
|
||||
|
||||
pub fn layout(&self) -> &WorkspaceLayout {
|
||||
&self.layout
|
||||
}
|
||||
|
||||
/// Lint a proposed write to `path` with the given UTF-8 `content`.
|
||||
///
|
||||
/// `mode` tells the linter whether the path already existed at the
|
||||
/// moment of write — Create triggers same-slug duplication checks,
|
||||
/// Update doesn't.
|
||||
pub fn lint(&self, path: &Path, content: &str, mode: WriteMode) -> LintReport {
|
||||
let mut report = LintReport::default();
|
||||
|
||||
// 1. Path classification.
|
||||
let classified = match self.layout.classify(path) {
|
||||
Ok(Some(cp)) => cp,
|
||||
Ok(None) => {
|
||||
report.push_error(LintError::InvalidPath(path.to_path_buf()));
|
||||
return report;
|
||||
}
|
||||
Err(e) => {
|
||||
report.push_error(e);
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Workflow paths are sub-Worker-forbidden at the tool layer.
|
||||
if classified.kind == RecordKind::Workflow {
|
||||
report.push_error(LintError::WorkflowWriteForbidden);
|
||||
return report;
|
||||
}
|
||||
|
||||
// 3. Frontmatter parse + kind-specific structural checks +
|
||||
// size limits. Reference-integrity needs the existing
|
||||
// record set, fetched once below.
|
||||
let existing = match existing::scan_existing(&self.layout) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
report.push_error(LintError::MalformedFrontmatter(format!(
|
||||
"failed to scan existing records: {e}"
|
||||
)));
|
||||
return report;
|
||||
}
|
||||
};
|
||||
|
||||
// Same-slug check on Create.
|
||||
if mode == WriteMode::Create {
|
||||
if let Some(slug) = &classified.slug {
|
||||
if existing.contains(classified.kind, slug) {
|
||||
report.push_error(LintError::SlugAlreadyExists(slug.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Frontmatter parse dispatch by kind.
|
||||
match classified.kind {
|
||||
RecordKind::Decision => {
|
||||
self.check_decision(content, &classified, &existing, &mut report);
|
||||
}
|
||||
RecordKind::Request => {
|
||||
self.check_kind::<RequestFrontmatter>(content, &classified, &mut report);
|
||||
}
|
||||
RecordKind::Knowledge => {
|
||||
self.check_knowledge(content, &classified, &mut report);
|
||||
}
|
||||
RecordKind::Summary => {
|
||||
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
|
||||
}
|
||||
RecordKind::Workflow => unreachable!("guarded above"),
|
||||
}
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
fn check_kind<F>(&self, content: &str, cp: &ClassifiedPath, report: &mut LintReport)
|
||||
where
|
||||
F: DeserializeOwned + crate::schema::Frontmatter,
|
||||
{
|
||||
let parsed = match parse_frontmatter::<F>(content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.push_error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let body = parsed.body;
|
||||
size::check_body::<F>(body, report);
|
||||
warnings::check_warnings_kindless(cp, body, report);
|
||||
let _ = parsed.frontmatter; // discarded after structural checks
|
||||
}
|
||||
|
||||
fn check_decision(
|
||||
&self,
|
||||
content: &str,
|
||||
cp: &ClassifiedPath,
|
||||
existing: &ExistingRecords,
|
||||
report: &mut LintReport,
|
||||
) {
|
||||
let parsed = match parse_frontmatter::<DecisionFrontmatter>(content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.push_error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fm = parsed.frontmatter;
|
||||
size::check_body::<DecisionFrontmatter>(parsed.body, report);
|
||||
|
||||
// replaced_by structural rules.
|
||||
if let Some(target) = &fm.replaced_by {
|
||||
if let Some(self_slug) = &cp.slug {
|
||||
if target == self_slug {
|
||||
report.push_error(LintError::ReplacedBySelf);
|
||||
}
|
||||
}
|
||||
references::check_replaced_by(
|
||||
cp.slug.as_ref(),
|
||||
target,
|
||||
existing,
|
||||
report,
|
||||
);
|
||||
}
|
||||
|
||||
warnings::check_warnings_with_sources(parsed.body, fm.sources.len(), report);
|
||||
}
|
||||
|
||||
fn check_knowledge(
|
||||
&self,
|
||||
content: &str,
|
||||
cp: &ClassifiedPath,
|
||||
report: &mut LintReport,
|
||||
) {
|
||||
let parsed = match parse_frontmatter::<KnowledgeFrontmatter>(content) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
report.push_error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let fm = parsed.frontmatter;
|
||||
size::check_body::<KnowledgeFrontmatter>(parsed.body, report);
|
||||
|
||||
if fm.model_invokation
|
||||
&& fm.description.chars().count()
|
||||
> crate::schema::KNOWLEDGE_DESCRIPTION_HARD_CAP
|
||||
{
|
||||
report.push_error(LintError::DescriptionTooLong {
|
||||
actual: fm.description.chars().count(),
|
||||
limit: crate::schema::KNOWLEDGE_DESCRIPTION_HARD_CAP,
|
||||
});
|
||||
}
|
||||
|
||||
warnings::check_warnings_with_sources(parsed.body, fm.last_sources.len(), report);
|
||||
let _ = cp;
|
||||
}
|
||||
}
|
||||
|
||||
/// Workflow frontmatter validator exposed for human-edit paths
|
||||
/// (CLI / pre-commit). Not used by the memory tool, which rejects
|
||||
/// workflow writes outright.
|
||||
pub fn lint_workflow_frontmatter(content: &str) -> Result<WorkflowFrontmatter, LintError> {
|
||||
let parsed = parse_frontmatter::<WorkflowFrontmatter>(content)?;
|
||||
Ok(parsed.frontmatter)
|
||||
}
|
||||
|
||||
struct Parsed<'a, F> {
|
||||
frontmatter: F,
|
||||
body: &'a str,
|
||||
}
|
||||
|
||||
fn parse_frontmatter<F: DeserializeOwned>(content: &str) -> Result<Parsed<'_, F>, LintError> {
|
||||
let (yaml, body) = split_frontmatter(content)?;
|
||||
let fm = frontmatter::deserialize_strict::<F>(yaml)?;
|
||||
Ok(Parsed {
|
||||
frontmatter: fm,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write(p: &std::path::Path, content: &str) {
|
||||
if let Some(parent) = p.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(p, content).unwrap();
|
||||
}
|
||||
|
||||
fn iso_now() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
fn workspace() -> (TempDir, Linter) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
let linter = Linter::new(layout);
|
||||
(dir, linter)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_write_rejected() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/workflow/wf.md");
|
||||
let content = format!(
|
||||
"---\nupdated_at: {now}\ndescription: x\nauto_invoke: false\nuser_invocable: true\n---\nbody",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::WorkflowWriteForbidden)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_memory_tree_rejected() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("src/main.rs");
|
||||
let report = linter.lint(&path, "ignored", WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::InvalidPath(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_with_unknown_replaced_by_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: ghost\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(
|
||||
e,
|
||||
LintError::UnknownReference { .. }
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_replaced_by_self_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: foo\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Update);
|
||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::ReplacedBySelf)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decision_replaced_by_existing_ok() {
|
||||
let (dir, linter) = workspace();
|
||||
// Pre-create the target.
|
||||
let target = dir.path().join("memory/decisions/bar.md");
|
||||
write(
|
||||
&target,
|
||||
&format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nbar body\n",
|
||||
now = iso_now()
|
||||
),
|
||||
);
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: bar\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_required_field_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
// Missing `status`.
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(
|
||||
e,
|
||||
LintError::MissingField(_) | LintError::MalformedFrontmatter(_)
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_long_description_with_model_invokation_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("knowledge/foo.md");
|
||||
let big_desc = "x".repeat(2000);
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nkind: rule\ndescription: {big_desc}\nmodel_invokation: true\nuser_invocable: true\nlast_sources: []\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::DescriptionTooLong { .. })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knowledge_long_description_without_model_invokation_ok() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("knowledge/foo.md");
|
||||
let big_desc = "x".repeat(2000);
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nkind: rule\ndescription: {big_desc}\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\nbody\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_path_accepted() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/summary.md");
|
||||
let content = format!(
|
||||
"---\nupdated_at: {now}\n---\nsummary body\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Update);
|
||||
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_when_existing_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
write(
|
||||
&path,
|
||||
&format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nold\n",
|
||||
now = iso_now()
|
||||
),
|
||||
);
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nnew\n",
|
||||
now = iso_now()
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::SlugAlreadyExists(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_size_limit_errors() {
|
||||
let (dir, linter) = workspace();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
let big_body = "x".repeat(8001);
|
||||
let content = format!(
|
||||
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\n{body}",
|
||||
now = iso_now(),
|
||||
body = big_body
|
||||
);
|
||||
let report = linter.lint(&path, &content, WriteMode::Create);
|
||||
assert!(report.errors.iter().any(|e| matches!(e, LintError::BodyTooLong { .. })));
|
||||
// Sanity: ensure path was treated as PathBuf consistently.
|
||||
let _ = PathBuf::from(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Reference-integrity checks: `replaced_by` existence + cycle detection.
|
||||
//!
|
||||
//! `requires` (Workflow) is checked symmetrically when/if the Workflow
|
||||
//! linter is invoked from a human-edit path; the memory tool itself
|
||||
//! never writes Workflow records.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::error::LintError;
|
||||
use crate::linter::ExistingRecords;
|
||||
use crate::linter::LintReport;
|
||||
use crate::slug::Slug;
|
||||
use crate::workspace::RecordKind;
|
||||
|
||||
/// Validate a Decision's `replaced_by` against the existing record set.
|
||||
///
|
||||
/// `self_slug` is the slug of the record currently being written (None
|
||||
/// only when the path was malformed and we shouldn't even reach here).
|
||||
pub fn check_replaced_by(
|
||||
self_slug: Option<&Slug>,
|
||||
target: &Slug,
|
||||
existing: &ExistingRecords,
|
||||
report: &mut LintReport,
|
||||
) {
|
||||
// Existence: target must already be a Decision on disk.
|
||||
if !existing.contains(RecordKind::Decision, target) {
|
||||
report.push_error(LintError::UnknownReference {
|
||||
field: "replaced_by",
|
||||
kind: "decision",
|
||||
slug: target.to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycle: walk the chain target → target.replaced_by → ... and
|
||||
// ensure we never revisit `self_slug` or any node twice.
|
||||
let mut visited = HashSet::new();
|
||||
if let Some(s) = self_slug {
|
||||
visited.insert(s.clone());
|
||||
}
|
||||
let mut cursor = Some(target.clone());
|
||||
let mut chain: Vec<String> = Vec::new();
|
||||
while let Some(node) = cursor {
|
||||
if !visited.insert(node.clone()) {
|
||||
chain.push(node.to_string());
|
||||
report.push_error(LintError::ReplacedByCycle {
|
||||
chain: chain.join(" -> "),
|
||||
});
|
||||
return;
|
||||
}
|
||||
chain.push(node.to_string());
|
||||
cursor = existing
|
||||
.decision(&node)
|
||||
.and_then(|m| m.replaced_by.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
// Smoke test: cycle detection terminates on a 2-node loop where the
|
||||
// existing tree already contains A↔B and the new write would close
|
||||
// the loop. A direct unit test against `check_replaced_by` is
|
||||
// exercised by linter::tests; here we just guard the loop bound.
|
||||
#[test]
|
||||
fn empty_chain_terminates() {
|
||||
let mut report = LintReport::default();
|
||||
let existing = ExistingRecords::default();
|
||||
let target = Slug::parse("foo").unwrap();
|
||||
check_replaced_by(None, &target, &existing, &mut report);
|
||||
assert_eq!(report.errors.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//! Body size limit checks.
|
||||
|
||||
use crate::error::LintError;
|
||||
use crate::linter::LintReport;
|
||||
use crate::schema::Frontmatter;
|
||||
|
||||
pub fn check_body<F: Frontmatter>(body: &str, report: &mut LintReport) {
|
||||
let chars = body.chars().count();
|
||||
if chars > F::BODY_LIMIT {
|
||||
report.push_error(LintError::BodyTooLong {
|
||||
actual: chars,
|
||||
limit: F::BODY_LIMIT,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
//! Soft warnings: low-importance large records, sources accumulation.
|
||||
//!
|
||||
//! Similar-slug warnings need the existing record set and are
|
||||
//! integrated into the main linter pass when implemented; this file
|
||||
//! covers per-write checks that only need the proposed content.
|
||||
|
||||
use crate::error::LintWarning;
|
||||
use crate::linter::LintReport;
|
||||
use crate::workspace::ClassifiedPath;
|
||||
|
||||
const LARGE_BODY_THRESHOLD: usize = 1500;
|
||||
const SOURCES_OVERFLOW_THRESHOLD: usize = 10;
|
||||
|
||||
/// For kinds that don't carry a `sources` array (Summary), emit only
|
||||
/// the body-size warning.
|
||||
pub fn check_warnings_kindless(_cp: &ClassifiedPath, body: &str, _report: &mut LintReport) {
|
||||
let _ = body;
|
||||
// Summary intentionally has no warning band — the per-record
|
||||
// size:importance heuristic doesn't apply to a single rolling file.
|
||||
}
|
||||
|
||||
/// For kinds with `sources` (Decisions / Requests / Knowledge), consult
|
||||
/// both the body length and the sources count.
|
||||
pub fn check_warnings_with_sources(body: &str, source_count: usize, report: &mut LintReport) {
|
||||
let chars = body.chars().count();
|
||||
if source_count <= 1 && chars >= LARGE_BODY_THRESHOLD {
|
||||
report.push_warning(LintWarning::LowImportanceLargeRecord { chars });
|
||||
}
|
||||
if source_count > SOURCES_OVERFLOW_THRESHOLD {
|
||||
report.push_warning(LintWarning::SourcesOverflow {
|
||||
count: source_count,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Common frontmatter helpers and shared types.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::LintError;
|
||||
|
||||
/// Reference to a session-store entry range. Stored in `sources` /
|
||||
/// `last_sources` arrays for traceability back to raw session logs.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SourceRef {
|
||||
pub session_id: String,
|
||||
/// `[start_entry, end_entry]` inclusive range of session-store entry indices.
|
||||
pub range: [u64; 2],
|
||||
}
|
||||
|
||||
/// Trait every kind-specific frontmatter implements so the linter can
|
||||
/// drive them uniformly.
|
||||
pub trait Frontmatter: Sized {
|
||||
/// Hard upper bound on body chars (excluding the frontmatter block).
|
||||
const BODY_LIMIT: usize;
|
||||
|
||||
fn created_at(&self) -> DateTime<Utc>;
|
||||
fn updated_at(&self) -> DateTime<Utc>;
|
||||
}
|
||||
|
||||
const FRONTMATTER_DELIM: &str = "---";
|
||||
|
||||
/// Split a markdown document into `(yaml_frontmatter, body)`.
|
||||
///
|
||||
/// Expects the document to start with `---\n` and have a closing
|
||||
/// `---\n` (or `---` at EOF) somewhere downstream. Trailing newline
|
||||
/// after the closing delimiter is consumed.
|
||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), LintError> {
|
||||
// The opening delimiter must be the very first line.
|
||||
let after_open = content
|
||||
.strip_prefix(FRONTMATTER_DELIM)
|
||||
.and_then(|s| s.strip_prefix('\n').or(Some(s)))
|
||||
.ok_or(LintError::MissingFrontmatter)?;
|
||||
|
||||
// Look for the closing `---` on its own line.
|
||||
let mut yaml_end = None;
|
||||
let mut byte_offset = 0usize;
|
||||
for line in after_open.split_inclusive('\n') {
|
||||
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
|
||||
if trimmed == FRONTMATTER_DELIM {
|
||||
yaml_end = Some((byte_offset, byte_offset + line.len()));
|
||||
break;
|
||||
}
|
||||
byte_offset += line.len();
|
||||
}
|
||||
|
||||
let (yaml_end_excl, body_start) = yaml_end.ok_or_else(|| {
|
||||
LintError::MalformedFrontmatter("missing closing `---` line".to_string())
|
||||
})?;
|
||||
|
||||
let yaml = &after_open[..yaml_end_excl];
|
||||
let body = &after_open[body_start..];
|
||||
Ok((yaml, body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn splits_simple() {
|
||||
let doc = "---\nfoo: 1\n---\nbody here\n";
|
||||
let (y, b) = split_frontmatter(doc).unwrap();
|
||||
assert_eq!(y, "foo: 1\n");
|
||||
assert_eq!(b, "body here\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_leading_delim_errors() {
|
||||
let err = split_frontmatter("hello").unwrap_err();
|
||||
assert!(matches!(err, LintError::MissingFrontmatter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_closing_delim_errors() {
|
||||
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
|
||||
assert!(matches!(err, LintError::MalformedFrontmatter(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_empty_body() {
|
||||
let doc = "---\nfoo: 1\n---\n";
|
||||
let (_, b) = split_frontmatter(doc).unwrap();
|
||||
assert_eq!(b, "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Decisions frontmatter schema.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::schema::common::{Frontmatter, SourceRef};
|
||||
use crate::slug::Slug;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum DecisionStatus {
|
||||
Open,
|
||||
Resolved,
|
||||
Replaced,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct DecisionFrontmatter {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub sources: Vec<SourceRef>,
|
||||
pub status: DecisionStatus,
|
||||
#[serde(default)]
|
||||
pub replaced_by: Option<Slug>,
|
||||
}
|
||||
|
||||
impl Frontmatter for DecisionFrontmatter {
|
||||
const BODY_LIMIT: usize = 8000;
|
||||
|
||||
fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at
|
||||
}
|
||||
fn updated_at(&self) -> DateTime<Utc> {
|
||||
self.updated_at
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Knowledge frontmatter schema.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::schema::common::{Frontmatter, SourceRef};
|
||||
|
||||
/// Hard cap on `description` length when `model_invokation: true`.
|
||||
/// Mirrors the agent-skills 1024-char rule for description that lives
|
||||
/// in resident system-prompt budget.
|
||||
pub const KNOWLEDGE_DESCRIPTION_HARD_CAP: usize = 1024;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct KnowledgeFrontmatter {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub kind: String,
|
||||
pub description: String,
|
||||
pub model_invokation: bool,
|
||||
pub user_invocable: bool,
|
||||
pub last_sources: Vec<SourceRef>,
|
||||
}
|
||||
|
||||
impl Frontmatter for KnowledgeFrontmatter {
|
||||
const BODY_LIMIT: usize = 8000;
|
||||
|
||||
fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at
|
||||
}
|
||||
fn updated_at(&self) -> DateTime<Utc> {
|
||||
self.updated_at
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Frontmatter schemas for memory records.
|
||||
//!
|
||||
//! Each record kind has its own typed `*Frontmatter` struct. The linter
|
||||
//! deserializes the YAML between the leading `---` markers into the
|
||||
//! kind-appropriate struct; field-level errors are surfaced as
|
||||
//! [`LintError::MissingField`] / [`LintError::InvalidField`].
|
||||
|
||||
mod common;
|
||||
mod decision;
|
||||
mod knowledge;
|
||||
mod request;
|
||||
mod summary;
|
||||
mod workflow;
|
||||
|
||||
pub use common::{Frontmatter, SourceRef, split_frontmatter};
|
||||
pub use decision::{DecisionFrontmatter, DecisionStatus};
|
||||
pub use knowledge::{KNOWLEDGE_DESCRIPTION_HARD_CAP, KnowledgeFrontmatter};
|
||||
pub use request::RequestFrontmatter;
|
||||
pub use summary::SummaryFrontmatter;
|
||||
pub use workflow::WorkflowFrontmatter;
|
||||
@@ -0,0 +1,24 @@
|
||||
//! Requests frontmatter schema.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::schema::common::{Frontmatter, SourceRef};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct RequestFrontmatter {
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
pub sources: Vec<SourceRef>,
|
||||
}
|
||||
|
||||
impl Frontmatter for RequestFrontmatter {
|
||||
const BODY_LIMIT: usize = 8000;
|
||||
|
||||
fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at
|
||||
}
|
||||
fn updated_at(&self) -> DateTime<Utc> {
|
||||
self.updated_at
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Summary frontmatter schema.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::schema::common::Frontmatter;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct SummaryFrontmatter {
|
||||
pub updated_at: DateTime<Utc>,
|
||||
/// `created_at` is optional for the summary because it's a
|
||||
/// long-lived single file rewritten in place.
|
||||
#[serde(default)]
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
/// Optional pointer to the session-store entry range that drove the
|
||||
/// most recent rewrite.
|
||||
#[serde(default)]
|
||||
pub last_rewritten_from_range: Option<[u64; 2]>,
|
||||
}
|
||||
|
||||
impl Frontmatter for SummaryFrontmatter {
|
||||
/// Summary holds always-on context, so it gets a larger body budget
|
||||
/// than per-record kinds (~5k tokens at the upper end).
|
||||
const BODY_LIMIT: usize = 20000;
|
||||
|
||||
fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at.unwrap_or(self.updated_at)
|
||||
}
|
||||
fn updated_at(&self) -> DateTime<Utc> {
|
||||
self.updated_at
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Workflow frontmatter schema.
|
||||
//!
|
||||
//! NOTE: Workflows are written by humans, not by the memory tool. The
|
||||
//! linter only validates frontmatter when invoked directly (e.g. by a
|
||||
//! future CLI / pre-commit hook). The memory write/edit tool rejects
|
||||
//! `memory/workflow/` paths outright via [`LintError::WorkflowWriteForbidden`].
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::schema::common::Frontmatter;
|
||||
use crate::slug::Slug;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct WorkflowFrontmatter {
|
||||
/// Workflows don't carry sources/created_at requirements in the
|
||||
/// plan doc; only `updated_at` is required at the schema level.
|
||||
pub updated_at: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<DateTime<Utc>>,
|
||||
pub description: String,
|
||||
pub auto_invoke: bool,
|
||||
pub user_invocable: bool,
|
||||
#[serde(default)]
|
||||
pub requires: Vec<Slug>,
|
||||
}
|
||||
|
||||
impl Frontmatter for WorkflowFrontmatter {
|
||||
const BODY_LIMIT: usize = 8000;
|
||||
|
||||
fn created_at(&self) -> DateTime<Utc> {
|
||||
self.created_at.unwrap_or(self.updated_at)
|
||||
}
|
||||
fn updated_at(&self) -> DateTime<Utc> {
|
||||
self.updated_at
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
//! Helpers for constructing `ScopeRule` entries that exclude the
|
||||
//! memory tree from the generic CRUD tools' write surface.
|
||||
//!
|
||||
//! Pod is expected to call [`deny_write_rules`] when memory is enabled
|
||||
//! and append the result to the manifest's `scope.deny` list before
|
||||
//! constructing the [`Scope`] passed to `tools::ScopedFs`. The memory
|
||||
//! tools themselves bypass `ScopedFs` and write directly under the
|
||||
//! workspace root, so this deny does not affect their operation.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use manifest::{Permission, ScopeRule};
|
||||
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
/// Build deny rules that strip Write permission from `<workspace>/memory/`
|
||||
/// and `<workspace>/knowledge/`. Recursive — every descendant is capped
|
||||
/// at Read for the generic tools.
|
||||
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
|
||||
vec![
|
||||
deny_write(layout.memory_dir().as_path()),
|
||||
deny_write(layout.knowledge_dir().as_path()),
|
||||
]
|
||||
}
|
||||
|
||||
fn deny_write(target: &Path) -> ScopeRule {
|
||||
ScopeRule {
|
||||
target: target.to_path_buf(),
|
||||
permission: Permission::Write,
|
||||
recursive: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn deny_targets_memory_and_knowledge() {
|
||||
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
|
||||
let rules = deny_write_rules(&layout);
|
||||
assert_eq!(rules.len(), 2);
|
||||
assert_eq!(rules[0].target, PathBuf::from("/ws/memory"));
|
||||
assert_eq!(rules[0].permission, Permission::Write);
|
||||
assert!(rules[0].recursive);
|
||||
assert_eq!(rules[1].target, PathBuf::from("/ws/knowledge"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Slug type and validation.
|
||||
//!
|
||||
//! Syntax (agent-skills compatible):
|
||||
//! ^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$
|
||||
//! - 1–64 chars
|
||||
//! - lowercase ASCII alphanumerics and `-`
|
||||
//! - cannot start or end with `-`
|
||||
//! - no consecutive `--`
|
||||
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
use crate::error::LintError;
|
||||
|
||||
const MIN_LEN: usize = 1;
|
||||
const MAX_LEN: usize = 64;
|
||||
|
||||
/// Validated slug. Constructible only via [`Slug::parse`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct Slug(String);
|
||||
|
||||
impl Slug {
|
||||
/// Parse and validate. Returns [`LintError::InvalidSlug`] on rejection.
|
||||
pub fn parse(s: impl Into<String>) -> Result<Self, LintError> {
|
||||
let s = s.into();
|
||||
if is_valid_slug(&s) {
|
||||
Ok(Self(s))
|
||||
} else {
|
||||
Err(LintError::InvalidSlug(s))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Slug {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for Slug {
|
||||
fn as_ref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Slug {
|
||||
type Err = LintError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for Slug {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let raw = String::deserialize(deserializer)?;
|
||||
Self::parse(raw).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure-fn predicate matching the agent-skills slug regex without
|
||||
/// pulling in the `regex` crate.
|
||||
pub fn is_valid_slug(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
let len = bytes.len();
|
||||
if len < MIN_LEN || len > MAX_LEN {
|
||||
return false;
|
||||
}
|
||||
if !is_alnum_lower(bytes[0]) || !is_alnum_lower(bytes[len - 1]) {
|
||||
return false;
|
||||
}
|
||||
let mut prev_dash = false;
|
||||
for &b in bytes {
|
||||
if b == b'-' {
|
||||
if prev_dash {
|
||||
return false;
|
||||
}
|
||||
prev_dash = true;
|
||||
} else if is_alnum_lower(b) {
|
||||
prev_dash = false;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn is_alnum_lower(b: u8) -> bool {
|
||||
b.is_ascii_digit() || b.is_ascii_lowercase()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_basic_slugs() {
|
||||
for s in ["a", "ab", "abc-def", "x9", "a-b-c", "123", "a-1"] {
|
||||
assert!(is_valid_slug(s), "expected `{s}` valid");
|
||||
assert!(Slug::parse(s).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_bad_slugs() {
|
||||
for s in [
|
||||
"",
|
||||
"-",
|
||||
"-foo",
|
||||
"foo-",
|
||||
"Foo",
|
||||
"foo_bar",
|
||||
"foo bar",
|
||||
"foo--bar",
|
||||
"foo.bar",
|
||||
"ä",
|
||||
] {
|
||||
assert!(!is_valid_slug(s), "expected `{s}` invalid");
|
||||
assert!(Slug::parse(s).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_length_bounds() {
|
||||
let too_long = "a".repeat(MAX_LEN + 1);
|
||||
assert!(!is_valid_slug(&too_long));
|
||||
let max = "a".repeat(MAX_LEN);
|
||||
assert!(is_valid_slug(&max));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserializes_via_serde() {
|
||||
let json = "\"valid-slug\"";
|
||||
let slug: Slug = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(slug.as_str(), "valid-slug");
|
||||
|
||||
let bad = "\"BAD\"";
|
||||
let err: Result<Slug, _> = serde_json::from_str(bad);
|
||||
assert!(err.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
//! `MemoryEdit` tool — partial string replacement on an existing memory record.
|
||||
//!
|
||||
//! Reads current content, applies the replacement, runs the Linter on
|
||||
//! the result, writes only on success. The current-then-write window
|
||||
//! is single-tool-call narrow; an external tracker is intentionally
|
||||
//! omitted (memory tools are self-contained, no `tools` crate dep).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::linter::{LintReport, Linter, WriteMode};
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
const DESCRIPTION: &str = "Replace a substring in an existing memory or knowledge \
|
||||
record file. By default `old_string` must be unique in the file; set \
|
||||
`replace_all: true` to replace every occurrence. The resulting content is \
|
||||
re-validated by the memory linter; failure leaves the file untouched. Path \
|
||||
must be absolute and lie inside the workspace's `memory/` or `knowledge/` tree.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct EditParams {
|
||||
/// Absolute path under the workspace's `memory/` or `knowledge/` tree.
|
||||
file_path: PathBuf,
|
||||
/// String to replace. Must be unique in the file unless `replace_all` is true.
|
||||
old_string: String,
|
||||
/// Replacement string. Must differ from `old_string`.
|
||||
new_string: String,
|
||||
/// Replace all occurrences. Defaults to false.
|
||||
#[serde(default)]
|
||||
replace_all: bool,
|
||||
}
|
||||
|
||||
struct EditTool {
|
||||
linter: Linter,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for EditTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: EditParams = serde_json::from_str(input_json).map_err(|e| {
|
||||
ToolError::InvalidArgument(format!("invalid MemoryEdit input: {e}"))
|
||||
})?;
|
||||
|
||||
if !params.file_path.is_absolute() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"file_path must be absolute: {}",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
if params.old_string.is_empty() {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"old_string must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if params.old_string == params.new_string {
|
||||
return Err(ToolError::InvalidArgument(
|
||||
"old_string and new_string are identical".into(),
|
||||
));
|
||||
}
|
||||
|
||||
// Path-shape check; the layout::classify also runs inside the
|
||||
// linter but we want a crisp error before reading the file.
|
||||
if self
|
||||
.linter
|
||||
.layout()
|
||||
.classify(¶ms.file_path)
|
||||
.map_err(|e| ToolError::InvalidArgument(e.to_string()))?
|
||||
.is_none()
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"path is not under the memory tree: {}",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let current_bytes = std::fs::read(¶ms.file_path).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ToolError::ExecutionFailed(format!(
|
||||
"file not found (use MemoryWrite to create): {}",
|
||||
params.file_path.display()
|
||||
)),
|
||||
_ => ToolError::ExecutionFailed(format!(
|
||||
"read failed at {}: {e}",
|
||||
params.file_path.display()
|
||||
)),
|
||||
})?;
|
||||
let current_text = std::str::from_utf8(¤t_bytes).map_err(|_| {
|
||||
ToolError::InvalidArgument(format!(
|
||||
"file is not valid UTF-8: {}",
|
||||
params.file_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let count = current_text.matches(¶ms.old_string).count();
|
||||
if count == 0 {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"old_string not found in {}",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
if !params.replace_all && count > 1 {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"old_string occurs {count} times in {}; pass replace_all: true or narrow the snippet",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let new_text = if params.replace_all {
|
||||
current_text.replace(¶ms.old_string, ¶ms.new_string)
|
||||
} else {
|
||||
current_text.replacen(¶ms.old_string, ¶ms.new_string, 1)
|
||||
};
|
||||
let occurrences = if params.replace_all { count } else { 1 };
|
||||
|
||||
let report = self.linter.lint(¶ms.file_path, &new_text, WriteMode::Update);
|
||||
if report.has_errors() {
|
||||
return Err(ToolError::InvalidArgument(format_report(&report)));
|
||||
}
|
||||
|
||||
std::fs::write(¶ms.file_path, new_text.as_bytes()).map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"failed to write {}: {e}",
|
||||
params.file_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let summary = format!(
|
||||
"Edited {} ({} replacement{}){}",
|
||||
params.file_path.display(),
|
||||
occurrences,
|
||||
if occurrences == 1 { "" } else { "s" },
|
||||
warning_tail(&report),
|
||||
);
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report(report: &LintReport) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut buf = String::from("memory linter rejected the edit:");
|
||||
for e in &report.errors {
|
||||
let _ = write!(&mut buf, "\n - {e}");
|
||||
}
|
||||
if !report.warnings.is_empty() {
|
||||
let _ = write!(&mut buf, "\nwarnings (informational):");
|
||||
for w in &report.warnings {
|
||||
let _ = write!(&mut buf, "\n - {w}");
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn warning_tail(report: &LintReport) -> String {
|
||||
if report.warnings.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut s = format!(" [{} warning(s)]", report.warnings.len());
|
||||
for w in &report.warnings {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut s, " {w};");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn edit_tool(layout: WorkspaceLayout) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(EditParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("MemoryEdit")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(EditTool {
|
||||
linter: Linter::new(layout.clone()),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn now() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
fn setup() -> (TempDir, WorkspaceLayout, PathBuf) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let initial = format!(
|
||||
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\nbody body\n",
|
||||
n = now()
|
||||
);
|
||||
std::fs::write(&path, &initial).unwrap();
|
||||
(dir, layout, path)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_simple_replace() {
|
||||
let (_dir, layout, path) = setup();
|
||||
let (meta, tool) = edit_tool(layout)();
|
||||
assert_eq!(meta.name, "MemoryEdit");
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"old_string": "body body",
|
||||
"new_string": "edited",
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("1 replacement"));
|
||||
let after = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(after.contains("edited"));
|
||||
assert!(!after.contains("body body"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_resulting_invalid_frontmatter_rolled_back() {
|
||||
let (_dir, layout, path) = setup();
|
||||
let (_, tool) = edit_tool(layout)();
|
||||
|
||||
// Drop the `status` field by replacing it with nothing.
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"old_string": "status: open\n",
|
||||
"new_string": "",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("status") || msg.contains("missing"));
|
||||
|
||||
// File untouched.
|
||||
let after = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(after.contains("status: open"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_missing_file() {
|
||||
let (dir, layout, _) = setup();
|
||||
let other = dir.path().join("memory/decisions/ghost.md");
|
||||
let (_, tool) = edit_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": other.to_str().unwrap(),
|
||||
"old_string": "x",
|
||||
"new_string": "y",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::ExecutionFailed(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_outside_memory_tree_rejected() {
|
||||
let (dir, layout, _) = setup();
|
||||
let other = dir.path().join("src/lib.rs");
|
||||
std::fs::create_dir_all(other.parent().unwrap()).unwrap();
|
||||
std::fs::write(&other, "fn main() {}").unwrap();
|
||||
let (_, tool) = edit_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": other.to_str().unwrap(),
|
||||
"old_string": "fn",
|
||||
"new_string": "pub fn",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edit_workflow_path_rejected() {
|
||||
let (dir, layout, _) = setup();
|
||||
let path = dir.path().join("memory/workflow/wf.md");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let initial = format!(
|
||||
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\n---\nbody\n",
|
||||
n = now()
|
||||
);
|
||||
std::fs::write(&path, &initial).unwrap();
|
||||
|
||||
let (_, tool) = edit_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"old_string": "body",
|
||||
"new_string": "edited",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.to_lowercase().contains("workflow"), "{msg}");
|
||||
// Original untouched.
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("body"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//! Tool implementations stub. Filled in once the linter compiles green.
|
||||
|
||||
mod edit;
|
||||
mod read;
|
||||
mod write;
|
||||
|
||||
pub use edit::edit_tool;
|
||||
pub use read::read_tool;
|
||||
pub use write::write_tool;
|
||||
@@ -0,0 +1,195 @@
|
||||
//! `MemoryRead` tool.
|
||||
//!
|
||||
//! Constrained to `<workspace>/memory/` and `<workspace>/knowledge/`
|
||||
//! paths. Returns line-numbered content (1-based), like the generic
|
||||
//! Read tool, but rejects anything outside the memory tree so the
|
||||
//! agent can't sneak in a non-memory read through this surface.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
const DESCRIPTION: &str = "Read a memory or knowledge record file under the \
|
||||
workspace's `memory/` or `knowledge/` tree. Returns line-numbered output \
|
||||
(1-based). Paths must be absolute and lie inside the memory tree.";
|
||||
|
||||
const DEFAULT_LIMIT: usize = 2000;
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct ReadParams {
|
||||
/// Absolute path to a file under the workspace's `memory/` or `knowledge/` tree.
|
||||
file_path: PathBuf,
|
||||
/// 0-based line offset from the start. Defaults to 0.
|
||||
#[serde(default)]
|
||||
offset: Option<usize>,
|
||||
/// Maximum number of lines to return. Defaults to 2000.
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
struct ReadTool {
|
||||
layout: WorkspaceLayout,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for ReadTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: ReadParams = serde_json::from_str(input_json).map_err(|e| {
|
||||
ToolError::InvalidArgument(format!("invalid MemoryRead input: {e}"))
|
||||
})?;
|
||||
|
||||
if !params.file_path.is_absolute() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"file_path must be absolute: {}",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
if self
|
||||
.layout
|
||||
.classify(¶ms.file_path)
|
||||
.map_err(|e| ToolError::InvalidArgument(e.to_string()))?
|
||||
.is_none()
|
||||
{
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"path is not under the memory tree: {}",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(¶ms.file_path).map_err(|e| match e.kind() {
|
||||
std::io::ErrorKind::NotFound => ToolError::ExecutionFailed(format!(
|
||||
"file not found: {}",
|
||||
params.file_path.display()
|
||||
)),
|
||||
_ => ToolError::ExecutionFailed(format!(
|
||||
"read failed at {}: {e}",
|
||||
params.file_path.display()
|
||||
)),
|
||||
})?;
|
||||
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let offset = params.offset.unwrap_or(0);
|
||||
let limit = params.limit.unwrap_or(DEFAULT_LIMIT).max(1);
|
||||
let rendered = render_numbered(&text, offset, limit);
|
||||
|
||||
let summary = if rendered.truncated {
|
||||
format!(
|
||||
"Read {} line(s) [{}..{}] of {} from {}",
|
||||
rendered.line_count,
|
||||
offset + 1,
|
||||
offset + rendered.line_count,
|
||||
rendered.total_lines,
|
||||
params.file_path.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"Read {} line(s) from {}",
|
||||
rendered.line_count,
|
||||
params.file_path.display()
|
||||
)
|
||||
};
|
||||
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: Some(rendered.body),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Rendered {
|
||||
body: String,
|
||||
line_count: usize,
|
||||
total_lines: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
fn render_numbered(text: &str, offset: usize, limit: usize) -> Rendered {
|
||||
let all_lines: Vec<&str> = text.lines().collect();
|
||||
let total_lines = all_lines.len();
|
||||
let start = offset.min(total_lines);
|
||||
let end = start.saturating_add(limit).min(total_lines);
|
||||
let slice = &all_lines[start..end];
|
||||
let line_count = slice.len();
|
||||
|
||||
use std::fmt::Write as _;
|
||||
let mut body = String::with_capacity(text.len().saturating_add(line_count * 8));
|
||||
for (i, line) in slice.iter().enumerate() {
|
||||
let lineno = start + i + 1;
|
||||
let _ = writeln!(&mut body, "{:>6}\t{}", lineno, line);
|
||||
}
|
||||
|
||||
Rendered {
|
||||
body,
|
||||
line_count,
|
||||
total_lines,
|
||||
truncated: start > 0 || end < total_lines,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn read_tool(layout: WorkspaceLayout) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(ReadParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("MemoryRead")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(ReadTool {
|
||||
layout: layout.clone(),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn setup() -> (TempDir, WorkspaceLayout) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
(dir, layout)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_returns_numbered_lines() {
|
||||
let (dir, layout) = setup();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, "alpha\nbeta\n").unwrap();
|
||||
|
||||
let (_meta, tool) = read_tool(layout)();
|
||||
let inp = serde_json::json!({ "file_path": path.to_str().unwrap() });
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
let body = out.content.unwrap();
|
||||
assert!(body.contains(" 1\talpha"));
|
||||
assert!(body.contains(" 2\tbeta"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_outside_memory_tree() {
|
||||
let (dir, layout) = setup();
|
||||
let other = dir.path().join("src/main.rs");
|
||||
std::fs::create_dir_all(other.parent().unwrap()).unwrap();
|
||||
std::fs::write(&other, "fn main() {}").unwrap();
|
||||
|
||||
let (_, tool) = read_tool(layout)();
|
||||
let inp = serde_json::json!({ "file_path": other.to_str().unwrap() });
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_relative_path() {
|
||||
let (_dir, layout) = setup();
|
||||
let (_, tool) = read_tool(layout)();
|
||||
let inp = serde_json::json!({ "file_path": "memory/summary.md" });
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! `MemoryWrite` tool.
|
||||
//!
|
||||
//! Creates or overwrites a memory or knowledge record with full content.
|
||||
//! Pre-write Linter validates frontmatter, slug uniqueness (Create only),
|
||||
//! reference integrity, size limits, and the workflow-write ban. On any
|
||||
//! Linter error the tool returns `ToolError::InvalidArgument` with all
|
||||
//! violations aggregated and the file is **not** written.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use llm_worker::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::linter::{LintReport, Linter, WriteMode};
|
||||
use crate::workspace::WorkspaceLayout;
|
||||
|
||||
const DESCRIPTION: &str = "Create or overwrite a memory or knowledge record file. \
|
||||
Path must be absolute and lie inside the workspace's `memory/` or `knowledge/` \
|
||||
tree. Frontmatter is validated before the file is written; on validation \
|
||||
failure no write occurs and every violation is returned in the error message.";
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
struct WriteParams {
|
||||
/// Absolute path under the workspace's `memory/` or `knowledge/` tree.
|
||||
file_path: PathBuf,
|
||||
/// Full file contents (frontmatter + body).
|
||||
content: String,
|
||||
}
|
||||
|
||||
struct WriteTool {
|
||||
linter: Linter,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for WriteTool {
|
||||
async fn execute(&self, input_json: &str) -> Result<ToolOutput, ToolError> {
|
||||
let params: WriteParams = serde_json::from_str(input_json).map_err(|e| {
|
||||
ToolError::InvalidArgument(format!("invalid MemoryWrite input: {e}"))
|
||||
})?;
|
||||
|
||||
if !params.file_path.is_absolute() {
|
||||
return Err(ToolError::InvalidArgument(format!(
|
||||
"file_path must be absolute: {}",
|
||||
params.file_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let already_exists = params.file_path.exists();
|
||||
let mode = if already_exists {
|
||||
WriteMode::Update
|
||||
} else {
|
||||
WriteMode::Create
|
||||
};
|
||||
|
||||
let report = self.linter.lint(¶ms.file_path, ¶ms.content, mode);
|
||||
if report.has_errors() {
|
||||
return Err(ToolError::InvalidArgument(format_report(&report)));
|
||||
}
|
||||
|
||||
if let Some(parent) = params.file_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"failed to create directory {}: {e}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
std::fs::write(¶ms.file_path, params.content.as_bytes()).map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!(
|
||||
"failed to write {}: {e}",
|
||||
params.file_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let summary = format!(
|
||||
"{} {}{}",
|
||||
if already_exists { "Overwrote" } else { "Created" },
|
||||
params.file_path.display(),
|
||||
warning_tail(&report),
|
||||
);
|
||||
Ok(ToolOutput {
|
||||
summary,
|
||||
content: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report(report: &LintReport) -> String {
|
||||
use std::fmt::Write as _;
|
||||
let mut buf = String::from("memory linter rejected the write:");
|
||||
for e in &report.errors {
|
||||
let _ = write!(&mut buf, "\n - {e}");
|
||||
}
|
||||
if !report.warnings.is_empty() {
|
||||
let _ = write!(&mut buf, "\nwarnings (informational):");
|
||||
for w in &report.warnings {
|
||||
let _ = write!(&mut buf, "\n - {w}");
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
fn warning_tail(report: &LintReport) -> String {
|
||||
if report.warnings.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let mut s = format!(" [{} warning(s)]", report.warnings.len());
|
||||
for w in &report.warnings {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(&mut s, " {w};");
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
pub fn write_tool(layout: WorkspaceLayout) -> ToolDefinition {
|
||||
Arc::new(move || {
|
||||
let schema = schemars::schema_for!(WriteParams);
|
||||
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
|
||||
let meta = ToolMeta::new("MemoryWrite")
|
||||
.description(DESCRIPTION)
|
||||
.input_schema(schema_value);
|
||||
let tool: Arc<dyn Tool> = Arc::new(WriteTool {
|
||||
linter: Linter::new(layout.clone()),
|
||||
});
|
||||
(meta, tool)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Utc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn now() -> String {
|
||||
Utc::now().to_rfc3339()
|
||||
}
|
||||
|
||||
fn setup() -> (TempDir, WorkspaceLayout) {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
|
||||
(dir, layout)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_creates_summary() {
|
||||
let (dir, layout) = setup();
|
||||
let path = dir.path().join("memory/summary.md");
|
||||
let content = format!("---\nupdated_at: {n}\n---\nbody\n", n = now());
|
||||
|
||||
let (meta, tool) = write_tool(layout)();
|
||||
assert_eq!(meta.name, "MemoryWrite");
|
||||
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"content": content,
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("Created"));
|
||||
assert!(path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_rejects_workflow() {
|
||||
let (dir, layout) = setup();
|
||||
let path = dir.path().join("memory/workflow/wf.md");
|
||||
let content = format!(
|
||||
"---\nupdated_at: {n}\ndescription: x\nauto_invoke: false\nuser_invocable: true\n---\n",
|
||||
n = now()
|
||||
);
|
||||
let (_, tool) = write_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"content": content,
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("workflow"), "unexpected error: {msg}");
|
||||
assert!(!path.exists(), "workflow file must not be written");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_aggregates_multiple_errors() {
|
||||
let (dir, layout) = setup();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
// Missing required `status` field AND body too long.
|
||||
let huge = "x".repeat(8001);
|
||||
let content = format!(
|
||||
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\n---\n{huge}",
|
||||
n = now()
|
||||
);
|
||||
let (_, tool) = write_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"content": content,
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
let msg = format!("{err}");
|
||||
assert!(msg.contains("status") || msg.contains("missing"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_blocks_create_when_existing() {
|
||||
let (dir, layout) = setup();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let initial = format!(
|
||||
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\nold\n",
|
||||
n = now()
|
||||
);
|
||||
std::fs::write(&path, &initial).unwrap();
|
||||
|
||||
// Same content as a re-write should pass (Update mode).
|
||||
let (_, tool) = write_tool(layout.clone())();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"content": initial,
|
||||
});
|
||||
let out = tool.execute(&inp.to_string()).await.unwrap();
|
||||
assert!(out.summary.contains("Overwrote"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_rejects_non_absolute() {
|
||||
let (_dir, layout) = setup();
|
||||
let (_, tool) = write_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": "memory/summary.md",
|
||||
"content": "ignored",
|
||||
});
|
||||
let err = tool.execute(&inp.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, ToolError::InvalidArgument(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_does_not_persist_on_lint_failure() {
|
||||
let (dir, layout) = setup();
|
||||
let path = dir.path().join("memory/decisions/foo.md");
|
||||
let bad = "no frontmatter at all";
|
||||
let (_, tool) = write_tool(layout)();
|
||||
let inp = serde_json::json!({
|
||||
"file_path": path.to_str().unwrap(),
|
||||
"content": bad,
|
||||
});
|
||||
assert!(tool.execute(&inp.to_string()).await.is_err());
|
||||
assert!(!path.exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Workspace-level path layout for the memory subsystem.
|
||||
//!
|
||||
//! Resolves a workspace root into the concrete directories the linter
|
||||
//! and tools operate on:
|
||||
//!
|
||||
//! - `<root>/memory/summary.md`
|
||||
//! - `<root>/memory/decisions/<slug>.md`
|
||||
//! - `<root>/memory/requests/<slug>.md`
|
||||
//! - `<root>/memory/workflow/<slug>.md`
|
||||
//! - `<root>/memory/_staging/<id>.json`
|
||||
//! - `<root>/knowledge/<slug>.md`
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::error::LintError;
|
||||
use crate::slug::Slug;
|
||||
|
||||
const MEMORY_DIR: &str = "memory";
|
||||
const KNOWLEDGE_DIR: &str = "knowledge";
|
||||
const SUMMARY_FILE: &str = "summary.md";
|
||||
const DECISIONS_DIR: &str = "decisions";
|
||||
const REQUESTS_DIR: &str = "requests";
|
||||
const WORKFLOW_DIR: &str = "workflow";
|
||||
const STAGING_DIR: &str = "_staging";
|
||||
|
||||
/// What kind of record a path under the memory tree represents.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RecordKind {
|
||||
Summary,
|
||||
Decision,
|
||||
Request,
|
||||
Workflow,
|
||||
Knowledge,
|
||||
}
|
||||
|
||||
impl RecordKind {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Summary => "summary",
|
||||
Self::Decision => "decision",
|
||||
Self::Request => "request",
|
||||
Self::Workflow => "workflow",
|
||||
Self::Knowledge => "knowledge",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A path classified into a kind and (where applicable) a slug.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClassifiedPath {
|
||||
pub kind: RecordKind,
|
||||
pub slug: Option<Slug>,
|
||||
}
|
||||
|
||||
/// Workspace-rooted layout. Cheap to clone.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkspaceLayout {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl WorkspaceLayout {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn memory_dir(&self) -> PathBuf {
|
||||
self.root.join(MEMORY_DIR)
|
||||
}
|
||||
|
||||
pub fn knowledge_dir(&self) -> PathBuf {
|
||||
self.root.join(KNOWLEDGE_DIR)
|
||||
}
|
||||
|
||||
pub fn summary_path(&self) -> PathBuf {
|
||||
self.memory_dir().join(SUMMARY_FILE)
|
||||
}
|
||||
|
||||
pub fn decisions_dir(&self) -> PathBuf {
|
||||
self.memory_dir().join(DECISIONS_DIR)
|
||||
}
|
||||
|
||||
pub fn requests_dir(&self) -> PathBuf {
|
||||
self.memory_dir().join(REQUESTS_DIR)
|
||||
}
|
||||
|
||||
pub fn workflow_dir(&self) -> PathBuf {
|
||||
self.memory_dir().join(WORKFLOW_DIR)
|
||||
}
|
||||
|
||||
pub fn staging_dir(&self) -> PathBuf {
|
||||
self.memory_dir().join(STAGING_DIR)
|
||||
}
|
||||
|
||||
pub fn decision_path(&self, slug: &Slug) -> PathBuf {
|
||||
self.decisions_dir().join(format!("{slug}.md"))
|
||||
}
|
||||
|
||||
pub fn request_path(&self, slug: &Slug) -> PathBuf {
|
||||
self.requests_dir().join(format!("{slug}.md"))
|
||||
}
|
||||
|
||||
pub fn workflow_path(&self, slug: &Slug) -> PathBuf {
|
||||
self.workflow_dir().join(format!("{slug}.md"))
|
||||
}
|
||||
|
||||
pub fn knowledge_path(&self, slug: &Slug) -> PathBuf {
|
||||
self.knowledge_dir().join(format!("{slug}.md"))
|
||||
}
|
||||
|
||||
/// Classify a path under the memory tree. Returns `None` if the
|
||||
/// path is not under `memory/` or `knowledge/` of this workspace,
|
||||
/// or if it lives in `_staging/` (which is opaque to the linter).
|
||||
///
|
||||
/// On a conventional path that's *almost* a record but malformed
|
||||
/// (e.g. `decisions/Foo.md` with an invalid slug), returns
|
||||
/// `Err(LintError::InvalidSlug | InvalidPath)` so the caller can
|
||||
/// surface it as a write violation.
|
||||
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
|
||||
let memory = self.memory_dir();
|
||||
let knowledge = self.knowledge_dir();
|
||||
|
||||
if let Ok(rel) = path.strip_prefix(&knowledge) {
|
||||
return Ok(Some(classify_kinded_md(
|
||||
rel,
|
||||
RecordKind::Knowledge,
|
||||
path,
|
||||
)?));
|
||||
}
|
||||
let rel = match path.strip_prefix(&memory) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
|
||||
let mut comps = rel.components();
|
||||
let first = match comps.next() {
|
||||
Some(c) => c.as_os_str(),
|
||||
None => return Err(LintError::InvalidPath(path.to_path_buf())),
|
||||
};
|
||||
|
||||
if first == SUMMARY_FILE {
|
||||
if comps.next().is_some() {
|
||||
return Err(LintError::InvalidPath(path.to_path_buf()));
|
||||
}
|
||||
return Ok(Some(ClassifiedPath {
|
||||
kind: RecordKind::Summary,
|
||||
slug: None,
|
||||
}));
|
||||
}
|
||||
if first == STAGING_DIR {
|
||||
// Linter opts out of `_staging/`; Phase 1 handles its schema.
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let kind = if first == DECISIONS_DIR {
|
||||
RecordKind::Decision
|
||||
} else if first == REQUESTS_DIR {
|
||||
RecordKind::Request
|
||||
} else if first == WORKFLOW_DIR {
|
||||
RecordKind::Workflow
|
||||
} else {
|
||||
return Err(LintError::InvalidPath(path.to_path_buf()));
|
||||
};
|
||||
|
||||
let rest: PathBuf = comps.collect();
|
||||
let cp = classify_kinded_md(&rest, kind, path)?;
|
||||
Ok(Some(cp))
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_kinded_md(
|
||||
rel: &Path,
|
||||
kind: RecordKind,
|
||||
full_path: &Path,
|
||||
) -> Result<ClassifiedPath, LintError> {
|
||||
let mut comps = rel.components();
|
||||
let first = match comps.next() {
|
||||
Some(c) => c,
|
||||
None => return Err(LintError::InvalidPath(full_path.to_path_buf())),
|
||||
};
|
||||
if comps.next().is_some() {
|
||||
// Subdirectories under the record kind aren't allowed.
|
||||
return Err(LintError::InvalidPath(full_path.to_path_buf()));
|
||||
}
|
||||
let name = first.as_os_str();
|
||||
let s = name
|
||||
.to_str()
|
||||
.ok_or_else(|| LintError::InvalidPath(full_path.to_path_buf()))?;
|
||||
let stem = s
|
||||
.strip_suffix(".md")
|
||||
.ok_or_else(|| LintError::InvalidPath(full_path.to_path_buf()))?;
|
||||
let slug = Slug::parse(stem)?;
|
||||
Ok(ClassifiedPath {
|
||||
kind,
|
||||
slug: Some(slug),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn layout() -> WorkspaceLayout {
|
||||
WorkspaceLayout::new(PathBuf::from("/ws"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_summary() {
|
||||
let cp = layout()
|
||||
.classify(&PathBuf::from("/ws/memory/summary.md"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cp.kind, RecordKind::Summary);
|
||||
assert!(cp.slug.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_decision_with_slug() {
|
||||
let cp = layout()
|
||||
.classify(&PathBuf::from("/ws/memory/decisions/foo-bar.md"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cp.kind, RecordKind::Decision);
|
||||
assert_eq!(cp.slug.unwrap().as_str(), "foo-bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_knowledge() {
|
||||
let cp = layout()
|
||||
.classify(&PathBuf::from("/ws/knowledge/x.md"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cp.kind, RecordKind::Knowledge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_workflow() {
|
||||
let cp = layout()
|
||||
.classify(&PathBuf::from("/ws/memory/workflow/wf.md"))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(cp.kind, RecordKind::Workflow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staging_returns_none() {
|
||||
assert!(
|
||||
layout()
|
||||
.classify(&PathBuf::from("/ws/memory/_staging/abc.json"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_returns_none() {
|
||||
assert!(
|
||||
layout()
|
||||
.classify(&PathBuf::from("/elsewhere/file.md"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
layout()
|
||||
.classify(&PathBuf::from("/ws/src/main.rs"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_slug_rejected() {
|
||||
let err = layout()
|
||||
.classify(&PathBuf::from("/ws/memory/decisions/Foo.md"))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, LintError::InvalidSlug(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_under_record_dir_rejected() {
|
||||
let err = layout()
|
||||
.classify(&PathBuf::from("/ws/memory/decisions/sub/foo.md"))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, LintError::InvalidPath(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_top_level_dir_rejected() {
|
||||
let err = layout()
|
||||
.classify(&PathBuf::from("/ws/memory/something/foo.md"))
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, LintError::InvalidPath(_)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user