remem-ai 0.6.81

Local-first coding agent memory for Claude Code and OpenAI Codex
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use super::types::{CodingBenchTask, CuratorLogAttachment};

const MINUTES_PER_SESSION: f64 = 3.0;
const MAX_CHARS: usize = 4_000;

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CuratorLog {
    schema_version: u32,
    condition: String,
    task_id: String,
    target_blind: bool,
    budget: CuratorBudget,
    sessions: Vec<CuratorSession>,
    totals: CuratorTotals,
    final_char_count: usize,
    final_file_sha256: String,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CuratorBudget {
    minutes_per_session: f64,
    max_chars: usize,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CuratorSession {
    episode_id: String,
    minutes_spent: f64,
    edit_count: u64,
    deletion_count: u64,
    conflict_resolution_count: u64,
    chars_after: usize,
}

#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CuratorTotals {
    maintenance_minutes: f64,
    update_count: u64,
    deletion_count: u64,
    conflict_resolution_count: u64,
}

pub(super) fn install_budgeted_memory(
    curator_root: &Path,
    task: &CodingBenchTask,
    repo_dir: &Path,
) -> Result<CuratorLogAttachment> {
    let (memory_bytes, attachment) = load_budgeted_input(curator_root, task)?;
    fs::write(repo_dir.join("MEMORY.md"), &memory_bytes)
        .context("write verified budgeted MEMORY.md")?;
    Ok(attachment)
}

pub(super) fn validate_budgeted_input(curator_root: &Path, task: &CodingBenchTask) -> Result<()> {
    load_budgeted_input(curator_root, task).map(|_| ())
}

pub(super) fn budgeted_input_manifest_sha256(
    curator_root: &Path,
    tasks: &[&CodingBenchTask],
) -> Result<String> {
    let entries = tasks
        .iter()
        .map(|task| {
            let (_, attachment) = load_budgeted_input(curator_root, task)?;
            Ok(CuratorManifestEntry {
                task_id: task.id.as_str(),
                memory_sha256: attachment.memory_sha256,
                curator_log_sha256: attachment.curator_log_sha256,
            })
        })
        .collect::<Result<Vec<_>>>()?;
    let value = serde_json::to_value(entries)?;
    let canonical = crate::api::mutation::canonical_json_bytes(&value)?;
    Ok(format!("{:x}", Sha256::digest(canonical)))
}

#[derive(Serialize)]
#[serde(deny_unknown_fields)]
struct CuratorManifestEntry<'a> {
    task_id: &'a str,
    memory_sha256: String,
    curator_log_sha256: String,
}

fn load_budgeted_input(
    curator_root: &Path,
    task: &CodingBenchTask,
) -> Result<(Vec<u8>, CuratorLogAttachment)> {
    let task_root = safe_task_root(curator_root, &task.id)?;
    let memory_path = task_root.join("MEMORY.md");
    let log_path = task_root.join("curator-log.json");
    reject_symlink(&memory_path)?;
    reject_symlink(&log_path)?;
    let memory_bytes = fs::read(&memory_path)
        .with_context(|| format!("read budgeted curator memory {}", memory_path.display()))?;
    let memory_text = std::str::from_utf8(&memory_bytes).with_context(|| {
        format!(
            "budgeted curator memory {} is not UTF-8",
            memory_path.display()
        )
    })?;
    let log_bytes =
        fs::read(&log_path).with_context(|| format!("read curator log {}", log_path.display()))?;
    let log: CuratorLog = serde_json::from_slice(&log_bytes)
        .with_context(|| format!("parse curator log {}", log_path.display()))?;
    let log_sha256 = format!("{:x}", Sha256::digest(&log_bytes));
    let mut attachment = validate_log(task, memory_text, &memory_bytes, log)?;
    attachment.curator_log_sha256 = log_sha256;
    Ok((memory_bytes, attachment))
}

fn validate_log(
    task: &CodingBenchTask,
    memory_text: &str,
    memory_bytes: &[u8],
    log: CuratorLog,
) -> Result<CuratorLogAttachment> {
    if log.schema_version != 1
        || log.condition != "curated_file_budgeted"
        || log.task_id != task.id
        || !log.target_blind
    {
        bail!(
            "curator log identity does not match curated_file_budgeted task {}",
            task.id
        );
    }
    if (log.budget.minutes_per_session - MINUTES_PER_SESSION).abs() > f64::EPSILON
        || log.budget.max_chars != MAX_CHARS
    {
        bail!(
            "curator log for {} does not use the registered 3-minute/4000-character budget",
            task.id
        );
    }
    if log.sessions.len() != task.history_episodes.len() {
        bail!(
            "curator log for {} does not cover every history episode",
            task.id
        );
    }

    let mut minutes = 0.0;
    let mut updates = 0_u64;
    let mut deletions = 0_u64;
    let mut conflicts = 0_u64;
    for (session, episode) in log.sessions.iter().zip(&task.history_episodes) {
        if session.episode_id != episode.episode_id {
            bail!(
                "curator log for {} is not in registered chronological episode order",
                task.id
            );
        }
        if !session.minutes_spent.is_finite()
            || session.minutes_spent < 0.0
            || session.minutes_spent > MINUTES_PER_SESSION
            || session.chars_after > MAX_CHARS
        {
            bail!(
                "curator log for {} exceeds a registered session budget",
                task.id
            );
        }
        minutes += session.minutes_spent;
        updates = updates.saturating_add(session.edit_count);
        deletions = deletions.saturating_add(session.deletion_count);
        conflicts = conflicts.saturating_add(session.conflict_resolution_count);
    }
    if (minutes - log.totals.maintenance_minutes).abs() > 1e-9
        || updates != log.totals.update_count
        || deletions != log.totals.deletion_count
        || conflicts != log.totals.conflict_resolution_count
    {
        bail!(
            "curator log totals do not match per-session accounting for {}",
            task.id
        );
    }

    let char_count = memory_text.chars().count();
    let memory_sha256 = format!("{:x}", Sha256::digest(memory_bytes));
    if char_count != log.final_char_count
        || char_count > MAX_CHARS
        || memory_sha256 != log.final_file_sha256
    {
        bail!(
            "curator log freeze hash/character count mismatch for {}",
            task.id
        );
    }
    Ok(CuratorLogAttachment {
        schema_version: log.schema_version,
        task_id: task.id.clone(),
        target_blind: true,
        memory_sha256,
        curator_log_sha256: String::new(),
        final_char_count: char_count,
        history_session_count: log.sessions.len(),
        maintenance_minutes: minutes,
        update_count: updates,
        deletion_count: deletions,
        conflict_resolution_count: conflicts,
    })
}

fn safe_task_root(root: &Path, task_id: &str) -> Result<PathBuf> {
    if task_id.is_empty()
        || !task_id
            .bytes()
            .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
    {
        bail!("invalid task id for curator input path");
    }
    reject_symlink(root)?;
    let task_root = root.join(task_id);
    reject_symlink(&task_root)?;
    Ok(task_root)
}

fn reject_symlink(path: &Path) -> Result<()> {
    let metadata = fs::symlink_metadata(path)
        .with_context(|| format!("inspect curator input {}", path.display()))?;
    if metadata.file_type().is_symlink() {
        bail!("curator input {} must not be a symlink", path.display());
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validates_hash_budget_order_and_totals() -> Result<()> {
        let fixture = super::super::fixture::load_fixture("eval/coding-bench/fixtures/tasks.json")?;
        let task = &fixture.tasks[0];
        let text = "Ticket normalization uses the registered convention.\n";
        let hash = format!("{:x}", Sha256::digest(text.as_bytes()));
        let log = CuratorLog {
            schema_version: 1,
            condition: "curated_file_budgeted".to_string(),
            task_id: task.id.clone(),
            target_blind: true,
            budget: CuratorBudget {
                minutes_per_session: 3.0,
                max_chars: 4_000,
            },
            sessions: vec![CuratorSession {
                episode_id: task.history_episodes[0].episode_id.clone(),
                minutes_spent: 1.5,
                edit_count: 2,
                deletion_count: 1,
                conflict_resolution_count: 0,
                chars_after: text.chars().count(),
            }],
            totals: CuratorTotals {
                maintenance_minutes: 1.5,
                update_count: 2,
                deletion_count: 1,
                conflict_resolution_count: 0,
            },
            final_char_count: text.chars().count(),
            final_file_sha256: hash,
        };
        let attachment = validate_log(task, text, text.as_bytes(), log)?;
        assert_eq!(attachment.maintenance_minutes, 1.5);
        assert_eq!(attachment.history_session_count, 1);
        Ok(())
    }
}