sara-tasks 1.0.0

Sara — folder-aware task manager
//! Read-only MCP tools: load and inspect tasks. Contributes `read_router`.

use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::ErrorData;
use rmcp::{tool, tool_router};

use crate::commands;

use super::params::*;
use super::server::{SaraServer, mcp_err, ok_json};

#[tool_router(router = read_router, vis = "pub(crate)")]
impl SaraServer {
    #[tool(description = "List pending tasks for a project (or all projects).")]
    fn list(&self, Parameters(p): Parameters<ListParams>) -> Result<String, ErrorData> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp list", |conn, cfg| {
                commands::list::list_value(conn, cfg, p.all.unwrap_or(false), p.project.as_deref())
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Full task guide as JSON: description, steps, acceptance, notes, links, freshness, open feedback. When Strong memories (strength>=2.0) matching the task's description or tags exist, a `similar_work` array is included automatically — check it before starting."
    )]
    fn info(&self, Parameters(p): Parameters<IdParams>) -> Result<String, ErrorData> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp info", |conn, _cfg| {
                commands::info::guide_value(conn, &p.id)
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(description = "The execution cursor: the first not-done step of a task.")]
    fn next(&self, Parameters(p): Parameters<IdParams>) -> Result<String, ErrorData> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp next", |conn, _cfg| {
                commands::guide::next_value(conn, &p.id)
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(description = "Ordered steps of a task (optionally only up to step `until`).")]
    fn steps(&self, Parameters(p): Parameters<StepsParams>) -> Result<String, ErrorData> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp steps", |conn, _cfg| {
                commands::guide::steps_value(conn, &p.id, p.until)
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Read-only: the verification commands + acceptance criteria for a task (does NOT run them)."
    )]
    fn verify(&self, Parameters(p): Parameters<VerifyParams>) -> Result<String, ErrorData> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp verify", |conn, _cfg| {
                commands::guide::verify_value(conn, &p.id, p.step)
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Cross-task keyword search over descriptions, notes, and code anchors, plus exact --tag/--project lookups and --files filter over learned memories. Returns `confidence` (high/medium/none) and a `caveat` string — always read these: `none` with a caveat means FTS found nothing but that does NOT mean no similar work exists (literal keyword search only, no stemming or semantics). Set `spread: true` to also radiate across the memory graph and return associatively-related memories (sharing no keyword) in an `associative` array."
    )]
    fn recall(&self, Parameters(p): Parameters<RecallParams>) -> Result<String, ErrorData> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp recall", |conn, cfg| {
                commands::recall::recall_value(
                    conn,
                    cfg,
                    &p.query,
                    p.tag.as_deref().unwrap_or(&[]),
                    p.project.as_deref().unwrap_or(&[]),
                    p.files.as_deref().unwrap_or(&[]),
                    p.limit.unwrap_or(20),
                    p.spread.unwrap_or(false),
                )
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(description = "List a task's open human feedback (items awaiting a response).")]
    fn feedback(&self, Parameters(p): Parameters<IdParams>) -> Result<String, ErrorData> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp feedback", |conn, _cfg| {
                commands::guide::feedback_value(conn, &p.id)
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Dependency-ordered briefing for a task: each task's full guide in dependency order (the task plus everything it is blocked by)."
    )]
    fn plan_show(&self, Parameters(p): Parameters<IdParams>) -> Result<String, ErrorData> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp plan_show", |conn, _cfg| {
                commands::plan::show_value(conn, &p.id)
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(description = "List all tag vocabulary with usage counts across active memories.")]
    fn tags(&self, Parameters(p): Parameters<TagsParams>) -> Result<String, ErrorData> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp tags", |conn, _cfg| {
                let counts = crate::infrastructure::db::list_tags_with_counts(conn)?;
                Ok(serde_json::json!(
                    counts
                        .into_iter()
                        .map(|(tag, count)| serde_json::json!({ "tag": tag, "count": count }))
                        .collect::<Vec<_>>()
                ))
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "Browse all saved memories newest-first with strength labels (Strong/Linked/Weak). Use to audit what recall trusts or to find a memory label for `forget`."
    )]
    fn memories(&self, Parameters(p): Parameters<MemoriesParams>) -> Result<String, ErrorData> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp memories", |conn, _cfg| {
                let items = crate::infrastructure::db::list_memories(conn)?;
                let rows: Vec<serde_json::Value> = items
                    .iter()
                    .map(|m| {
                        let strength = crate::infrastructure::db::item_strength(conn, m);
                        let label = format!(
                            "{}{}",
                            m.kind.chars().next().unwrap_or('m'),
                            m.display_id.unwrap_or(0)
                        );
                        let strength_label = if strength >= 2.0 {
                            "Strong"
                        } else if strength >= 1.5 {
                            "Linked"
                        } else {
                            "Weak"
                        };
                        let files = crate::infrastructure::db::get_item_files(conn, &m.uuid)
                            .unwrap_or_default();
                        serde_json::json!({
                            "label": label,
                            "title": m.title,
                            "body": m.body,
                            "strength": strength,
                            "strength_label": strength_label,
                            "tags": m.tags,
                            "files": files,
                            "created": m.created.to_rfc3339(),
                            "modified": m.modified.to_rfc3339(),
                        })
                    })
                    .collect();
                Ok(serde_json::json!({ "memories": rows }))
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(
        description = "List all known projects with their metadata, task counts, and last activity."
    )]
    fn projects(&self, Parameters(p): Parameters<ProjectsParams>) -> Result<String, ErrorData> {
        let v = self
            .with_project(p.project_path.as_deref(), "mcp projects", |conn, _cfg| {
                let names = crate::infrastructure::db::project_names(conn)?;
                let mut rows = Vec::with_capacity(names.len());
                for name in names {
                    let profile = crate::infrastructure::db::get_project(conn, &name)?;
                    let stats = crate::infrastructure::db::project_stats(conn, &name)?;
                    let last = crate::infrastructure::db::project_last_activity(conn, &name)?;
                    rows.push(serde_json::json!({
                        "name": name,
                        "goal": profile.as_ref().and_then(|p| p.goal.as_deref()),
                        "stack": profile.as_ref().and_then(|p| p.stack.as_deref()),
                        "pending": stats.pending,
                        "done": stats.completed_total,
                        "last_activity": last.map(|d| d.to_rfc3339()),
                    }));
                }
                Ok(serde_json::Value::Array(rows))
            })
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(description = "Create a typed directed link between two memories. \
        Relations: `supersedes` (new replaces old; old shows ⚠ superseded-by in recall), \
        `similar_to` (bidirectional affinity), `derived_from` (this was built on top of that), \
        `used_in` (memory references a context/file). \
        The superseding memory surfaces alongside the stale one in recall output. \
        Use this to invalidate outdated memories rather than deleting them.")]
    fn link_memory(
        &self,
        Parameters(p): Parameters<LinkMemoryParams>,
    ) -> Result<String, ErrorData> {
        let v = self
            .with_project(
                p.project_path.as_deref(),
                "mcp link_memory",
                |conn, _cfg| {
                    commands::link_memory::link_memory_value(
                        conn,
                        &p.from,
                        &p.relation,
                        &p.to,
                        p.weight.unwrap_or(1.0),
                    )
                },
            )
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(description = "Remove a typed directed link between two memories.")]
    fn unlink_memory(
        &self,
        Parameters(p): Parameters<UnlinkMemoryParams>,
    ) -> Result<String, ErrorData> {
        let v = self
            .with_project(
                p.project_path.as_deref(),
                "mcp unlink_memory",
                |conn, _cfg| commands::link_memory::unlink_value(conn, &p.from, &p.relation, &p.to),
            )
            .map_err(mcp_err)?;
        ok_json(v)
    }

    #[tool(description = "Evaluate and optionally archive low-value memories. \
        Three signals: (1) superseded — has incoming `supersedes` edge, \
        (2) provisional + old — auto-generated on `done` but not reviewed within `provisional_days`, \
        (3) weak + old — no task link and older than `weak_days`. \
        Set dry_run=true (default) to preview without archiving. Set dry_run=false to apply. \
        Archived memories are NOT deleted — they can be inspected via direct DB query.")]
    fn prune_memories(
        &self,
        Parameters(p): Parameters<PruneMemoriesParams>,
    ) -> Result<String, ErrorData> {
        use crate::commands::prune_memories::{DEFAULT_PROVISIONAL_DAYS, DEFAULT_WEAK_DAYS};
        let v = self
            .with_project(
                p.project_path.as_deref(),
                "mcp prune_memories",
                |conn, _cfg| {
                    crate::commands::prune_memories::prune_value(
                        conn,
                        p.weak_days.unwrap_or(DEFAULT_WEAK_DAYS),
                        p.provisional_days.unwrap_or(DEFAULT_PROVISIONAL_DAYS),
                        p.dry_run.unwrap_or(true),
                    )
                },
            )
            .map_err(mcp_err)?;
        ok_json(v)
    }
}