Skip to main content

trusty_memory/transport/methods/
chat.rs

1//! Provider availability and the three inter-project message endpoints
2//! (#6286).
3//!
4//! Why: the chat-session CRUD routes are NOT folded — `chat_session_create`,
5//! `_list`, `_get` and `_delete` were already tool names the dispatcher routes,
6//! so the routes were duplicates. What had no equivalent is the provider probe
7//! and the message list / mark-read pair. `memory_send_message` IS a tool, but
8//! the route also emits a `DrawerAdded` event the tool does not, so it folds
9//! rather than retiring.
10//!
11//! The chat completion itself is not here: it answers in many frames and lives
12//! in [`crate::chat::handler`], registered with `typed_stream`.
13//!
14//! What: `memory.chat_providers`, `memory.messages_list`,
15//! `memory.message_send`, `memory.message_mark_read`.
16//! Test: `super::super::uds::tests` — `rpc_chat_providers_*`, `rpc_messages_*`.
17
18use serde::Deserialize;
19use serde_json::{json, Value};
20use uuid::Uuid;
21
22use crate::service::load_user_config;
23use crate::transport::api_error::{open_handle, ApiError};
24use crate::{ActivitySource, AppState, DaemonEvent};
25
26use super::{CallerParams, NoParams};
27
28/// `memory.chat_providers` — which chat upstreams are reachable, and which one
29/// this daemon would use.
30///
31/// The Ollama probe is a real 1-second connect, so the answer reflects what is
32/// actually running rather than what is configured.
33pub async fn chat_providers(state: &AppState, _params: NoParams) -> Result<Value, ApiError> {
34    let cfg = load_user_config().unwrap_or_default();
35    let ollama_available = if cfg.local_model.enabled {
36        trusty_common::auto_detect_local_provider(&cfg.local_model.base_url)
37            .await
38            .is_some()
39    } else {
40        false
41    };
42    let openrouter_available = !cfg.openrouter_api_key.is_empty();
43    let active = state.chat_provider().await.map(|p| p.name().to_string());
44    Ok(json!({
45        "providers": [
46            {
47                "name": "ollama",
48                "model": cfg.local_model.model,
49                "available": ollama_available,
50            },
51            {
52                "name": "openrouter",
53                "model": cfg.openrouter_model,
54                "available": openrouter_available,
55            }
56        ],
57        "active": active,
58    }))
59}
60
61/// Params for `memory.messages_list`.
62#[derive(Debug, Deserialize)]
63pub struct ListMessagesParams {
64    /// Recipient palace.
65    pub palace: String,
66    /// The SessionStart hook asks for unread only; the audit view asks for all.
67    #[serde(default)]
68    pub unread_only: Option<bool>,
69}
70
71/// `memory.messages_list` — a palace's inbox (#99).
72///
73/// `formatted` is the pre-rendered Markdown block the SessionStart hook prints,
74/// so the hook does not have to know the rendering.
75pub async fn messages_list(
76    state: &AppState,
77    params: ListMessagesParams,
78) -> Result<Value, ApiError> {
79    let handle = open_handle(state, &params.palace)?;
80    let unread_only = params.unread_only.unwrap_or(false);
81    let payload: Vec<Value> = crate::messaging::list_messages(&handle, unread_only)
82        .into_iter()
83        .map(|m| {
84            let formatted = m.to_injection_block();
85            json!({
86                "id":          m.id.to_string(),
87                "from_palace": m.from_palace,
88                "to_palace":   m.to_palace,
89                "purpose":     m.purpose,
90                "sent_at":     m.sent_at.to_rfc3339(),
91                "read":        m.read,
92                "content":     m.content,
93                "formatted":   formatted,
94            })
95        })
96        .collect();
97    Ok(Value::Array(payload))
98}
99
100/// Params for `memory.message_send`.
101#[derive(Debug, Deserialize)]
102pub struct SendMessageParams {
103    /// Recipient palace.
104    pub to_palace: String,
105    /// Why the message is being sent.
106    pub purpose: String,
107    /// The message body.
108    pub content: String,
109    /// Sender; falls back to this daemon's `--palace` default, then
110    /// `<unknown>`. The CLI derives it from cwd client-side so the daemon
111    /// stays project-agnostic.
112    #[serde(default)]
113    pub from_palace: Option<String>,
114    /// Who is asking.
115    #[serde(flatten)]
116    pub caller: CallerParams,
117}
118
119/// `memory.message_send` — put a message on a recipient palace's queue (#99).
120pub async fn message_send(state: &AppState, params: SendMessageParams) -> Result<Value, ApiError> {
121    let from_palace = params
122        .from_palace
123        .or_else(|| state.default_palace.clone())
124        .unwrap_or_else(|| "<unknown>".to_string());
125    let drawer_id = crate::messaging::send_message_to_palace(
126        &state.registry,
127        &state.data_root,
128        &from_palace,
129        &params.to_palace,
130        &params.purpose,
131        params.content,
132        params.caller.creator(),
133    )
134    .await
135    .map_err(|e| ApiError::internal(format!("send_message: {e:#}")))?;
136
137    // The activity feed shows the new message immediately rather than at the
138    // next status tick.
139    let drawer_count = open_handle(state, &params.to_palace)
140        .map(|h| h.drawers.read().len())
141        .unwrap_or(0);
142    state.emit(DaemonEvent::DrawerAdded {
143        palace_id: params.to_palace.clone(),
144        palace_name: params.to_palace.clone(),
145        drawer_count,
146        timestamp: chrono::Utc::now(),
147        content_preview: format!("[msg from {from_palace}] {}", params.purpose),
148        source: ActivitySource::Http,
149    });
150
151    Ok(json!({
152        "drawer_id": drawer_id.to_string(),
153        "from_palace": from_palace,
154        "to_palace": params.to_palace,
155        "purpose": params.purpose,
156        "status": "sent",
157    }))
158}
159
160/// Params for `memory.message_mark_read`.
161#[derive(Debug, Deserialize)]
162pub struct MarkReadParams {
163    /// Palace holding the message.
164    pub palace: String,
165    /// The message's drawer id, as a UUID.
166    pub drawer_id: String,
167}
168
169/// `memory.message_mark_read` — flip one message's read flag, atomically.
170///
171/// `flipped: false` is a success: it means the drawer was already read or has
172/// been removed, and either way no further work is needed. Separating the ack
173/// from the list is what lets two concurrent sessions on one palace retire
174/// exactly the messages each printed.
175pub async fn message_mark_read(
176    state: &AppState,
177    params: MarkReadParams,
178) -> Result<Value, ApiError> {
179    let uuid = Uuid::parse_str(&params.drawer_id)
180        .map_err(|_| ApiError::bad_request("drawer_id must be a UUID"))?;
181    let handle = open_handle(state, &params.palace)?;
182    let flipped = crate::messaging::mark_message_read(&handle, uuid)
183        .await
184        .map_err(|e| ApiError::internal(format!("mark_read: {e:#}")))?;
185    Ok(json!({ "flipped": flipped }))
186}