mermaid-cli 0.29.0

Open-source AI pair programmer with agentic capabilities. Local-first with Ollama, native tool calling, and beautiful TUI.
Documentation
//! The `memory` tool — the agent's autonomous write path into durable
//! semantic memory.
//!
//! Reading needs no tool: the memory index is always in context and the full
//! facts are fetched with `read_file` on the listed paths. This tool changes
//! memory (plus one lookup verb):
//!   - `remember` — create a new atomic fact.
//!   - `update`   — replace one fact's body (clean replace, never a merge).
//!   - `forget`   — delete a fact.
//!   - `search`   — find facts by keyword across names/descriptions/bodies.
//!
//! The directory chosen by `scope` is authoritative (private by default,
//! `shared`/`global` opt-in). Writes are ungated in every safety mode except
//! read-only (see `ToolCategory::Memory` in the policy engine) — there is no
//! approval modal — so the gate here only blocks read-only.

use async_trait::async_trait;

use crate::app::memory::{self};
use mermaid_domain::MemoryScope;
use mermaid_domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};

use super::super::ctx::ExecContext;
use super::ToolExecutor;

pub struct MemoryTool;

fn scope_from_args(args: &serde_json::Value) -> MemoryScope {
    if args
        .get("global")
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
    {
        MemoryScope::Global
    } else if args
        .get("shared")
        .and_then(|v| v.as_bool())
        .unwrap_or(false)
    {
        MemoryScope::ProjectShared
    } else {
        MemoryScope::ProjectPrivate
    }
}

fn str_arg(args: &serde_json::Value, key: &str) -> Option<String> {
    args.get(key)
        .and_then(|v| v.as_str())
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_string)
}

fn tags_arg(args: &serde_json::Value) -> Vec<String> {
    args.get("tags")
        .and_then(|v| v.as_array())
        .map(|a| {
            a.iter()
                .filter_map(|t| t.as_str())
                .map(str::to_string)
                .collect()
        })
        .unwrap_or_default()
}

#[async_trait]
impl ToolExecutor for MemoryTool {
    fn name(&self) -> &'static str {
        "memory"
    }

    fn schema(&self) -> ToolDefinition {
        ToolDefinition {
            name: "memory".to_string(),
            description: "Durable, cross-session memory of facts, one fact per file. The memory index is always in your context; this tool changes or searches it. \
                `action=remember` saves a new fact; `action=update` replaces one fact's body (pass its `id`); `action=forget` deletes a fact (pass its `id`); `action=search` finds facts by keyword (pass `query`) across names, descriptions, and bodies. \
                Default scope is project-private (machine-local, not committed); `shared=true` writes to the repo's .mermaid/memory, and `global=true` applies across all projects. \
                Never store secrets, tokens, or PII."
                .to_string(),
            input_schema: serde_json::json!({
                "type": "object",
                "properties": {
                    "action": {
                        "type": "string",
                        "enum": ["remember", "update", "forget", "search"],
                        "description": "What to do."
                    },
                    "query": {
                        "type": "string",
                        "description": "Keyword(s) to search for across memory names, descriptions, and bodies. Required for search."
                    },
                    "name": {
                        "type": "string",
                        "description": "Short title for the fact; also the filename. Required for remember."
                    },
                    "content": {
                        "type": "string",
                        "description": "The fact itself (Markdown). Required for remember and update."
                    },
                    "description": {
                        "type": "string",
                        "description": "One-line summary shown in the always-loaded index. Defaults to the first line of content."
                    },
                    "tags": {
                        "type": "array",
                        "items": { "type": "string" },
                        "description": "Optional keywords."
                    },
                    "shared": {
                        "type": "boolean",
                        "description": "Store in the committed project-shared scope (.mermaid/memory) instead of private."
                    },
                    "global": {
                        "type": "boolean",
                        "description": "Store in the global, cross-project scope."
                    },
                    "id": {
                        "type": "string",
                        "description": "Name/id of the fact to update or forget (as shown in the index)."
                    }
                },
                "required": ["action"]
            }),
        }
    }

    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
        let start = std::time::Instant::now();
        let action = str_arg(&args, "action").unwrap_or_default();

        // `search` is a pure read (a targeted alternative to scanning the
        // always-loaded index), so it runs before the mutation gate below and
        // stays available in every safety mode, including read-only.
        if action == "search" {
            let Some(query) = str_arg(&args, "query") else {
                return ToolOutcome::error(
                    "memory search requires 'query'",
                    Some(start.elapsed().as_secs_f64()),
                );
            };
            return run_search(&ctx.workdir, &query, start);
        }

        // Ungated except read-only: this returns `Some(block)` only when the
        // policy denies (read-only mode); it never touches the approval broker.
        if let Some(blocked) = super::policy_gate::gate_external(
            &ctx,
            "memory",
            mermaid_runtime::ToolCategory::Memory,
            format!("memory {action}"),
            &args,
        )
        .await
        {
            return blocked;
        }

        match action.as_str() {
            "remember" => run_remember(&ctx.workdir, &args, start),
            "update" => run_update(&ctx.workdir, &args, start),
            "forget" => run_forget(&ctx.workdir, &args, start),
            other => ToolOutcome::error(
                format!(
                    "memory: unknown action '{other}' (expected remember, update, forget, or search)"
                ),
                Some(start.elapsed().as_secs_f64()),
            ),
        }
    }
}

/// The `remember` action: write a new memory file from name + content.
fn run_remember(
    workdir: &std::path::Path,
    args: &serde_json::Value,
    start: std::time::Instant,
) -> ToolOutcome {
    let secs = || start.elapsed().as_secs_f64();
    let Some(name) = str_arg(args, "name") else {
        return ToolOutcome::error("memory remember requires 'name'", Some(secs()));
    };
    let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
        return ToolOutcome::error("memory remember requires 'content'", Some(secs()));
    };
    let scope = scope_from_args(args);
    let description = str_arg(args, "description").unwrap_or_else(|| {
        content
            .lines()
            .find(|l| !l.trim().is_empty())
            .unwrap_or("")
            .to_string()
    });
    let tags = tags_arg(args);
    match memory::write_memory(workdir, scope, &name, &description, &tags, content) {
        Ok(path) => finish(start, "remember", &name, scope, &path),
        Err(e) => ToolOutcome::error(format!("memory remember failed: {e}"), Some(secs())),
    }
}

/// The `update` action: rewrite an existing memory in place (by id or name),
/// keeping its scope and, unless given, its description.
fn run_update(
    workdir: &std::path::Path,
    args: &serde_json::Value,
    start: std::time::Instant,
) -> ToolOutcome {
    let secs = || start.elapsed().as_secs_f64();
    let Some(id) = str_arg(args, "id").or_else(|| str_arg(args, "name")) else {
        return ToolOutcome::error("memory update requires 'id'", Some(secs()));
    };
    let Some(content) = args.get("content").and_then(|v| v.as_str()) else {
        return ToolOutcome::error("memory update requires 'content'", Some(secs()));
    };
    let Some(existing) = memory::find(workdir, &id) else {
        return ToolOutcome::error(
            format!("memory update: no memory named '{id}'"),
            Some(secs()),
        );
    };
    let description = str_arg(args, "description").unwrap_or_else(|| existing.description.clone());
    let tags = tags_arg(args);
    match memory::write_memory(
        workdir,
        existing.scope,
        &existing.name,
        &description,
        &tags,
        content,
    ) {
        Ok(path) => {
            // If the slug differs from the original file's stem
            // (e.g. a hand-named file), drop the stale file so we
            // don't leave a duplicate.
            if path != existing.path {
                let _ = std::fs::remove_file(&existing.path);
            }
            finish(start, "update", &existing.name, existing.scope, &path)
        },
        Err(e) => ToolOutcome::error(format!("memory update failed: {e}"), Some(secs())),
    }
}

/// The `forget` action: delete a memory by id or name.
fn run_forget(
    workdir: &std::path::Path,
    args: &serde_json::Value,
    start: std::time::Instant,
) -> ToolOutcome {
    let secs = || start.elapsed().as_secs_f64();
    let Some(id) = str_arg(args, "id").or_else(|| str_arg(args, "name")) else {
        return ToolOutcome::error("memory forget requires 'id'", Some(secs()));
    };
    match memory::delete_memory(workdir, &id) {
        Ok(Some(path)) => ToolOutcome::success(
            format!("Forgot memory '{id}' ({})", path.display()),
            format!("forgot {id}"),
            secs(),
        )
        .with_metadata(ToolRunMetadata {
            detail: ToolMetadata::Custom {
                name: "memory".to_string(),
                data: serde_json::json!({
                    "action": "forget",
                    "id": id,
                    "path": path.display().to_string(),
                }),
            },
            ..ToolRunMetadata::default()
        }),
        Ok(None) => ToolOutcome::error(
            format!("memory forget: no memory named '{id}'"),
            Some(secs()),
        ),
        Err(e) => ToolOutcome::error(format!("memory forget failed: {e}"), Some(secs())),
    }
}

/// Shared success path for remember/update.
fn finish(
    start: std::time::Instant,
    action: &str,
    name: &str,
    scope: MemoryScope,
    path: &std::path::Path,
) -> ToolOutcome {
    let verb = if action == "remember" {
        "Remembered"
    } else {
        "Updated"
    };
    ToolOutcome::success(
        format!("{verb} '{name}' [{}] → {}", scope.as_str(), path.display()),
        format!("{action} {name} [{}]", scope.as_str()),
        start.elapsed().as_secs_f64(),
    )
    .with_metadata(ToolRunMetadata {
        detail: ToolMetadata::Custom {
            name: "memory".to_string(),
            data: serde_json::json!({
                "action": action,
                "name": name,
                "scope": scope.as_str(),
                "path": path.display().to_string(),
            }),
        },
        ..ToolRunMetadata::default()
    })
}

/// Handle the read-only `search` action: substring-match memory and return the
/// hits (name, scope, path, and a one-line snippet) as a compact text block.
fn run_search(workdir: &std::path::Path, query: &str, start: std::time::Instant) -> ToolOutcome {
    let hits = memory::search(workdir, query);
    let secs = start.elapsed().as_secs_f64();
    if hits.is_empty() {
        return ToolOutcome::success(
            format!("No memory matched '{query}'."),
            format!("search '{query}': 0 hits"),
            secs,
        );
    }
    let mut body = format!("{} memory hit(s) for '{query}':\n", hits.len());
    for hit in &hits {
        body.push_str(&format!(
            "- {} [{}] ({}) — {}\n",
            hit.entry.name,
            hit.entry.scope.as_str(),
            hit.entry.path.display(),
            hit.snippet,
        ));
    }
    ToolOutcome::success(body, format!("search '{query}': {} hits", hits.len()), secs)
}