trusty-memory 0.27.1

MCP server (stdio + Unix socket) for trusty-memory
Documentation
//! Daemon summary, config, the palace roster, one palace, and drawer CRUD
//! (#6286).
//!
//! Why: these were `/api/v1/status`, `/config`, `GET /palaces`,
//! `GET /palaces/{id}` and the three drawer routes. `palace_create`,
//! `palace_update` and `palace_delete` are NOT here — they were already tool
//! methods the dispatcher routes, so folding them again would be a second
//! implementation of the same call.
//!
//! [`palaces_list`] is the one that is not a straight fold. `palace_list`, the
//! tool, answers bare ids; `GET /palaces` answered rows but with `peek`-based
//! placeholder zeros for any palace not already resident (#4640). Neither is
//! what a roster with counts needs, which is why the monitor was fanning out one
//! [`get_palace`] per id — see [`palaces_list`] and [`PalaceListRow`]. #6155
//! adds [`PalacesListParams`]`::counts`, because a caller that wants only NAMES
//! should not pay for the opens: see that type.
//!
//! What: each handler delegates to `MemoryService`, which is where the
//! behaviour lived all along; the axum extractors become one params struct.
//! Test: `super::super::uds::tests` — `rpc_status_*`, `rpc_drawer_*`,
//! `rpc_palace_get_*`, `rpc_palaces_list_*`.

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

use crate::transport::api_error::ApiError;
use crate::{ActivitySource, AppState};

use super::{to_value, CallerParams, NoParams, PalaceParams};

pub use crate::service::StatusPayload;

/// `memory.status` — daemon and palace summary.
///
/// The console header and external health tooling read this for the palace,
/// drawer and triple counts.
pub async fn status(state: &AppState, _params: NoParams) -> Result<Value, ApiError> {
    to_value(
        crate::service::MemoryService::new(state.clone())
            .status()
            .await,
    )
}

/// What `memory.config` reports.
///
/// The OpenRouter key itself is never included — only whether one is set.
#[derive(Debug, Serialize)]
pub struct ConfigPayload {
    /// Whether an OpenRouter key is configured.
    pub openrouter_configured: bool,
    /// The model that would be used.
    pub model: String,
    /// Where this daemon keeps its palaces.
    pub data_root: String,
}

/// `memory.config` — the current daemon configuration, minus the secret.
pub async fn config(state: &AppState, _params: NoParams) -> Result<Value, ApiError> {
    let cfg = crate::service::load_user_config().unwrap_or_default();
    to_value(ConfigPayload {
        openrouter_configured: !cfg.openrouter_api_key.is_empty(),
        model: cfg.openrouter_model,
        data_root: state.data_root.display().to_string(),
    })
}

/// `memory.palace_get` — one palace by id.
pub async fn get_palace(state: &AppState, params: PalaceParams) -> Result<Value, ApiError> {
    to_value(
        crate::service::MemoryService::new(state.clone())
            .get_palace(&params.palace_id)
            .await?,
    )
}

/// One palace's row in [`palaces_list`], or why its counts are missing.
///
/// Why the error is a FIELD rather than an omitted row (#6286): the monitor's
/// predecessor — an N-call `memory.palace_get` fan-out — dropped a palace whose
/// call failed at `debug!`, so the panel could report "12 palaces" above 9 rows
/// with nothing to say which three were missing or why. A row that can carry
/// its own failure makes that impossible to reintroduce: every palace the
/// registry lists appears, and one that could not be read says so.
///
/// What: `id` is always present. Exactly one of `palace` and `error` is
/// non-null. `error` is serialised even when null so a consumer reads it
/// directly rather than testing for the key.
///
/// `palace` is nested rather than flattened because [`crate::service::PalaceInfo`]
/// carries its own `id`, and a flatten would put two spellings of the same
/// field in one object.
/// Test: `rpc_palaces_list_reports_counts_per_palace`,
/// `rpc_palaces_list_reports_an_unreadable_palace_rather_than_dropping_it`.
#[derive(Debug, Serialize)]
pub struct PalaceListRow {
    /// The palace id, present whether or not its counts could be read.
    pub id: String,
    /// The palace's real counts. Never a row of zeros standing in for unknown.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub palace: Option<crate::service::PalaceInfo>,
    /// Why the counts are absent. `null` when `palace` is present.
    pub error: Option<String>,
}

/// Params for [`palaces_list`] — whether the roster is worth its counts.
///
/// Why (#6155): counting is the whole cost of this method. On the operator's
/// 93-palace install one call took 8.5-11 s because every palace is opened, and
/// the console's 30 s bridge budget was exceeded often enough that
/// `/tools/memory` sat on "Loading palaces…" and the KG palace selector stayed
/// empty. A roster of NAMES needs none of that work, and `list_palaces` has
/// answered it from `PalaceRegistry::peek` — zero disk I/O — since #4637.
///
/// What: `counts` defaults to `true`, so `{}` and `null` both keep the #6286
/// behaviour. `null` is accepted because the method took [`NoParams`] before
/// this field existed.
///
/// The default stays `true` because this is a published UDS/MCP method and an
/// external caller sending `{}` asked for the #6286 contract. What changed in
/// #7125 is who sends `{}`: the two PERIODIC pollers — `trusty_common`'s
/// monitor client and trusty-mpm's health TUI — now send `counts: false`,
/// because a poll on a 2- or 5-second timer was opening every palace on disk
/// and pinning the registry's LRU at its ceiling for as long as it ran.
/// Test: `rpc_palaces_list_without_counts_does_not_open_a_cold_palace`,
/// `rpc_palaces_list_defaults_to_counting`,
/// `monitor_client_poll_leaves_closed_palaces_closed`.
#[derive(Debug, Clone, Copy)]
pub struct PalacesListParams {
    /// Open every palace and report measured counts. `false` answers ids,
    /// names and `cached` flags only.
    pub counts: bool,
}

impl Default for PalacesListParams {
    fn default() -> Self {
        Self { counts: true }
    }
}

impl<'de> Deserialize<'de> for PalacesListParams {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        /// `true` unless the caller says otherwise.
        fn counts_default() -> bool {
            true
        }
        /// The wire shape, with `counts` omissible.
        #[derive(Deserialize)]
        struct Wire {
            #[serde(default = "counts_default")]
            counts: bool,
        }
        Ok(Option::<Wire>::deserialize(deserializer)?
            .map(|w| Self { counts: w.counts })
            .unwrap_or_default())
    }
}

/// `memory.palaces_list` — one row per palace, counted or named (#6286, #6155).
///
/// Why: this is the contract the retired `GET /api/v1/palaces` carried, with
/// the counts it could not. That route used `PalaceRegistry::peek` since #4640,
/// so it reported `cached: false` and placeholder zeros for any palace not
/// already resident — which is why the monitor fanned out one
/// `memory.palace_get` per id instead, and why the panel could disagree with
/// itself about how many palaces there are.
///
/// What: with `counts: true` (the default) it delegates to
/// `MemoryService::list_palaces_with_counts`, which opens each palace and keeps
/// per-palace failures. See that method for why opening every palace is the
/// same cost as the fan-out it replaces, not a new one. With `counts: false`
/// (#6155) it delegates to `MemoryService::list_palaces`, which peeks: a palace
/// that is not already resident is never opened, so the call is bounded by the
/// registry walk rather than by cold disk I/O. Those rows carry `cached: false`
/// and zeroed counts, which #4637 defines as UNKNOWN rather than empty; a
/// caller that wants one palace's real counts asks `memory.palace_get` for it.
/// Answers `{"palaces": [PalaceListRow, …]}` either way — an object rather than
/// a bare array so a later addition (a total, a truncation flag) is not a
/// breaking shape change.
///
/// Test: `rpc_palaces_list_reports_counts_per_palace`,
/// `rpc_palaces_list_reports_an_unreadable_palace_rather_than_dropping_it`,
/// `rpc_palaces_list_without_counts_does_not_open_a_cold_palace`,
/// `rpc_palaces_list_defaults_to_counting`.
pub async fn palaces_list(state: &AppState, params: PalacesListParams) -> Result<Value, ApiError> {
    let service = crate::service::MemoryService::new(state.clone());
    let rows: Vec<PalaceListRow> = if params.counts {
        service
            .list_palaces_with_counts()
            .await?
            .into_iter()
            .map(|(id, result)| match result {
                Ok(info) => PalaceListRow {
                    id,
                    palace: Some(info),
                    error: None,
                },
                Err(error) => PalaceListRow {
                    id,
                    palace: None,
                    error: Some(error),
                },
            })
            .collect()
    } else {
        // #6155: `list_palaces` is peek-only (#4637) — a palace that is not
        // already resident is never opened, so this cannot fail per palace and
        // no row carries an `error`.
        service
            .list_palaces()
            .await?
            .into_iter()
            .map(|info| PalaceListRow {
                id: info.id.clone(),
                palace: Some(info),
                error: None,
            })
            .collect()
    };
    to_value(json!({ "palaces": rows }))
}

/// Params for `memory.drawers_list`.
///
/// Flattens the former `{id}` path segment and the tag/search/pagination query
/// string into one object.
#[derive(Debug, Deserialize)]
pub struct ListDrawersParams {
    /// Palace to list.
    pub palace_id: String,
    /// The filters `MemoryService::list_drawers` already understood.
    #[serde(flatten)]
    pub query: crate::service::ListDrawersQuery,
}

/// `memory.drawers_list` — drawers in one palace, filtered and paged.
pub async fn list_drawers(state: &AppState, params: ListDrawersParams) -> Result<Value, ApiError> {
    Ok(crate::service::MemoryService::new(state.clone())
        .list_drawers(&params.palace_id, params.query)
        .await?)
}

/// Params for `memory.drawer_create`.
#[derive(Debug, Deserialize)]
pub struct CreateDrawerParams {
    /// Palace to write into.
    pub palace_id: String,
    /// The drawer body, exactly as the REST route took it.
    #[serde(flatten)]
    pub body: crate::service::CreateDrawerBody,
    /// Who is asking — see [`CallerParams`] for why this is a field now.
    #[serde(flatten)]
    pub caller: CallerParams,
}

/// `memory.drawer_create` — write one drawer, attributed to its caller.
///
/// Test: `rpc_drawer_create_attributes_the_caller_it_was_given`.
pub async fn create_drawer(
    state: &AppState,
    params: CreateDrawerParams,
) -> Result<Value, ApiError> {
    let creator = params.caller.creator();
    let drawer_id = crate::service::MemoryService::new(state.clone())
        .create_drawer(
            &params.palace_id,
            params.body,
            creator,
            ActivitySource::Http,
        )
        .await?;
    Ok(json!({ "id": drawer_id }))
}

/// Params for `memory.drawer_delete`.
#[derive(Debug, Deserialize)]
pub struct DeleteDrawerParams {
    /// Palace holding the drawer.
    pub palace_id: String,
    /// Drawer to remove.
    pub drawer_id: String,
}

/// `memory.drawer_delete` — remove one drawer.
///
/// Answers `{"deleted": true}` rather than the former `204 No Content`: a
/// JSON-RPC success frame always carries a result, and a `null` would be
/// indistinguishable from a method that forgot to return one. A drawer id that
/// does not exist is still `-32004` (#5231).
pub async fn delete_drawer(
    state: &AppState,
    params: DeleteDrawerParams,
) -> Result<Value, ApiError> {
    crate::service::MemoryService::new(state.clone())
        .delete_drawer(&params.palace_id, &params.drawer_id, ActivitySource::Http)
        .await?;
    Ok(json!({ "deleted": true }))
}