1use crate::service::load_user_config;
32use crate::AppState;
33use serde_json::{json, Value};
34use tokio::sync::mpsc;
35use trusty_common::memory_core::palace::PalaceId;
36use trusty_common::memory_core::retrieval::recall_with_default_embedder;
37use trusty_common::memory_core::PalaceRegistry;
38use trusty_common::uds::server::{RpcError, RpcStreamItems};
39use trusty_common::{ChatEvent, ChatMessage};
40
41use super::tools::{all_tools, execute_get_dream_status, execute_tool, ChatBody, MAX_TOOL_ROUNDS};
44
45pub async fn chat_stream(state: &AppState, body: ChatBody) -> Result<RpcStreamItems, RpcError> {
57 let state = state.clone();
58 let Some(provider) = state.chat_provider().await else {
60 return Err(RpcError::internal(
61 "No chat provider configured (no local Ollama detected and no OpenRouter key set)",
62 ));
63 };
64
65 let palace_id = body
67 .palace_id
68 .clone()
69 .or_else(|| state.default_palace.clone())
70 .unwrap_or_default();
71
72 let (session_id, mut history): (Option<String>, Vec<ChatMessage>) = if !palace_id.is_empty() {
74 let store = match state.session_store(&palace_id) {
75 Ok(s) => s,
76 Err(e) => {
77 tracing::warn!(palace = %palace_id, "session_store open failed: {e:#}");
78 return Err(RpcError::internal(format!("session store: {e:#}")));
79 }
80 };
81 match body.session_id.clone() {
82 Some(sid) => match store.get_session(&sid) {
83 Ok(Some(s)) => (
84 Some(sid),
85 s.history
86 .into_iter()
87 .map(|m| ChatMessage {
88 role: m.role,
89 content: m.content,
90 tool_call_id: None,
91 tool_calls: None,
92 })
93 .collect(),
94 ),
95 _ => (Some(sid), body.history.clone()),
96 },
97 None => {
98 let new_id = store.create_session(None).unwrap_or_else(|e| {
99 tracing::warn!("create_session failed: {e:#}");
100 String::new()
101 });
102 (
103 if new_id.is_empty() {
104 None
105 } else {
106 Some(new_id)
107 },
108 body.history.clone(),
109 )
110 }
111 }
112 } else {
113 (None, body.history.clone())
114 };
115
116 let all_palaces = PalaceRegistry::list_palaces(&state.data_root).unwrap_or_default();
119 let palace_count = all_palaces.len();
120 let palace_roster: String = all_palaces
121 .iter()
122 .map(|p| format!("- {} (id: {})", p.name, p.id.0))
123 .collect::<Vec<_>>()
124 .join("\n");
125
126 let cfg = load_user_config().unwrap_or_default();
129 let active_provider_name = state
130 .chat_provider()
131 .await
132 .map(|p| p.name().to_string())
133 .unwrap_or_else(|| "none".to_string());
134 let dream_snapshot = execute_get_dream_status(&state).await;
135
136 let selected_palace_meta = if palace_id.is_empty() {
139 None
140 } else {
141 all_palaces.iter().find(|p| p.id.0 == palace_id).cloned()
142 };
143
144 let mut palace_block = String::new();
145 let mut context = String::new();
146 let mut palace_display_name = palace_id.clone();
147
148 if !palace_id.is_empty() {
149 if let Ok(handle) = state
150 .registry
151 .open_palace(&state.data_root, &PalaceId::new(&palace_id))
152 {
153 let drawer_count = handle.drawers.read().len();
155 let vector_count = handle.vector_store.index_size();
156 let kg_triple_count = match handle.kg.count_active_triples() {
159 Ok(n) => n.to_string(),
160 Err(e) => {
161 tracing::warn!(palace = %palace_id, "kg_triple_count unavailable: {e:#}");
162 "unknown (read failed)".to_string()
163 }
164 };
165
166 let (name, description) = match &selected_palace_meta {
168 Some(p) => (p.name.clone(), p.description.clone()),
169 None => (palace_id.clone(), None),
170 };
171 palace_display_name = name.clone();
172
173 palace_block.push_str(&format!(
174 "Currently selected palace:\n\
175 - id: {id}\n\
176 - name: {name}\n",
177 id = palace_id,
178 name = name,
179 ));
180 if let Some(desc) = description.as_deref().filter(|s| !s.is_empty()) {
181 palace_block.push_str(&format!("- description: {desc}\n"));
182 }
183 palace_block.push_str(&format!(
184 "- drawers: {drawer_count}\n\
185 - vectors: {vector_count}\n\
186 - kg_triples: {kg_triple_count}\n",
187 ));
188 let identity_trimmed = handle.identity.trim();
189 if !identity_trimmed.is_empty() {
190 palace_block.push_str(&format!("- identity:\n{identity_trimmed}\n",));
191 }
192
193 if let Ok(hits) = recall_with_default_embedder(&handle, &body.message, 5).await {
194 for r in hits.iter().take(5) {
195 context.push_str(&format!("- (L{}) {}\n", r.layer, r.drawer.content()));
196 }
197 }
198 }
199 }
200
201 let mut system = String::new();
205 system.push_str(&format!(
206 "You are the assistant for trusty-memory, a machine-wide AI memory \
207 service running locally on this user's machine. trusty-memory stores \
208 knowledge in named \"palaces\" — isolated memory namespaces, each with \
209 its own vector index (usearch HNSW) and temporal knowledge graph \
210 (redb). Memories are organized as Palace -> Wing -> Room -> Drawer, \
211 where a Drawer is an atomic memory unit. A \"closet\" is not a level \
212 in that hierarchy — it is a keyword index over drawers.\n\
213 There are currently {palace_count} palace(s) on this machine.\n",
214 ));
215 if !palace_roster.is_empty() {
216 system.push_str(&format!("Palaces:\n{palace_roster}\n"));
217 }
218 system.push('\n');
219
220 system.push_str(&format!(
222 "System configuration:\n\
223 - active chat provider: {active_provider_name}\n\
224 - openrouter model: {or_model}\n\
225 - local model: {local_model} ({local_url}, enabled={local_enabled})\n\
226 - data root: {data_root}\n\n",
227 or_model = cfg.openrouter_model,
228 local_model = cfg.local_model.model,
229 local_url = cfg.local_model.base_url,
230 local_enabled = cfg.local_model.enabled,
231 data_root = state.data_root.display(),
232 ));
233
234 system.push_str(&format!(
236 "Global dream status (background memory maintenance):\n{}\n\n",
237 dream_snapshot,
238 ));
239
240 if !palace_block.is_empty() {
241 system.push_str(&palace_block);
242 system.push('\n');
243 }
244
245 if !context.is_empty() {
246 system.push_str(&format!(
247 "Relevant memories from the '{palace_display_name}' palace \
248 (L0 = identity, L1 = essentials, L2 = topic-filtered, L3 = deep):\n\
249 {context}\n",
250 ));
251 }
252
253 system.push_str(
254 "You have a set of tools to introspect and modify this trusty-memory \
255 daemon. Prefer calling a tool over guessing — e.g. call \
256 `list_palaces` rather than relying on the roster above if you need \
257 live counts, and call `recall_memories` to search for facts you \
258 don't have in context. When the user asks about \"palaces\", they \
259 mean trusty-memory palaces (memory namespaces on this machine), not \
260 architectural palaces like Versailles. If a tool returns an error, \
261 report it honestly and don't fabricate results.",
262 );
263
264 history.push(ChatMessage {
266 role: "user".to_string(),
267 content: body.message.clone(),
268 tool_call_id: None,
269 tool_calls: None,
270 });
271
272 let mut messages: Vec<ChatMessage> = Vec::with_capacity(history.len() + 1);
273 messages.push(ChatMessage {
274 role: "system".to_string(),
275 content: system,
276 tool_call_id: None,
277 tool_calls: None,
278 });
279 messages.extend(history.iter().cloned());
280
281 let tools = all_tools();
282 let (frames, items) = mpsc::channel::<Result<Value, RpcError>>(64);
286
287 let session_store = if !palace_id.is_empty() && session_id.is_some() {
289 state.session_store(&palace_id).ok()
290 } else {
291 None
292 };
293 let persist_session_id = session_id.clone();
294
295 let loop_state = state.clone();
298 tokio::spawn(async move {
299 if let Some(sid) = persist_session_id.as_deref() {
302 if frames.send(Ok(json!({ "session_id": sid }))).await.is_err() {
303 return;
304 }
305 }
306
307 let mut final_assistant_text = String::new();
308 let mut stream_err: Option<String> = None;
309
310 for round in 0..MAX_TOOL_ROUNDS {
311 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel::<ChatEvent>(256);
312 let messages_clone = messages.clone();
313 let tools_clone = tools.clone();
314 let provider_clone = provider.clone();
315 let stream_handle = tokio::spawn(async move {
316 provider_clone
317 .chat_stream(messages_clone, tools_clone, event_tx)
318 .await
319 });
320
321 let mut tool_calls_this_round: Vec<trusty_common::ToolCall> = Vec::new();
322 let mut round_assistant_text = String::new();
323
324 while let Some(event) = event_rx.recv().await {
325 match event {
326 ChatEvent::Delta(text) => {
327 round_assistant_text.push_str(&text);
328 if frames.send(Ok(json!({ "delta": text }))).await.is_err() {
332 return;
333 }
334 }
335 ChatEvent::ToolCall(tc) => {
336 let _ = frames
337 .send(Ok(json!({ "tool_call": {
338 "id": tc.id,
339 "name": tc.name,
340 "arguments": tc.arguments,
341 }})))
342 .await;
343 tool_calls_this_round.push(tc);
344 }
345 ChatEvent::Done => break,
346 ChatEvent::Usage(_) => {}
349 ChatEvent::Error(e) => {
350 stream_err = Some(e);
351 break;
352 }
353 _ => {}
356 }
357 }
358
359 match stream_handle.await {
361 Ok(Ok(())) => {}
362 Ok(Err(e)) => stream_err = Some(e.to_string()),
363 Err(e) => stream_err = Some(format!("join: {e}")),
364 }
365
366 if stream_err.is_some() {
367 break;
368 }
369
370 final_assistant_text.push_str(&round_assistant_text);
371
372 if tool_calls_this_round.is_empty() {
373 break;
375 }
376
377 let assistant_tool_calls_json: Vec<Value> = tool_calls_this_round
379 .iter()
380 .map(|tc| {
381 json!({
382 "id": tc.id,
383 "type": "function",
384 "function": { "name": tc.name, "arguments": tc.arguments },
385 })
386 })
387 .collect();
388 messages.push(ChatMessage {
389 role: "assistant".to_string(),
390 content: round_assistant_text,
391 tool_call_id: None,
392 tool_calls: Some(assistant_tool_calls_json),
393 });
394
395 for tc in &tool_calls_this_round {
398 let result = execute_tool(&tc.name, &tc.arguments, &loop_state).await;
399 let result_str = result.to_string();
400 let _ = frames
401 .send(Ok(json!({ "tool_result": {
402 "id": tc.id,
403 "name": tc.name,
404 "content": &result_str,
405 }})))
406 .await;
407 messages.push(ChatMessage {
408 role: "tool".to_string(),
409 content: result_str,
410 tool_call_id: Some(tc.id.clone()),
411 tool_calls: None,
412 });
413 }
414
415 if round + 1 == MAX_TOOL_ROUNDS {
417 tracing::warn!(
418 "chat: hit MAX_TOOL_ROUNDS={} — terminating tool loop",
419 MAX_TOOL_ROUNDS
420 );
421 }
422 }
423
424 if let (Some(store), Some(sid)) = (session_store, persist_session_id.as_deref()) {
427 if !final_assistant_text.is_empty() {
428 history.push(ChatMessage {
429 role: "assistant".into(),
430 content: final_assistant_text,
431 tool_call_id: None,
432 tool_calls: None,
433 });
434 }
435 let core_history: Vec<trusty_common::memory_core::store::chat_sessions::ChatMessage> =
436 history
437 .iter()
438 .map(
439 |m| trusty_common::memory_core::store::chat_sessions::ChatMessage {
440 attachments: vec![],
441 role: m.role.clone(),
442 content: m.content.clone(),
443 },
444 )
445 .collect();
446 if let Err(e) = store.upsert_session(sid, &core_history) {
447 tracing::warn!("upsert_session failed: {e:#}");
448 }
449 }
450
451 if let Some(e) = stream_err {
458 let _ = frames.send(Err(RpcError::internal(e))).await;
459 }
460 });
461
462 Ok(items)
463}