use crate::extras::dirge_paths::ProjectPaths;
pub const MEMORY_CURATOR_PROMPT: &str = "You are running as dirge's background memory CURATOR. Your job is to consolidate \
the project's MEMORY.md and PITFALLS.md so they stay accurate and compact, NOT to add new facts. \
You have ONLY the `memory` tool available — no read/write/edit/bash/skill tools are loaded. \
\n\n\
The mechanical pass below identified stale candidates: entries first observed ≥ 30 days ago. \
Stale ≠ obsolete; many old facts are still load-bearing. Read each candidate carefully against \
the rest of the memory store before acting. \
\n\n\
Preference order — prefer the earliest that fits:\n\
1. KEEP. Most entries should be kept untouched. \"Old\" is not a reason to act.\n\
2. CONSOLIDATE. If two or more entries cover the same fact, merge them into one \
clearer entry using `memory(action='replace', ...)` then `memory(action='remove', ...)` for \
the redundant copies.\n\
3. RESTRUCTURE. If one entry mixed unrelated concerns, split it via \
`memory(action='replace', ...)` to the cleaner of the two facts, then `memory(action='add', ...)` \
the other. This is rare — only do it when the entry is genuinely two facts wearing one coat.\n\
4. REMOVE. Only if the entry is clearly obsolete (refers to a deleted file, a renamed binary, \
a long-superseded approach the project no longer uses). Show your reasoning in your thinking before \
removing. Removal ARCHIVES the entry (it can be restored with `memory(action='restore', ...)`), \
so a justified removal is recoverable. The `old_text` argument also accepts the entry's exact \
`urn:ump:...` id from the stale-candidate table below when a substring would be ambiguous.\n\
\n\
PROMOTE durable working memory. `working`-kind entries are transient session scratch and are the \
first to be evicted from context. The promotion-candidates table below lists `working` entries that \
have outlived their session. For any whose content is a durable project fact or convention — a build/test \
command, a design decision, a recurring quirk — and whose Uses count shows it has actually been consulted, \
re-classify it with `memory(action='replace', old_text='<id-or-substring>', content='<same or lightly cleaned text>', \
kind='procedural')` (use `semantic` for a pure fact about the project, `procedural` for a how-to/rule). \
This keeps the knowledge in long-term memory instead of letting it decay. Leave genuinely transient notes \
(\"currently refactoring X\") as `working`; a low or zero Uses count is a reason NOT to promote.\n\
\n\
Do NOT:\n\
• Add new facts. The curator is for consolidation, not capture. Background review handles capture.\n\
• Reword for style. Only change wording when consolidating duplicates or fixing a fact that's \
now wrong.\n\
• Remove pitfalls eagerly. A pitfall surviving 90 days probably caught someone.\n\
\n\
Target shape: the memory file at the end of your pass should have STRICTLY FEWER OR EQUAL entries \
to the start, each one carrying a fact that's still true. \"Nothing to consolidate.\" is a valid \
outcome and is often the right answer.\n\
\n\
Below is the current memory store and the stale candidates the mechanical pass flagged. \
Operate on these only.";
const STALE_AFTER_DAYS: u64 = 30;
const PROMOTE_MIN_AGE_DAYS: u64 = 7;
const BUDGET_PRESSURE_PCT: u32 = 90;
#[allow(dead_code)]
const ARCHIVE_AFTER_STALE_DAYS: u64 = 90;
const PURGE_RETIRED_AFTER_DAYS: u64 = 180;
const INTERVAL_HOURS: u64 = 168;
pub struct MemoryCurator {
paths: ProjectPaths,
clock: crate::extras::curator_clock::CuratorClock,
}
impl MemoryCurator {
pub fn new(paths: &ProjectPaths) -> Result<Self, String> {
let clock = crate::extras::curator_clock::CuratorClock::new(
paths,
paths.memory_dir().join(".curator_state"),
INTERVAL_HOURS,
crate::extras::curator_clock::DEFAULT_MIN_SESSIONS_FIRST_RUN,
)?;
Ok(Self {
paths: paths.clone(),
clock,
})
}
pub fn should_run_now(&mut self) -> bool {
self.clock.should_run_now()
}
pub fn run_mechanical_pass(&mut self) -> Result<MechanicalReport, String> {
let started_at = chrono::Utc::now();
let started_at_iso = started_at.to_rfc3339();
let started_at_filename = started_at.format("%Y%m%d-%H%M%S").to_string();
let store = crate::extras::memory_db::SqliteMemoryStore::load(&self.paths)?;
let decayed = store
.apply_disuse_decay(STALE_AFTER_DAYS as i64)
.unwrap_or_else(|e| {
tracing::warn!(
target: "dirge::memory_curator",
error = %e,
"disuse decay failed — continuing pass",
);
0
});
let purged = store
.purge_retired_rows(PURGE_RETIRED_AFTER_DAYS as i64)
.unwrap_or_else(|e| {
tracing::warn!(
target: "dirge::memory_curator",
error = %e,
"retired-row purge failed — continuing pass",
);
0
});
let entries = store.entries_for_curation()?;
let total_entries = entries.len();
let recent_use_cutoff =
(started_at - chrono::Duration::days(STALE_AFTER_DAYS as i64)).to_rfc3339();
let mut stale_candidates: Vec<StaleCandidate> = Vec::new();
let mut promotion_candidates: Vec<PromotionCandidate> = Vec::new();
for entry in &entries {
let Ok(first_seen) = chrono::DateTime::parse_from_rfc3339(&entry.created_at) else {
continue;
};
let age_secs = started_at.timestamp() - first_seen.timestamp();
let age_days = (age_secs.max(0) as u64) / 86400;
let recently_used = entry
.last_used_at
.as_deref()
.map(|t| t > recent_use_cutoff.as_str())
.unwrap_or(false);
if entry.kind == "working" && age_days >= PROMOTE_MIN_AGE_DAYS {
promotion_candidates.push(PromotionCandidate {
target: entry.target.clone(),
entry_id: entry.uid.clone(),
preview: crate::text::first_line_preview(&entry.content),
age_days,
use_count: entry.use_count,
});
}
if age_days >= STALE_AFTER_DAYS && !recently_used {
stale_candidates.push(StaleCandidate {
target: entry.target.clone(),
entry_id: entry.uid.clone(),
preview: crate::text::first_line_preview(&entry.content),
age_days,
use_count: entry.use_count,
});
}
}
stale_candidates.sort_by_key(|c| std::cmp::Reverse(c.age_days));
promotion_candidates.sort_by_key(|c| std::cmp::Reverse(c.use_count));
let pressure_targets: Vec<String> = ["memory", "pitfalls"]
.iter()
.filter(|t| store.hot_usage_pct(t) >= BUDGET_PRESSURE_PCT)
.map(|t| t.to_string())
.collect();
self.clock.mark_ran()?;
let report = MechanicalReport {
started_at_iso: started_at_iso.clone(),
total_entries,
decayed,
purged,
pressure_targets,
stale_candidates,
promotion_candidates,
};
let reports_dir = self
.paths
.memory_dir()
.join(".curator_reports")
.join(&started_at_filename);
std::fs::create_dir_all(&reports_dir).map_err(|e| format!("create reports dir: {e}"))?;
let report_path = reports_dir.join("REPORT.md");
std::fs::write(&report_path, report.to_markdown())
.map_err(|e| format!("write report: {e}"))?;
Ok(report)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MechanicalReport {
pub started_at_iso: String,
pub total_entries: usize,
pub decayed: usize,
pub purged: usize,
pub pressure_targets: Vec<String>,
pub stale_candidates: Vec<StaleCandidate>,
pub promotion_candidates: Vec<PromotionCandidate>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromotionCandidate {
pub target: String,
pub entry_id: String,
pub preview: String,
pub age_days: u64,
pub use_count: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StaleCandidate {
pub target: String,
pub entry_id: String,
pub preview: String,
pub age_days: u64,
pub use_count: i64,
}
impl MechanicalReport {
pub fn to_markdown(&self) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "# Memory curator — mechanical pass\n");
let _ = writeln!(out, "- Started: {}", self.started_at_iso);
let _ = writeln!(out, "- Total entries: {}", self.total_entries);
let _ = writeln!(out, "- Salience decayed (disuse): {}", self.decayed);
let _ = writeln!(out, "- Retired rows purged: {}", self.purged);
if !self.pressure_targets.is_empty() {
let _ = writeln!(
out,
"- Budget pressure (≥ {BUDGET_PRESSURE_PCT}%): {}",
self.pressure_targets.join(", "),
);
}
let _ = writeln!(out, "- Stale candidates: {}", self.stale_candidates.len());
let _ = writeln!(
out,
"- Working promotion candidates: {}",
self.promotion_candidates.len(),
);
if !self.stale_candidates.is_empty() {
let _ = writeln!(
out,
"\n## Stale candidates (≥ {STALE_AFTER_DAYS} days, no recent use)\n",
);
let _ = writeln!(out, "| Target | Age (days) | Uses | Entry ID | Preview |");
let _ = writeln!(out, "|---|---|---|---|---|");
for c in &self.stale_candidates {
let _ = writeln!(
out,
"| `{}` | {} | {} | `{}` | {} |",
c.target,
c.age_days,
c.use_count,
c.entry_id,
c.preview.replace('|', "\\|"),
);
}
}
let _ = writeln!(
out,
"\n_Mechanical pass only \u{2014} no entries archived. LLM consolidation pass (dirge-mo0w PR-2) decides actual fate._"
);
out
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct LlmCuratorReport {
pub started_at_iso: String,
pub elapsed_secs: f64,
pub stale_candidates: Vec<StaleCandidate>,
pub tool_actions: Vec<String>,
pub error: Option<String>,
}
impl LlmCuratorReport {
pub fn to_markdown(&self) -> String {
use std::collections::BTreeMap;
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(out, "# Memory curator — LLM consolidation pass\n");
let _ = writeln!(out, "- Started: {}", self.started_at_iso);
let _ = writeln!(out, "- Elapsed: {:.2}s", self.elapsed_secs);
let _ = writeln!(
out,
"- Outcome: {}",
if self.error.is_some() {
"error"
} else if self.tool_actions.is_empty() {
"no-op (LLM chose to keep all candidates)"
} else {
"modified memory entries"
}
);
if let Some(err) = &self.error {
let _ = writeln!(out, "- Error: `{err}`");
}
let mut histogram: BTreeMap<&str, usize> = BTreeMap::new();
for action in &self.tool_actions {
*histogram.entry(action.as_str()).or_insert(0) += 1;
}
if !histogram.is_empty() {
let _ = writeln!(out, "\n## Tool calls\n");
for (name, count) in &histogram {
let _ = writeln!(out, "- `{name}` × {count}");
}
}
if !self.stale_candidates.is_empty() {
let _ = writeln!(out, "\n## Stale candidates given to the LLM\n");
let _ = writeln!(out, "| Target | Age (days) | Uses | Entry ID | Preview |");
let _ = writeln!(out, "|---|---|---|---|---|");
for c in &self.stale_candidates {
let _ = writeln!(
out,
"| `{}` | {} | {} | `{}` | {} |",
c.target,
c.age_days,
c.use_count,
c.entry_id,
c.preview.replace('|', "\\|"),
);
}
}
out
}
}
pub fn render_curator_input(
report: &MechanicalReport,
memory_md: &str,
pitfalls_md: &str,
) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(
out,
"\nEach entry is prefixed `[kind | N uses | conf C | id]`: its memory kind, how many times \
the agent has looked it up, its confidence (0–1 truth-likelihood; entries the user \
contradicted are held lower), and its `urn:ump:…` id (pass that id as `old_text` to act \
on exactly that entry). Uses is a signal of how load-bearing an entry is — but a low \
count is NOT on its own a reason to remove; keep facts that are still true. A low \
confidence is a reason to VERIFY a fact, not to delete it blindly."
);
let _ = writeln!(out, "\n## Current MEMORY.md\n");
if memory_md.trim().is_empty() {
let _ = writeln!(out, "_(empty)_");
} else {
let _ = writeln!(out, "{}", memory_md.trim_end());
}
let _ = writeln!(out, "\n## Current PITFALLS.md\n");
if pitfalls_md.trim().is_empty() {
let _ = writeln!(out, "_(empty)_");
} else {
let _ = writeln!(out, "{}", pitfalls_md.trim_end());
}
if !report.pressure_targets.is_empty() {
let _ = writeln!(
out,
"\n## Budget pressure\n\nThe following target(s) are at ≥ {BUDGET_PRESSURE_PCT}% of \
their inline budget: {}. For these, consolidating YOUNGER overlapping or \
contradictory entries is also in scope — the <30-day rule is relaxed under pressure.",
report.pressure_targets.join(", "),
);
}
let _ = writeln!(
out,
"\n## Stale candidates flagged by mechanical pass ({})\n",
report.stale_candidates.len(),
);
if report.stale_candidates.is_empty() {
let _ = writeln!(
out,
"_None. The mechanical pass found no entries ≥ {STALE_AFTER_DAYS} days old without recent use._"
);
} else {
let _ = writeln!(
out,
"Uses = how many times the agent looked the entry up; 0 means nothing has needed it.\n"
);
let _ = writeln!(out, "| Target | Age (days) | Uses | Entry ID | Preview |");
let _ = writeln!(out, "|---|---|---|---|---|");
for c in &report.stale_candidates {
let _ = writeln!(
out,
"| `{}` | {} | {} | `{}` | {} |",
c.target,
c.age_days,
c.use_count,
c.entry_id,
c.preview.replace('|', "\\|"),
);
}
}
if !report.promotion_candidates.is_empty() {
let _ = writeln!(
out,
"\n## Working-memory promotion candidates ({})\n",
report.promotion_candidates.len(),
);
let _ = writeln!(
out,
"These `working` entries are older than {PROMOTE_MIN_AGE_DAYS} days. Promote the ones \
whose content is a durable fact/convention AND whose Uses count shows it's been \
consulted; leave transient notes as working.\n"
);
let _ = writeln!(out, "| Target | Age (days) | Uses | Entry ID | Preview |");
let _ = writeln!(out, "|---|---|---|---|---|");
for c in &report.promotion_candidates {
let _ = writeln!(
out,
"| `{}` | {} | {} | `{}` | {} |",
c.target,
c.age_days,
c.use_count,
c.entry_id,
c.preview.replace('|', "\\|"),
);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_project() -> (ProjectPaths, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!(
"dirge-memory-curator-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let paths = ProjectPaths::new(&dir);
std::fs::create_dir_all(paths.memory_dir()).unwrap();
(paths, dir)
}
fn seed_memory(paths: &ProjectPaths, target: &str, entries: &[&str]) {
let store = crate::extras::memory_db::SqliteMemoryStore::load(paths).unwrap();
for entry in entries {
store.add_entry(target, entry, None).unwrap();
}
}
fn backdate_entry(paths: &ProjectPaths, content: &str, days: i64) {
let conn = crate::extras::memory_db::raw_conn(paths);
let then = (chrono::Utc::now() - chrono::Duration::days(days)).to_rfc3339();
let changed = conn
.execute(
"UPDATE memories SET created_at = ?1 WHERE content = ?2",
rusqlite::params![then, content],
)
.unwrap();
assert_eq!(changed, 1, "backdate must hit exactly one row");
}
fn seed_sessions(paths: &ProjectPaths, n: usize) {
let db = crate::extras::session_db::SessionDb::open(&paths.session_db_path()).unwrap();
for i in 0..n {
db.insert_session(
&format!("sess-{i}"),
"cli",
"gpt-5",
"openai",
"2026-05-01T10:00:00Z",
)
.unwrap();
}
}
#[test]
fn first_run_gated_on_session_count_not_calendar() {
let (paths, _tmp) = temp_project();
std::fs::create_dir_all(paths.sessions_dir()).unwrap();
seed_sessions(&paths, 2);
let mut curator = MemoryCurator::new(&paths).unwrap();
assert!(
!curator.should_run_now(),
"2 sessions — first run still deferred"
);
seed_sessions(&paths, 10); let mut curator = MemoryCurator::new(&paths).unwrap();
assert!(
curator.should_run_now(),
"enough sessions — first run fires without a 7-day wait"
);
curator.run_mechanical_pass().unwrap();
assert!(curator.clock.last_run().is_some());
assert!(!curator.should_run_now(), "interval gate applies after");
}
fn write_state(paths: &ProjectPaths, last_run: u64) {
std::fs::create_dir_all(paths.memory_dir()).unwrap();
std::fs::write(
paths.memory_dir().join(".curator_state"),
format!(r#"{{"last_run": {last_run}, "first_check": {last_run}}}"#),
)
.unwrap();
}
#[test]
fn should_run_now_respects_interval_gate() {
let (paths, _tmp) = temp_project();
write_state(&paths, crate::time_util::now_unix_secs());
let mut curator = MemoryCurator::new(&paths).unwrap();
assert!(
!curator.should_run_now(),
"must respect 7-day interval gate",
);
}
#[test]
fn should_run_now_returns_true_after_interval_elapsed() {
let (paths, _tmp) = temp_project();
write_state(
&paths,
crate::time_util::now_unix_secs().saturating_sub(8 * 24 * 3600),
);
let mut curator = MemoryCurator::new(&paths).unwrap();
assert!(curator.should_run_now(), "after 8 days the gate must open");
}
#[test]
fn run_mechanical_pass_handles_empty_memory_store() {
let (paths, _tmp) = temp_project();
let mut curator = MemoryCurator::new(&paths).unwrap();
let report = curator.run_mechanical_pass().unwrap();
assert_eq!(report.total_entries, 0);
assert_eq!(report.stale_candidates.len(), 0);
assert!(curator.clock.last_run().is_some());
}
#[test]
fn run_mechanical_pass_records_fresh_entries_without_marking_stale() {
let (paths, _tmp) = temp_project();
seed_memory(&paths, "memory", &["fact 1", "fact 2"]);
seed_memory(&paths, "pitfalls", &["pitfall 1"]);
let mut curator = MemoryCurator::new(&paths).unwrap();
let report = curator.run_mechanical_pass().unwrap();
assert_eq!(report.total_entries, 3);
assert_eq!(
report.stale_candidates.len(),
0,
"freshly-observed entries can't be stale yet",
);
}
#[test]
fn run_mechanical_pass_identifies_old_entries_as_stale() {
let (paths, _tmp) = temp_project();
seed_memory(&paths, "memory", &["old fact", "new fact"]);
backdate_entry(&paths, "old fact", 31);
let mut curator = MemoryCurator::new(&paths).unwrap();
let report = curator.run_mechanical_pass().unwrap();
let stale_targets: Vec<&str> = report
.stale_candidates
.iter()
.map(|c| c.preview.as_str())
.collect();
assert!(
stale_targets.contains(&"old fact"),
"old entry must be marked stale: {stale_targets:?}",
);
assert!(
!stale_targets.contains(&"new fact"),
"fresh entry must NOT be stale: {stale_targets:?}",
);
assert!(
report.stale_candidates[0].entry_id.starts_with("urn:ump:"),
"candidate id must be the row uid: {:?}",
report.stale_candidates[0].entry_id,
);
}
#[test]
fn recently_used_old_entries_are_not_stale() {
let (paths, _tmp) = temp_project();
seed_memory(&paths, "memory", &["consulted fact", "ignored fact"]);
backdate_entry(&paths, "consulted fact", 60);
backdate_entry(&paths, "ignored fact", 60);
let store = crate::extras::memory_db::SqliteMemoryStore::load(&paths).unwrap();
store.expand_entry("consulted fact").unwrap();
let mut curator = MemoryCurator::new(&paths).unwrap();
let report = curator.run_mechanical_pass().unwrap();
let stale: Vec<&str> = report
.stale_candidates
.iter()
.map(|c| c.preview.as_str())
.collect();
assert!(
!stale.contains(&"consulted fact"),
"recently-used entry must not be stale: {stale:?}"
);
assert!(
stale.contains(&"ignored fact"),
"unused old entry still flags: {stale:?}"
);
assert_eq!(report.stale_candidates[0].use_count, 0);
}
#[test]
fn mechanical_pass_applies_disuse_decay() {
let (paths, _tmp) = temp_project();
seed_memory(&paths, "memory", &["old unused", "young entry"]);
backdate_entry(&paths, "old unused", 45);
let mut curator = MemoryCurator::new(&paths).unwrap();
let report = curator.run_mechanical_pass().unwrap();
assert_eq!(report.decayed, 1, "exactly the old unused entry decays");
let conn = crate::extras::memory_db::raw_conn(&paths);
let (old_sal, young_sal): (f64, f64) = (
conn.query_row(
"SELECT salience FROM memories WHERE content = 'old unused'",
[],
|r| r.get(0),
)
.unwrap(),
conn.query_row(
"SELECT salience FROM memories WHERE content = 'young entry'",
[],
|r| r.get(0),
)
.unwrap(),
);
assert!(old_sal < 0.5, "default 0.5 must have decayed: {old_sal}");
assert!(
(young_sal - 0.5).abs() < 1e-9,
"young entry untouched: {young_sal}"
);
}
#[test]
fn mechanical_pass_flags_budget_pressure() {
let (paths, _tmp) = temp_project();
let big_one = format!("one {}", "a".repeat(1024));
let big_two = format!("two {}", "b".repeat(1024));
seed_memory(&paths, "memory", &[&big_one, &big_two]);
let mut curator = MemoryCurator::new(&paths).unwrap();
let report = curator.run_mechanical_pass().unwrap();
assert_eq!(
report.pressure_targets,
vec!["memory".to_string()],
"memory target under pressure, pitfalls not"
);
}
#[test]
fn run_mechanical_pass_writes_audit_report_to_disk() {
let (paths, _tmp) = temp_project();
seed_memory(&paths, "memory", &["one fact"]);
let mut curator = MemoryCurator::new(&paths).unwrap();
curator.run_mechanical_pass().unwrap();
let reports_root = paths.memory_dir().join(".curator_reports");
assert!(reports_root.is_dir(), "reports root must exist");
let entries: Vec<_> = std::fs::read_dir(&reports_root)
.unwrap()
.filter_map(|e| e.ok())
.collect();
assert_eq!(entries.len(), 1, "exactly one run directory per run");
let report_md = entries[0].path().join("REPORT.md");
assert!(report_md.is_file(), "REPORT.md must be written");
let body = std::fs::read_to_string(&report_md).unwrap();
assert!(body.contains("# Memory curator"));
assert!(body.contains("Total entries: 1"));
}
#[test]
fn run_mechanical_pass_reflects_removed_entries() {
let (paths, _tmp) = temp_project();
seed_memory(&paths, "memory", &["doomed fact"]);
let mut curator = MemoryCurator::new(&paths).unwrap();
let report = curator.run_mechanical_pass().unwrap();
assert_eq!(report.total_entries, 1);
let store = crate::extras::memory_db::SqliteMemoryStore::load(&paths).unwrap();
store.remove_entry("memory", "doomed fact").unwrap();
let mut curator2 = MemoryCurator::new(&paths).unwrap();
let report = curator2.run_mechanical_pass().unwrap();
assert_eq!(report.total_entries, 0, "removed entry must disappear");
}
#[test]
fn run_mechanical_pass_persists_last_run_timestamp() {
let (paths, _tmp) = temp_project();
seed_memory(&paths, "memory", &["whatever"]);
let mut curator = MemoryCurator::new(&paths).unwrap();
curator.run_mechanical_pass().unwrap();
let last_run = curator.clock.last_run();
let curator2 = MemoryCurator::new(&paths).unwrap();
assert_eq!(
curator2.clock.last_run(),
last_run,
"state must round-trip through disk",
);
}
#[test]
fn report_markdown_disclaims_actual_archival() {
let report = MechanicalReport {
started_at_iso: "2026-05-28T12:00:00Z".to_string(),
total_entries: 5,
decayed: 0,
purged: 0,
pressure_targets: vec![],
stale_candidates: vec![],
promotion_candidates: vec![],
};
let md = report.to_markdown();
assert!(
md.contains("no entries archived"),
"PR-1 must disclaim mechanical-only scope: {md}",
);
}
fn make_report(stale: Vec<StaleCandidate>) -> MechanicalReport {
MechanicalReport {
started_at_iso: "2026-05-28T12:00:00Z".to_string(),
total_entries: stale.len(),
decayed: 0,
purged: 0,
pressure_targets: vec![],
stale_candidates: stale,
promotion_candidates: vec![],
}
}
#[test]
fn render_curator_input_includes_memory_pitfalls_and_stale_table() {
let report = make_report(vec![StaleCandidate {
target: "memory".to_string(),
entry_id: "abc123".to_string(),
preview: "old fact".to_string(),
age_days: 45,
use_count: 3,
}]);
let out = render_curator_input(&report, "fact A\n§\nfact B", "pitfall X");
assert!(out.contains("## Current MEMORY.md"));
assert!(out.contains("fact A"));
assert!(out.contains("## Current PITFALLS.md"));
assert!(out.contains("pitfall X"));
assert!(out.contains("## Stale candidates"));
assert!(out.contains("abc123"));
assert!(out.contains("old fact"));
assert!(out.contains("45"));
assert!(out.contains("Uses"), "usage column rendered: {out}");
}
#[test]
fn render_curator_input_includes_pressure_note() {
let mut report = make_report(vec![]);
report.pressure_targets = vec!["memory".to_string()];
let out = render_curator_input(&report, "fact A", "");
assert!(out.contains("## Budget pressure"), "{out}");
assert!(out.contains("YOUNGER"), "{out}");
}
#[test]
fn render_curator_input_marks_empty_stores_explicitly() {
let report = make_report(vec![]);
let out = render_curator_input(&report, "", "");
assert!(out.contains("## Current MEMORY.md"));
assert!(out.contains("_(empty)_"));
assert!(out.contains("## Current PITFALLS.md"));
assert!(out.contains("None. The mechanical pass found no entries"));
}
#[test]
fn curator_prompt_instructs_working_promotion() {
let p = MEMORY_CURATOR_PROMPT.to_lowercase();
assert!(p.contains("promote"), "prompt must mention promotion");
assert!(p.contains("working"), "prompt must name the working kind");
assert!(
p.contains("procedural") || p.contains("semantic"),
"prompt must name the promotion target kind"
);
}
#[test]
fn mechanical_pass_collects_durable_working_candidates() {
let (paths, _tmp) = temp_project();
let store = crate::extras::memory_db::SqliteMemoryStore::load(&paths).unwrap();
store
.add_entry(
"memory",
"build: cargo test --bin dirge",
Some(crate::extras::memory_db::MemoryKind::Working),
)
.unwrap();
store
.add_entry(
"memory",
"fresh scratch note",
Some(crate::extras::memory_db::MemoryKind::Working),
)
.unwrap();
store
.add_entry(
"memory",
"a durable project fact",
Some(crate::extras::memory_db::MemoryKind::Semantic),
)
.unwrap();
backdate_entry(&paths, "build: cargo test --bin dirge", 10);
drop(store);
let mut curator = MemoryCurator::new(&paths).unwrap();
let report = curator.run_mechanical_pass().unwrap();
let previews: Vec<&str> = report
.promotion_candidates
.iter()
.map(|c| c.preview.as_str())
.collect();
assert!(
previews.iter().any(|p| p.contains("cargo test")),
"aged working entry is a promotion candidate: {previews:?}"
);
assert!(
!previews.iter().any(|p| p.contains("fresh scratch")),
"a same-session working note is not yet a candidate: {previews:?}"
);
assert!(
!previews.iter().any(|p| p.contains("durable project fact")),
"non-working entries are never promotion candidates: {previews:?}"
);
}
#[test]
fn render_curator_input_lists_promotion_candidates() {
let mut report = make_report(vec![]);
report.promotion_candidates = vec![PromotionCandidate {
target: "memory".to_string(),
entry_id: "urn:ump:abc".to_string(),
preview: "build: cargo test --bin dirge".to_string(),
age_days: 12,
use_count: 3,
}];
let out = render_curator_input(&report, "[working] build: cargo test --bin dirge", "");
assert!(
out.contains("promotion candidates"),
"render must include the promotion section: {out}"
);
assert!(
out.contains("build: cargo test --bin dirge"),
"candidate preview must appear: {out}"
);
}
#[test]
fn render_curator_input_explains_entry_metadata() {
let out = render_curator_input(
&make_report(vec![]),
"[procedural | 2 uses | conf 0.60 | urn:ump:x]\nsome durable fact",
"",
);
assert!(
out.contains("[kind | N uses | conf C | id]"),
"input must explain the per-entry metadata prefix: {out}"
);
assert!(
out.contains("urn:ump:x") && out.contains("some durable fact"),
"annotated entry passes through: {out}"
);
}
#[test]
fn llm_curator_report_markdown_includes_actions_and_candidates() {
let r = LlmCuratorReport {
started_at_iso: "2026-05-28T12:00:00Z".to_string(),
elapsed_secs: 4.2,
stale_candidates: vec![StaleCandidate {
target: "pitfalls".to_string(),
entry_id: "deadbeef00000000".to_string(),
preview: "stale pitfall".to_string(),
age_days: 100,
use_count: 0,
}],
tool_actions: vec!["memory".to_string(), "memory".to_string()],
error: None,
};
let md = r.to_markdown();
assert!(md.contains("# Memory curator — LLM consolidation pass"));
assert!(md.contains("Outcome: modified memory entries"));
assert!(md.contains("`memory` × 2"));
assert!(md.contains("deadbeef00000000"));
assert!(md.contains("stale pitfall"));
}
#[test]
fn llm_curator_report_markdown_flags_noop_outcome() {
let r = LlmCuratorReport {
started_at_iso: "2026-05-28T12:00:00Z".to_string(),
elapsed_secs: 0.5,
stale_candidates: vec![],
tool_actions: vec![],
error: None,
};
let md = r.to_markdown();
assert!(md.contains("no-op (LLM chose to keep all candidates)"));
}
#[test]
fn llm_curator_report_markdown_surfaces_errors() {
let r = LlmCuratorReport {
started_at_iso: "2026-05-28T12:00:00Z".to_string(),
elapsed_secs: 0.1,
stale_candidates: vec![],
tool_actions: vec![],
error: Some("model timed out".to_string()),
};
let md = r.to_markdown();
assert!(md.contains("Outcome: error"));
assert!(md.contains("model timed out"));
}
#[test]
fn preview_truncates_long_lines_with_ellipsis() {
let short = crate::text::first_line_preview("short and sweet");
assert_eq!(short, "short and sweet");
let long = crate::text::first_line_preview(&"x".repeat(120));
assert!(
long.ends_with("..."),
"long preview must end with '...': {long:?}",
);
assert!(long.len() <= 80, "preview must cap length: {}", long.len());
}
}