spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
use super::protocol::JsonRpcError;
use super::schemas::{
    RESOURCE_CURRENT_PLAN_URI, RESOURCE_RESTART_GUIDE_URI, RESOURCE_SESSION_HANDOFF_URI,
};
use crate::domain::{MemoryScope, OutputFormat, RouteInput, TargetTool, WakeupProfile};
use crate::lifecycle_store::{ProposeMemoryRequest, RecordMemoryRequest, TransitionMetadata};
use serde_json::{Value, json};
use std::path::{Path, PathBuf};

pub(super) fn parse_record_request(arguments: &Value) -> Result<RecordMemoryRequest, JsonRpcError> {
    Ok(RecordMemoryRequest {
        title: required_string(arguments, "title")?,
        summary: required_string(arguments, "summary")?,
        memory_type: required_string(arguments, "memory_type")?,
        scope: parse_scope(arguments)?,
        source_ref: required_string(arguments, "source_ref")?,
        project_id: optional_string(arguments, "project_id"),
        user_id: optional_string(arguments, "user_id"),
        sensitivity: optional_string(arguments, "sensitivity"),
        metadata: parse_metadata(arguments)?,
        entities: parse_string_array(arguments, "entities")?,
        tags: parse_string_array(arguments, "tags")?,
        triggers: parse_string_array(arguments, "triggers")?,
        related_files: parse_string_array(arguments, "related_files")?,
        related_records: parse_string_array(arguments, "related_records")?,
        supersedes: optional_string(arguments, "supersedes"),
        applies_to: parse_string_array(arguments, "applies_to")?,
        valid_until: optional_string(arguments, "valid_until"),
    })
}

pub(super) fn parse_propose_request(
    arguments: &Value,
) -> Result<ProposeMemoryRequest, JsonRpcError> {
    Ok(ProposeMemoryRequest {
        title: required_string(arguments, "title")?,
        summary: required_string(arguments, "summary")?,
        memory_type: required_string(arguments, "memory_type")?,
        scope: parse_scope(arguments)?,
        source_ref: required_string(arguments, "source_ref")?,
        project_id: optional_string(arguments, "project_id"),
        user_id: optional_string(arguments, "user_id"),
        sensitivity: optional_string(arguments, "sensitivity"),
        metadata: parse_metadata(arguments)?,
        entities: parse_string_array(arguments, "entities")?,
        tags: parse_string_array(arguments, "tags")?,
        triggers: parse_string_array(arguments, "triggers")?,
        related_files: parse_string_array(arguments, "related_files")?,
        related_records: parse_string_array(arguments, "related_records")?,
        supersedes: optional_string(arguments, "supersedes"),
        applies_to: parse_string_array(arguments, "applies_to")?,
        valid_until: optional_string(arguments, "valid_until"),
    })
}

pub(super) fn parse_metadata(arguments: &Value) -> Result<TransitionMetadata, JsonRpcError> {
    Ok(TransitionMetadata {
        actor: optional_string(arguments, "actor"),
        reason: optional_string(arguments, "reason"),
        evidence_refs: parse_string_array(arguments, "evidence_refs")?,
    })
}

pub(super) fn parse_string_array(
    arguments: &Value,
    key: &str,
) -> Result<Vec<String>, JsonRpcError> {
    let Some(value) = arguments.get(key) else {
        return Ok(Vec::new());
    };
    let items = value.as_array().ok_or_else(|| {
        JsonRpcError::new(-32602, format!("field must be an array of strings: {key}"))
    })?;

    items
        .iter()
        .map(|item| {
            item.as_str().map(ToString::to_string).ok_or_else(|| {
                JsonRpcError::new(-32602, format!("field must be an array of strings: {key}"))
            })
        })
        .collect()
}

pub(super) fn parse_scope(arguments: &Value) -> Result<MemoryScope, JsonRpcError> {
    match required_string(arguments, "scope")?.as_str() {
        "user" => Ok(MemoryScope::User),
        "project" => Ok(MemoryScope::Project),
        "workspace" => Ok(MemoryScope::Workspace),
        "team" => Ok(MemoryScope::Team),
        "agent" => Ok(MemoryScope::Agent),
        other => Err(JsonRpcError::new(-32602, format!("invalid scope: {other}"))),
    }
}

#[derive(Debug)]
pub(super) struct ParsedRouteRequest {
    pub input: RouteInput,
    pub format: OutputFormat,
}

#[derive(Debug)]
pub(super) struct ParsedWakeupRequest {
    pub input: RouteInput,
    pub format: OutputFormat,
    pub profile: WakeupProfile,
}

#[derive(Debug)]
pub(super) struct ParsedPromptOptimizeRequest {
    pub task: String,
    pub cwd: String,
    pub files: Vec<String>,
    pub target: TargetTool,
    pub profile: WakeupProfile,
    pub provider: Option<String>,
    pub session_id: Option<String>,
}

pub(super) fn parse_route_request(
    arguments: &Value,
    default_format: OutputFormat,
) -> Result<ParsedRouteRequest, JsonRpcError> {
    let format = parse_output_format(arguments, "format")?.unwrap_or(default_format);
    Ok(ParsedRouteRequest {
        input: RouteInput {
            task: required_string(arguments, "task")?,
            cwd: PathBuf::from(required_string(arguments, "cwd")?),
            files: parse_files(arguments)?,
            target: parse_target(arguments)?.unwrap_or(TargetTool::Codex),
            format,
        },
        format,
    })
}

pub(super) fn parse_wakeup_request(arguments: &Value) -> Result<ParsedWakeupRequest, JsonRpcError> {
    let format = parse_output_format(arguments, "format")?.unwrap_or(OutputFormat::Json);
    let route = parse_route_request(arguments, format)?;
    Ok(ParsedWakeupRequest {
        input: route.input,
        format,
        profile: parse_profile(arguments)?.unwrap_or(WakeupProfile::Developer),
    })
}

pub(super) fn parse_prompt_optimize_request(
    arguments: &Value,
) -> Result<ParsedPromptOptimizeRequest, JsonRpcError> {
    let target = parse_target(arguments)?.unwrap_or(TargetTool::Codex);
    Ok(ParsedPromptOptimizeRequest {
        task: required_string(arguments, "task")?,
        cwd: required_string(arguments, "cwd")?,
        files: parse_files(arguments)?,
        target,
        profile: parse_profile(arguments)?.unwrap_or(WakeupProfile::Project),
        provider: optional_string(arguments, "provider")
            .or_else(|| Some(default_provider_for_target(target).to_string())),
        session_id: optional_string(arguments, "session_id"),
    })
}

pub(super) fn default_provider_for_target(target: TargetTool) -> &'static str {
    match target {
        TargetTool::Claude => "claude",
        TargetTool::Codex => "codex",
        TargetTool::Opencode => "opencode",
    }
}

pub(super) fn parse_target(arguments: &Value) -> Result<Option<TargetTool>, JsonRpcError> {
    match optional_string(arguments, "target").as_deref() {
        None => Ok(None),
        Some("claude") => Ok(Some(TargetTool::Claude)),
        Some("codex") => Ok(Some(TargetTool::Codex)),
        Some("opencode") => Ok(Some(TargetTool::Opencode)),
        Some(other) => Err(JsonRpcError::new(
            -32602,
            format!("invalid target: {other}"),
        )),
    }
}

pub(super) fn parse_output_format(
    arguments: &Value,
    key: &str,
) -> Result<Option<OutputFormat>, JsonRpcError> {
    match optional_string(arguments, key).as_deref() {
        None => Ok(None),
        Some("prompt") => Ok(Some(OutputFormat::Prompt)),
        Some("markdown") => Ok(Some(OutputFormat::Markdown)),
        Some("json") => Ok(Some(OutputFormat::Json)),
        Some(other) => Err(JsonRpcError::new(
            -32602,
            format!("invalid format: {other}"),
        )),
    }
}

pub(super) fn parse_profile(arguments: &Value) -> Result<Option<WakeupProfile>, JsonRpcError> {
    match optional_string(arguments, "profile").as_deref() {
        None => Ok(None),
        Some("developer") => Ok(Some(WakeupProfile::Developer)),
        Some("project") => Ok(Some(WakeupProfile::Project)),
        Some(other) => Err(JsonRpcError::new(
            -32602,
            format!("invalid profile: {other}"),
        )),
    }
}

pub(super) fn parse_files(arguments: &Value) -> Result<Vec<String>, JsonRpcError> {
    parse_string_array(arguments, "files")
}

pub(super) fn handle_prompt_get(params: &Value) -> Result<Value, JsonRpcError> {
    let params = required_object(params, "params")?;
    let name = required_string_from_object(params, "name")?;
    let arguments = optional_object_field(params, "arguments")?.unwrap_or_else(|| json!({}));
    let arguments = required_object(&arguments, "arguments")?;

    match name.as_str() {
        "review_lifecycle_queue" => Ok(json!({
            "description": "Guide an AI client to review pending lifecycle memories.",
            "messages": [{
                "role": "user",
                "content": {
                    "type": "text",
                    "text": format!(
                        "Use `memory_review_queue` to inspect pending items, then use `memory_get` or `memory_history` for the records that need more context. Apply `memory_accept` / `memory_archive` only after you can justify the decision. Reviewer focus: {}",
                        optional_string_from_object(arguments, "focus").unwrap_or_else(|| "validate stable preferences, constraints, and decisions".to_string())
                    )
                }
            }]
        })),
        "generate_project_wakeup" => Ok(json!({
            "description": "Guide an AI client to build a project wakeup packet.",
            "messages": [{
                "role": "user",
                "content": {
                    "type": "text",
                    "text": format!(
                        "Call `memory_wakeup` with `profile=project`, `cwd={}`, and task `{}`. After reading the packet, summarize the active project context, constraints, and the most relevant notes.",
                        optional_string_from_object(arguments, "cwd").unwrap_or_else(|| "<repo cwd>".to_string()),
                        optional_string_from_object(arguments, "task").unwrap_or_else(|| "prepare project wakeup".to_string())
                    )
                }
            }]
        })),
        "retrieve_project_context" => Ok(json!({
            "description": "Guide an AI client to retrieve routed project context.",
            "messages": [{
                "role": "user",
                "content": {
                    "type": "text",
                    "text": format!(
                        "Call `memory_search` with task `{}` and cwd `{}`. Then inspect `memory_explain` if the matched notes or project routing look suspicious.",
                        optional_string_from_object(arguments, "task").unwrap_or_else(|| "understand the current project state".to_string()),
                        optional_string_from_object(arguments, "cwd").unwrap_or_else(|| "<repo cwd>".to_string())
                    )
                }
            }]
        })),
        _ => Err(JsonRpcError::new(
            -32601,
            format!("prompt not found: {name}"),
        )),
    }
}

pub(super) fn handle_resource_read(
    config_path: &Path,
    params: &Value,
) -> Result<Value, JsonRpcError> {
    let params = required_object(params, "params")?;
    let uri = required_string_from_object(params, "uri")?;
    let (resource_uri, relative_path, description) = match uri.as_str() {
        RESOURCE_SESSION_HANDOFF_URI => (
            RESOURCE_SESSION_HANDOFF_URI,
            Path::new("docs/SESSION_HANDOFF.md"),
            "Current spool handoff and restart context.",
        ),
        RESOURCE_CURRENT_PLAN_URI => (
            RESOURCE_CURRENT_PLAN_URI,
            Path::new("docs/MCP_PROMPTS_ROUND_8_PLAN.md"),
            "Current MCP prompts/resources implementation plan.",
        ),
        RESOURCE_RESTART_GUIDE_URI => (
            RESOURCE_RESTART_GUIDE_URI,
            Path::new("docs/SESSION_HANDOFF.md"),
            "Restart guide and current next steps extracted from the handoff.",
        ),
        _ => {
            return Err(JsonRpcError::new(
                -32601,
                format!("resource not found: {uri}"),
            ));
        }
    };

    let config_base_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
    let config_resource_path = config_base_dir.join(relative_path);
    let fallback_resource_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(relative_path);
    let resource_path = if config_resource_path.exists() {
        config_resource_path
    } else {
        fallback_resource_path
    };
    let text = std::fs::read_to_string(&resource_path).map_err(|error| {
        JsonRpcError::new(
            -32603,
            format!(
                "failed to read resource {}: {error}",
                resource_path.display()
            ),
        )
    })?;
    Ok(json!({
        "contents": [{
            "uri": resource_uri,
            "mimeType": "text/markdown",
            "text": text
        }],
        "description": description
    }))
}

pub(super) fn required_object<'a>(
    value: &'a Value,
    field: &str,
) -> Result<&'a serde_json::Map<String, Value>, JsonRpcError> {
    value
        .as_object()
        .ok_or_else(|| JsonRpcError::new(-32602, format!("field must be an object: {field}")))
}

pub(super) fn optional_object_field(
    value: &serde_json::Map<String, Value>,
    key: &str,
) -> Result<Option<Value>, JsonRpcError> {
    let Some(field) = value.get(key) else {
        return Ok(None);
    };
    if !field.is_object() {
        return Err(JsonRpcError::new(
            -32602,
            format!("field must be an object: {key}"),
        ));
    }
    Ok(Some(field.clone()))
}

pub(super) fn required_string(value: &Value, key: &str) -> Result<String, JsonRpcError> {
    value
        .get(key)
        .and_then(Value::as_str)
        .map(ToString::to_string)
        .ok_or_else(|| JsonRpcError::new(-32602, format!("missing string field: {key}")))
}

pub(super) fn optional_string(value: &Value, key: &str) -> Option<String> {
    value
        .get(key)
        .and_then(Value::as_str)
        .map(ToString::to_string)
}

pub(super) fn required_string_from_object(
    value: &serde_json::Map<String, Value>,
    key: &str,
) -> Result<String, JsonRpcError> {
    value
        .get(key)
        .and_then(Value::as_str)
        .map(ToString::to_string)
        .ok_or_else(|| JsonRpcError::new(-32602, format!("missing string field: {key}")))
}

pub(super) fn optional_string_from_object(
    value: &serde_json::Map<String, Value>,
    key: &str,
) -> Option<String> {
    value
        .get(key)
        .and_then(Value::as_str)
        .map(ToString::to_string)
}