1use crate::client::{OriginClient, OriginError};
2use crate::types::*;
3use rmcp::{
4 handler::server::router::tool::ToolRouter,
5 handler::server::wrapper::Parameters,
6 model::{CallToolResult, Content, Implementation, InitializeResult, ServerCapabilities},
7 service::{NotificationContext, RoleServer},
8 tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler,
9};
10use serde::{Deserialize, Deserializer};
11
12fn deserialize_optional_usize_lenient<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
15where
16 D: Deserializer<'de>,
17{
18 #[derive(Deserialize)]
19 #[serde(untagged)]
20 enum StringOrNumber {
21 Number(usize),
22 Str(String),
23 }
24
25 match Option::<StringOrNumber>::deserialize(deserializer)? {
26 None => Ok(None),
27 Some(StringOrNumber::Number(n)) => Ok(Some(n)),
28 Some(StringOrNumber::Str(s)) => s
29 .parse::<usize>()
30 .map(Some)
31 .map_err(serde::de::Error::custom),
32 }
33}
34
35#[derive(Clone, Debug, PartialEq)]
37pub enum TransportMode {
38 Stdio,
40 Http,
42}
43
44#[derive(Clone)]
45pub struct OriginMcpServer {
46 #[allow(dead_code)]
47 tool_router: ToolRouter<Self>,
48 client: OriginClient,
49 transport: TransportMode,
50 agent_name: String,
51 client_name: std::sync::Arc<std::sync::Mutex<Option<String>>>,
53 user_id: Option<String>,
54}
55
56#[derive(Debug, Deserialize, schemars::JsonSchema)]
61pub struct CaptureParams {
62 #[schemars(
63 description = "The memory content. Write as a complete statement with context and reasoning, not shorthand. One idea per memory."
64 )]
65 pub content: String,
66 #[schemars(
67 description = "\"profile\" (about the user) or \"knowledge\" (about the world) — or precise: \"identity\", \"preference\", \"goal\", \"fact\", \"decision\" — auto-classified if omitted"
68 )]
69 pub memory_type: Option<String>,
70 #[schemars(
71 description = "Topic scope (e.g. 'rust', 'work', 'health', 'origin'). Auto-detected if omitted."
72 )]
73 pub domain: Option<String>,
74 #[schemars(
75 description = "Person, project, or tool name to anchor to (e.g. 'Alice', 'Origin', 'PostgreSQL'). Helps build the knowledge graph."
76 )]
77 pub entity: Option<String>,
78 #[schemars(
79 description = "0.0-1.0. Leave unset for auto-calculation based on type and trust level. Set low (0.3-0.5) for uncertain info, high (0.8-1.0) for user-stated facts."
80 )]
81 pub confidence: Option<f32>,
82 #[schemars(
83 description = "source_id of a memory this replaces. Use when correcting or updating an existing memory — get the ID from recall first."
84 )]
85 pub supersedes: Option<String>,
86 #[schemars(
87 description = "Pre-extracted structured fields as a JSON object. Auto-extracted by backend; only supply if you have high-quality structured data already."
88 )]
89 pub structured_fields: Option<serde_json::Map<String, serde_json::Value>>,
90 #[schemars(
91 description = "A question this memory answers, for search matching. Auto-generated by backend; only supply to override."
92 )]
93 pub retrieval_cue: Option<String>,
94}
95
96#[derive(Debug, Deserialize, schemars::JsonSchema)]
97pub struct RecallParams {
98 #[schemars(
99 description = "Natural language search. Be specific: 'Alice database preference' finds more than 'database stuff'."
100 )]
101 pub query: String,
102 #[schemars(
103 description = "Max results, default 10. Use 3-5 for quick lookups, 10-20 for exploration."
104 )]
105 #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
106 pub limit: Option<usize>,
107 #[schemars(
108 description = "Filter by type. Two-level filter: \"profile\" (user-facing) or \"knowledge\" (world-facing), or precise: identity, preference, goal, fact, decision."
109 )]
110 pub memory_type: Option<String>,
111 #[schemars(description = "Filter by topic scope.")]
112 pub domain: Option<String>,
113}
114
115#[derive(Debug, Deserialize, schemars::JsonSchema)]
116pub struct ContextParams {
117 #[schemars(
118 description = "Topic or conversation summary to focus context retrieval. Omit at session start for general orientation; provide when shifting topics."
119 )]
120 pub topic: Option<String>,
121 #[schemars(
122 description = "Max context chunks, default 20. Increase for complex topics, decrease for quick check-ins."
123 )]
124 #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
125 pub limit: Option<usize>,
126 #[schemars(
127 description = "Scope context to a domain/space (e.g. 'work', 'personal'). Auto-detected from conversation if omitted."
128 )]
129 pub domain: Option<String>,
130}
131
132#[derive(Debug, Deserialize, schemars::JsonSchema)]
133pub struct ForgetParams {
134 #[schemars(
135 description = "The source_id of the memory to delete. Get this from recall results first."
136 )]
137 pub memory_id: String,
138}
139
140#[derive(Debug, Deserialize, schemars::JsonSchema)]
141pub struct DistillParams {
142 #[schemars(
143 description = "Optional page ID. If provided, re-distills only that page from its current sources. If omitted, runs a full distillation pass over any clusters with new sources."
144 )]
145 pub page_id: Option<String>,
146}
147
148#[derive(Debug, Deserialize, schemars::JsonSchema)]
149pub struct ListPendingParams {
150 #[schemars(
151 description = "Max results, default 20. Increase for full audit, decrease for quick check-in."
152 )]
153 #[serde(default, deserialize_with = "deserialize_optional_usize_lenient")]
154 pub limit: Option<usize>,
155}
156
157#[derive(Debug, Deserialize, schemars::JsonSchema)]
158pub struct ConfirmMemoryParams {
159 #[schemars(
160 description = "The source_id of the memory to confirm. Get this from list_pending or recall results."
161 )]
162 pub memory_id: String,
163}
164
165fn format_capture_success(resp: &StoreMemoryResponse) -> String {
168 let mut msg = format!("Stored {}", resp.source_id);
169 if !resp.warnings.is_empty() {
170 msg.push_str("\nWarnings:");
171 for warning in &resp.warnings {
172 msg.push_str(&format!("\n - {}", warning));
173 }
174 }
175 msg
176}
177
178fn daemon_setup_hint() -> &'static str {
179 "Install the local Origin runtime and run `origin setup`.
180
181Setup choices:
182- Basic Memory: store, search, and recall now. No model download or API key.
183- On-device Model: private local extraction and background refinement after model download.
184- Anthropic Key: richer extraction and background refinement using your API key.
185
186Install:
187 curl -fsSL https://raw.githubusercontent.com/7xuanlu/origin/main/install.sh | bash
188 export PATH=\"$HOME/.origin/bin:$PATH\"
189 origin setup
190 origin install
191 origin status"
192}
193
194fn tool_error(e: OriginError, verb: &str) -> CallToolResult {
198 let msg = match &e {
199 OriginError::Unreachable(_) => format!(
200 "Origin daemon is not reachable (retried 3x over ~6s). \
201 The {verb} was NOT completed.\n\n{}",
202 daemon_setup_hint()
203 ),
204 OriginError::Api { status, body } => format!(
205 "Origin daemon returned HTTP {status}: {body}. The {verb} may not have completed."
206 ),
207 OriginError::Deserialize(detail) => format!(
208 "Failed to parse daemon response: {detail}. \
209 This may indicate a version mismatch between origin-mcp and the daemon."
210 ),
211 };
212 CallToolResult::error(vec![Content::text(msg)])
213}
214
215fn format_doctor_message(status: &serde_json::Value) -> String {
216 let mode = status
217 .get("mode")
218 .and_then(|v| v.as_str())
219 .unwrap_or("unknown");
220 let setup_completed = status
221 .get("setup_completed")
222 .and_then(|v| v.as_bool())
223 .unwrap_or(false);
224 let anthropic_key_configured = status
225 .get("anthropic_key_configured")
226 .and_then(|v| v.as_bool())
227 .unwrap_or(false);
228 let local_model_selected = status.get("local_model_selected").and_then(|v| v.as_str());
229 let local_model_loaded = status.get("local_model_loaded").and_then(|v| v.as_str());
230 let local_model_cached = status
231 .get("local_model_cached")
232 .and_then(|v| v.as_bool())
233 .unwrap_or(false);
234
235 let mode_label = match mode {
236 "basic-memory" => "Basic Memory",
237 "local-model" => "On-device Model",
238 "anthropic-key" => "Anthropic Key",
239 other => other,
240 };
241 let local_model_line = match local_model_selected {
242 Some(id) => {
243 let cache_status = if local_model_cached {
244 "downloaded"
245 } else {
246 "not downloaded"
247 };
248 let loaded_status = if Some(id) == local_model_loaded {
249 ", loaded"
250 } else {
251 ""
252 };
253 format!("{id} ({cache_status}{loaded_status})")
254 }
255 None => "not selected".to_string(),
256 };
257 let refinement_line = if anthropic_key_configured || local_model_loaded.is_some() {
258 "enabled (richer extraction and background refinement are active)"
259 } else if setup_completed {
260 "paused (Basic Memory stores, searches, and recalls now. Choose an on-device model or Anthropic key for richer extraction.)"
261 } else {
262 "not configured"
263 };
264
265 let mut msg = format!(
266 "Origin daemon: running\n\
267 Setup: {}\n\
268 Mode: {mode_label}\n\
269 Anthropic key: {}\n\
270 On-device model: {local_model_line}\n\
271 Background refinement: {refinement_line}",
272 if setup_completed {
273 "completed"
274 } else {
275 "not completed"
276 },
277 if anthropic_key_configured {
278 "configured"
279 } else {
280 "not configured"
281 }
282 );
283
284 if !setup_completed {
285 msg.push_str(
286 "\n\nRun `origin setup` to choose Basic Memory, On-device Model, or Anthropic Key.",
287 );
288 } else if !anthropic_key_configured && local_model_loaded.is_none() {
289 msg.push_str(
290 "\n\nBasic Memory works now: capture, recall, and context are available. \
291 To enable richer extraction and background refinement, run `origin model install` \
292 or `origin key set anthropic`.",
293 );
294 }
295
296 msg
297}
298
299impl OriginMcpServer {
300 fn resolve_source_agent(&self, param_agent: Option<String>) -> Option<String> {
303 if let Some(ref agent) = param_agent {
305 if !agent.is_empty() {
306 return param_agent;
307 }
308 }
309 if let Ok(guard) = self.client_name.lock() {
311 if let Some(ref name) = *guard {
312 return Some(name.clone());
313 }
314 }
315 Some(self.agent_name.clone())
317 }
318
319 fn resolve_user_id(&self, param_user_id: Option<String>) -> Option<String> {
322 if self.transport == TransportMode::Http {
323 self.user_id.clone().or(param_user_id)
324 } else {
325 param_user_id
326 }
327 }
328
329 pub async fn capture_impl(&self, params: CaptureParams) -> Result<CallToolResult, McpError> {
330 let source_agent = self.resolve_source_agent(None);
334 if let Some(uid) = self.resolve_user_id(None) {
335 tracing::debug!(user_id = %uid, "capture invoked");
336 }
337
338 let req = StoreMemoryRequest {
339 content: params.content,
340 memory_type: params.memory_type,
341 domain: params.domain,
342 source_agent,
343 title: None,
344 confidence: params.confidence,
345 supersedes: params.supersedes,
346 entity: params.entity,
347 entity_id: None,
348 structured_fields: params.structured_fields.map(serde_json::Value::Object),
349 retrieval_cue: params.retrieval_cue,
350 };
351
352 let resp: StoreMemoryResponse = match self.client.post("/api/memory/store", &req).await {
353 Ok(r) => r,
354 Err(e) => return Ok(tool_error(e, "memory store")),
355 };
356
357 Ok(CallToolResult::success(vec![Content::text(
358 format_capture_success(&resp),
359 )]))
360 }
361
362 pub async fn recall_impl(&self, params: RecallParams) -> Result<CallToolResult, McpError> {
363 let req = SearchMemoryRequest {
364 query: params.query,
365 limit: params.limit.unwrap_or(10),
366 memory_type: params.memory_type,
367 domain: params.domain,
368 source_agent: self.resolve_source_agent(None),
369 };
370
371 let resp: SearchMemoryResponse = match self.client.post("/api/memory/search", &req).await {
372 Ok(r) => r,
373 Err(e) => return Ok(tool_error(e, "search")),
374 };
375
376 let json = serde_json::to_string_pretty(&resp.results)
377 .map_err(|e| McpError::internal_error(e.to_string(), None))?;
378
379 Ok(CallToolResult::success(vec![Content::text(format!(
380 "{} results ({:.1}ms)\n{}",
381 resp.results.len(),
382 resp.took_ms,
383 json
384 ))]))
385 }
386
387 pub async fn context_impl(&self, params: ContextParams) -> Result<CallToolResult, McpError> {
388 #[allow(deprecated)]
389 let req = ChatContextRequest {
390 query: None,
391 conversation_id: params.topic,
392 max_chunks: params.limit.unwrap_or(20),
393 relevance_threshold: None,
394 include_goals: true,
395 domain: params.domain,
396 };
397
398 let raw: serde_json::Value = match self.client.post("/api/chat-context", &req).await {
406 Ok(r) => r,
407 Err(e) => return Ok(tool_error(e, "context load")),
408 };
409
410 let context = raw
411 .get("context")
412 .and_then(|v| v.as_str())
413 .unwrap_or_default()
414 .to_string();
415
416 if context.is_empty() {
417 Ok(CallToolResult::success(vec![Content::text(
418 "No relevant context found".to_string(),
419 )]))
420 } else {
421 Ok(CallToolResult::success(vec![Content::text(context)]))
422 }
423 }
424
425 pub async fn doctor_impl(&self) -> Result<CallToolResult, McpError> {
426 let status: serde_json::Value = match self.client.get("/api/setup/status").await {
427 Ok(r) => r,
428 Err(OriginError::Api { status: 404, .. }) => {
429 return Ok(CallToolResult::error(vec![Content::text(
430 "Origin daemon is running, but it does not expose /api/setup/status. \
431 Update Origin, then run `origin doctor`."
432 .to_string(),
433 )]));
434 }
435 Err(e) => return Ok(tool_error(e, "status check")),
436 };
437
438 Ok(CallToolResult::success(vec![Content::text(
439 format_doctor_message(&status),
440 )]))
441 }
442
443 pub async fn forget_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
444 if self.transport == TransportMode::Http {
445 return Ok(CallToolResult::error(vec![Content::text(
446 "Delete operations are not available over remote connections. \
447 Use local MCP on the machine running Origin to delete memories."
448 .to_string(),
449 )]));
450 }
451
452 let resp: DeleteResponse = match self
453 .client
454 .delete(&format!("/api/memory/delete/{}", memory_id))
455 .await
456 {
457 Ok(r) => r,
458 Err(e) => return Ok(tool_error(e, "delete")),
459 };
460
461 Ok(CallToolResult::success(vec![Content::text(
462 if resp.deleted {
463 "Memory deleted"
464 } else {
465 "Memory not found"
466 }
467 .to_string(),
468 )]))
469 }
470
471 pub async fn distill_impl(&self, params: DistillParams) -> Result<CallToolResult, McpError> {
472 let path = match params.page_id.as_deref() {
473 Some(id) if !id.is_empty() => format!("/api/distill/{}", id),
474 _ => "/api/distill".to_string(),
475 };
476 match self
477 .client
478 .post::<serde_json::Value, serde_json::Value>(&path, &serde_json::json!({}))
479 .await
480 {
481 Ok(_) => Ok(CallToolResult::success(vec![Content::text(
482 match params.page_id {
483 Some(id) => format!("Re-distilled page {}.", id),
484 None => "Distillation pass triggered.".to_string(),
485 },
486 )])),
487 Err(e) => Ok(tool_error(e, "distill")),
488 }
489 }
490
491 pub async fn list_pending_impl(
492 &self,
493 params: ListPendingParams,
494 ) -> Result<CallToolResult, McpError> {
495 let limit = params.limit.unwrap_or(20).min(100);
496 let path = format!("/api/memory/list?confirmed=false&limit={}", limit);
497 let value: serde_json::Value = match self.client.get(&path).await {
498 Ok(v) => v,
499 Err(e) => return Ok(tool_error(e, "list_pending")),
500 };
501 let body = serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
502 Ok(CallToolResult::success(vec![Content::text(body)]))
503 }
504
505 pub async fn confirm_memory_impl(&self, memory_id: &str) -> Result<CallToolResult, McpError> {
506 if self.transport == TransportMode::Http {
507 return Ok(CallToolResult::error(vec![Content::text(
508 "Confirm operations are not available over remote connections. \
509 Use local MCP on the machine running Origin for review."
510 .to_string(),
511 )]));
512 }
513 let path = format!("/api/memory/confirm/{}", memory_id);
514 match self
515 .client
516 .post::<serde_json::Value, serde_json::Value>(&path, &serde_json::json!({}))
517 .await
518 {
519 Ok(_) => Ok(CallToolResult::success(vec![Content::text(format!(
520 "Memory {} confirmed.",
521 memory_id
522 ))])),
523 Err(e) => Ok(tool_error(e, "confirm_memory")),
524 }
525 }
526}
527
528#[tool_router]
531impl OriginMcpServer {
532 pub fn new(
533 client: OriginClient,
534 transport: TransportMode,
535 agent_name: String,
536 user_id: Option<String>,
537 ) -> Self {
538 Self {
539 tool_router: Self::tool_router(),
540 client,
541 transport,
542 agent_name,
543 client_name: std::sync::Arc::new(std::sync::Mutex::new(None)),
544 user_id,
545 }
546 }
547
548 #[tool(
551 description = "Capture a memory. Call PROACTIVELY when you learn something durable about the user — preferences, decisions, corrections, or facts about people/projects/tools they care about. Don't wait for the user to say 'remember this' or 'capture that' — that phrasing is a floor, not a trigger.\n\nWrite content as a complete, self-contained statement — someone reading it months later with no conversation context should understand it. Include the WHY, not just the WHAT. Name people, projects, and tools explicitly.\n\nThe backend auto-classifies type, extracts structured fields, detects entities, and links to the knowledge graph. You don't need to set memory_type or structured_fields unless you're confident — omitting them gets better results than guessing wrong.\n\nDo NOT store: system prompts, boot logs, heartbeat/health checks, transient task state ('currently working on...'), tool output/responses, architecture dumps, single-word acknowledgments, or content you have already stored. Focus on durable facts, preferences, decisions, lessons, gotchas, and identity information. Each call is one atomic idea — \"prefers TDD\" and \"uses pytest\" are two calls, not one.",
552 annotations(
553 title = "Capture",
554 read_only_hint = false,
555 destructive_hint = false,
556 idempotent_hint = false,
557 open_world_hint = false
558 )
559 )]
560 async fn capture(
561 &self,
562 Parameters(params): Parameters<CaptureParams>,
563 ) -> Result<CallToolResult, McpError> {
564 self.capture_impl(params).await
565 }
566
567 #[tool(
568 description = "Search memories by query. Use when the user asks 'do you remember', 'what do you know about', 'look up', or when you need a specific fact before acting.\n\nWrite queries as natural language — the search engine handles semantic matching. For precision, use filters (memory_type, domain) to narrow results. If you get too many results, add filters rather than making the query longer.\n\nThis is for targeted lookups. For broad session orientation, use context instead.",
569 annotations(title = "Recall", read_only_hint = true, open_world_hint = false)
570 )]
571 async fn recall(
572 &self,
573 Parameters(params): Parameters<RecallParams>,
574 ) -> Result<CallToolResult, McpError> {
575 self.recall_impl(params).await
576 }
577
578 #[tool(
579 description = "Load session context — identity, preferences, goals, and topic-relevant memories. Call this FIRST at the start of every session before doing anything else. Also call on major topic shifts or when the user says 'catch me up' or 'what's the background on'.\n\nThis returns a curated blend of who the user is and what's relevant. For specific factual lookups, use recall instead. Use the result to model how the user thinks, not just to look things up — their preferences and corrections tell you how they want to be helped.",
580 annotations(title = "Context", read_only_hint = true, open_world_hint = false)
581 )]
582 async fn context(
583 &self,
584 Parameters(params): Parameters<ContextParams>,
585 ) -> Result<CallToolResult, McpError> {
586 self.context_impl(params).await
587 }
588
589 #[tool(
590 description = "Diagnose the local Origin runtime. This is not part of the memory loop. Use only when Origin tools fail, when onboarding a new MCP client, or when the user asks why setup, extraction, or background refinement is paused. Reports daemon reachability, setup mode, Basic Memory, On-device Model, Anthropic key state, and on-device model state.",
591 annotations(title = "Doctor", read_only_hint = true, open_world_hint = false)
592 )]
593 async fn doctor(&self) -> Result<CallToolResult, McpError> {
594 self.doctor_impl().await
595 }
596
597 #[tool(
598 description = "Delete a memory by ID. Use when the user says 'forget this', 'delete that', 'that's wrong and should be removed'. Requires the source_id — get it from recall first.\n\nThis is destructive and cannot be undone. For corrections, prefer storing a new memory with the supersedes param pointing to the old one — this preserves history.",
599 annotations(
600 title = "Forget",
601 read_only_hint = false,
602 destructive_hint = true,
603 idempotent_hint = true,
604 open_world_hint = false
605 )
606 )]
607 async fn forget(
608 &self,
609 Parameters(params): Parameters<ForgetParams>,
610 ) -> Result<CallToolResult, McpError> {
611 self.forget_impl(¶ms.memory_id).await
612 }
613
614 #[tool(
615 description = "Trigger Origin's distillation pass. With no `page_id`, runs a full pass that clusters new memories into pages and refreshes the wiki view. With a `page_id`, re-distills that single page from its current sources. Use when the user explicitly asks to synthesize, distill, or rebuild a page. The daemon also runs distillation periodically in the background, so don't trigger redundantly during normal flow.",
616 annotations(
617 title = "Distill",
618 read_only_hint = false,
619 destructive_hint = false,
620 idempotent_hint = true,
621 open_world_hint = false
622 )
623 )]
624 async fn distill(
625 &self,
626 Parameters(params): Parameters<DistillParams>,
627 ) -> Result<CallToolResult, McpError> {
628 self.distill_impl(params).await
629 }
630
631 #[tool(
632 description = "List unconfirmed memories pending review. Use when the user wants to audit what got captured before it becomes authoritative — typical phrases: 'review pending', 'show unconfirmed', 'what got captured'. Pair with `confirm_memory` to accept and `forget` to reject.",
633 annotations(title = "List pending", read_only_hint = true, open_world_hint = false)
634 )]
635 async fn list_pending(
636 &self,
637 Parameters(params): Parameters<ListPendingParams>,
638 ) -> Result<CallToolResult, McpError> {
639 self.list_pending_impl(params).await
640 }
641
642 #[tool(
643 description = "Confirm a pending memory by source_id. Use during review to accept a memory the agent captured. The user typically picks from a `list_pending` result. To reject instead, call `forget` with the same `memory_id`.",
644 annotations(
645 title = "Confirm memory",
646 read_only_hint = false,
647 destructive_hint = false,
648 idempotent_hint = true,
649 open_world_hint = false
650 )
651 )]
652 async fn confirm_memory(
653 &self,
654 Parameters(params): Parameters<ConfirmMemoryParams>,
655 ) -> Result<CallToolResult, McpError> {
656 self.confirm_memory_impl(¶ms.memory_id).await
657 }
658}
659
660#[tool_handler]
663impl ServerHandler for OriginMcpServer {
664 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
665 if let Some(client_info) = context.peer.peer_info() {
667 let name = &client_info.client_info.name;
668 if !name.is_empty() {
669 if let Ok(mut guard) = self.client_name.lock() {
670 tracing::info!("MCP client identified: {}", name);
671 *guard = Some(name.clone());
672 }
673 }
674 }
675 }
676
677 fn get_info(&self) -> InitializeResult {
678 InitializeResult::new(
679 ServerCapabilities::builder()
680 .enable_tools()
681 .build(),
682 )
683 .with_server_info(
684 Implementation::new("origin-mcp", env!("CARGO_PKG_VERSION"))
685 )
686 .with_instructions(
687 "Origin is your personal memory layer — a local knowledge base that persists across sessions and tools.\n\
688 Think of yourself as a curator, not a logger. Store insights, not conversation artifacts.\n\n\
689 Origin is cumulative: each memory you store can be recalled, linked, and distilled into knowledge over time. \
690 It's also shared across all the user's tools: what you write, other agents (Claude Desktop, Claude Code, \
691 ChatGPT, Cursor, etc.) will read later. Write for any future reader, not just this conversation.\n\n\
692 FIRST THING EVERY SESSION: Call context to load the user's identity, preferences, goals, and\n\
693 topic-relevant memories. This is how you know who you're talking to. Use the result to model how the \
694 user thinks — their preferences, corrections, and past decisions tell you how they want to be helped, \
695 not just what they already know.\n\n\
696 STORE PROACTIVELY — don't wait for the user to ask.\n\
697 - The user states a preference (\"I use X because...\", \"I prefer Y over Z\")\n\
698 - The user makes a decision (\"going with approach A\", \"switching to B\")\n\
699 - The user corrects you or prior info (\"actually, it's C, not D\") — store the correction so it sticks\n\
700 - The user shares a durable fact about themselves, their work, or people/projects/tools they care about — \
701 anchor it to the entity\n\n\
702 If the user asks explicitly (\"remember this\", \"save this\", \"don't forget\"), that's a floor — you \
703 should have already stored it.\n\n\
704 WHEN NOT TO STORE:\n\
705 - Conversation filler (\"ok\", \"thanks\", \"let's move on\")\n\
706 - Things the user can trivially re-derive (file paths, recent git history)\n\
707 - Anything already stored — recall first if unsure\n\
708 - Tool output or command results (file contents, git history, build logs) — these are derivable\n\
709 - General world facts or documentation that aren't personal to this user (e.g., \"Rust has a borrow \
710 checker\", \"PostgreSQL supports JSONB\") — those are not memory material.\n\
711 - Your own inferences about the user that they didn't express. Store what they said; infer from that \
712 when responding.\n\n\
713 CONTENT QUALITY — this is where you make the biggest difference:\n\
714 - Specific beats vague: \"prefers Rust for CLI tools because of compile-time safety\" > \"likes Rust\"\n\
715 - Include the WHY: the backend can classify \"dark mode\" as a preference, but only you know\n\
716 \"switched to dark mode because of migraines from bright screens\"\n\
717 - Name the entities: mention people, projects, tools by name — this powers the knowledge graph\n\
718 - Atomic: one idea per memory — \"prefers TDD\" and \"uses pytest\" should be two memories, not one\n\
719 - Declarative, not narrative: \"User prefers X because Y\" — not \"User said today they prefer X\". \
720 Memories outlive the conversation that produced them.\n\n\
721 MEMORY TYPES — omit and trust the backend.\n\n\
722 By default, do NOT set memory_type. The backend auto-classifies into identity / preference / goal / \
723 fact / decision with more context than you have. Agents that over-specify types tend to pick wrong.\n\n\
724 Opt-in specification:\n\
725 - \"profile\" — you're sure it's about the user (identity/preference/goal)\n\
726 - \"knowledge\" — you're sure it's about the world (fact/decision)\n\
727 - Precise type — only if you're confident and the distinction matters.\n\n\
728 EXCEPTION — decisions carry structured fields (alternatives considered, reversibility, domain) \
729 that power the Decision Log view. Set memory_type=\"decision\" explicitly ONLY when the user \
730 articulated alternatives weighed AND the reasoning for the choice. A bare \"I'm switching to Cursor\" \
731 is just a preference change — omit the type. \"Switching to Cursor over VSCode because of better \
732 Claude integration, and we can always go back\" — that's a decision.\n\n\
733 RECALL vs CONTEXT:\n\
734 - context: broad orientation, session start, topic shifts, \"catch me up\"\n\
735 - recall: specific lookup (\"what's Alice's role?\", \"database preferences\", \"our auth decision\")\n\n\
736 The backend handles classification, entity extraction, structured fields, quality scoring,\n\
737 and dedup — you don't need to replicate that logic. Focus on what only you know:\n\
738 the conversational context, why something matters, and what the user actually cares about."
739 )
740 }
741}
742
743#[cfg(test)]
744mod tests {
745 use super::*;
746 use crate::client::OriginClient;
747 use crate::types::{
748 ChatContextRequest, ChatContextResponse, SearchMemoryRequest, SearchResult,
749 StoreMemoryRequest, StoreMemoryResponse,
750 };
751
752 fn make_server(
753 transport: TransportMode,
754 agent_name: &str,
755 user_id: Option<&str>,
756 ) -> OriginMcpServer {
757 let client = OriginClient::new("http://127.0.0.1:19999".into());
758 OriginMcpServer::new(
759 client,
760 transport,
761 agent_name.into(),
762 user_id.map(String::from),
763 )
764 }
765
766 #[test]
769 fn test_http_mode_prefers_param_over_agent_name() {
770 let server = make_server(TransportMode::Http, "claude.ai", None);
771 let result = server.resolve_source_agent(Some("user-provided".into()));
773 assert_eq!(result, Some("user-provided".into()));
774 }
775
776 #[test]
777 fn test_http_mode_sets_source_agent_when_none() {
778 let server = make_server(TransportMode::Http, "chatgpt", None);
779 let result = server.resolve_source_agent(None);
780 assert_eq!(result, Some("chatgpt".into()));
781 }
782
783 #[test]
784 fn test_stdio_mode_passes_through_source_agent() {
785 let server = make_server(TransportMode::Stdio, "ignored", None);
786 let result = server.resolve_source_agent(Some("user-provided".into()));
787 assert_eq!(result, Some("user-provided".into()));
788 }
789
790 #[test]
791 fn test_stdio_mode_falls_back_to_agent_name() {
792 let server = make_server(TransportMode::Stdio, "fallback", None);
793 let result = server.resolve_source_agent(None);
795 assert_eq!(result, Some("fallback".into()));
796 }
797
798 #[test]
799 fn test_http_mode_resolves_configured_user_id_for_local_use() {
800 let server = make_server(TransportMode::Http, "agent", Some("lucian"));
801 let result = server.resolve_user_id(None);
802 assert_eq!(result, Some("lucian".into()));
803 }
804
805 #[test]
806 fn test_transport_mode_equality() {
807 assert_eq!(TransportMode::Stdio, TransportMode::Stdio);
808 assert_eq!(TransportMode::Http, TransportMode::Http);
809 assert_ne!(TransportMode::Stdio, TransportMode::Http);
810 }
811
812 #[test]
815 fn test_capture_params_minimal() {
816 let json = r#"{"content": "Lucian prefers dark mode"}"#;
817 let params: CaptureParams = serde_json::from_str(json).unwrap();
818 assert_eq!(params.content, "Lucian prefers dark mode");
819 assert!(params.memory_type.is_none());
820 assert!(params.domain.is_none());
821 assert!(params.entity.is_none());
822 assert!(params.confidence.is_none());
823 assert!(params.supersedes.is_none());
824 }
825
826 #[test]
827 fn test_capture_params_full() {
828 let json = r#"{
829 "content": "We chose PostgreSQL over MongoDB",
830 "memory_type": "decision",
831 "domain": "origin",
832 "entity": "PostgreSQL",
833 "confidence": 0.95,
834 "supersedes": "mem_abc123"
835 }"#;
836 let params: CaptureParams = serde_json::from_str(json).unwrap();
837 assert_eq!(params.content, "We chose PostgreSQL over MongoDB");
838 assert_eq!(params.memory_type.as_deref(), Some("decision"));
839 assert_eq!(params.domain.as_deref(), Some("origin"));
840 assert_eq!(params.entity.as_deref(), Some("PostgreSQL"));
841 assert_eq!(params.confidence, Some(0.95));
842 assert_eq!(params.supersedes.as_deref(), Some("mem_abc123"));
843 }
844
845 #[test]
846 fn test_capture_params_missing_content_fails() {
847 let json = r#"{"memory_type": "fact"}"#;
848 let result = serde_json::from_str::<CaptureParams>(json);
849 assert!(result.is_err());
850 }
851
852 #[test]
855 fn test_recall_params_minimal() {
856 let json = r#"{"query": "what does Alice work on?"}"#;
857 let params: RecallParams = serde_json::from_str(json).unwrap();
858 assert_eq!(params.query, "what does Alice work on?");
859 assert!(params.limit.is_none());
860 }
861
862 #[test]
863 fn test_recall_params_full() {
864 let json = r#"{
865 "query": "database preferences",
866 "limit": 5,
867 "memory_type": "decision",
868 "domain": "origin"
869 }"#;
870 let params: RecallParams = serde_json::from_str(json).unwrap();
871 assert_eq!(params.query, "database preferences");
872 assert_eq!(params.limit, Some(5));
873 assert_eq!(params.memory_type.as_deref(), Some("decision"));
874 assert_eq!(params.domain.as_deref(), Some("origin"));
875 }
876
877 #[test]
878 fn test_recall_params_limit_as_string() {
879 let json = r#"{"query": "test", "limit": "10"}"#;
880 let params: RecallParams = serde_json::from_str(json).unwrap();
881 assert_eq!(params.limit, Some(10));
882 }
883
884 #[test]
885 fn test_recall_params_missing_query_fails() {
886 let json = r#"{"limit": 5}"#;
887 let result = serde_json::from_str::<RecallParams>(json);
888 assert!(result.is_err());
889 }
890
891 #[test]
894 fn test_context_params_empty() {
895 let json = r#"{}"#;
896 let params: ContextParams = serde_json::from_str(json).unwrap();
897 assert!(params.topic.is_none());
898 assert!(params.limit.is_none());
899 assert!(params.domain.is_none());
900 }
901
902 #[test]
903 fn test_context_params_full() {
904 let json = r#"{"topic": "project Origin architecture", "limit": 30, "domain": "work"}"#;
905 let params: ContextParams = serde_json::from_str(json).unwrap();
906 assert_eq!(params.topic.as_deref(), Some("project Origin architecture"));
907 assert_eq!(params.limit, Some(30));
908 assert_eq!(params.domain.as_deref(), Some("work"));
909 }
910
911 #[test]
912 fn test_context_params_limit_as_string() {
913 let json = r#"{"limit": "20"}"#;
914 let params: ContextParams = serde_json::from_str(json).unwrap();
915 assert_eq!(params.limit, Some(20));
916 }
917
918 #[test]
919 fn store_memory_request_serialization_excludes_user_id() {
920 let req = StoreMemoryRequest {
921 content: "test content".into(),
922 memory_type: None,
923 domain: None,
924 source_agent: Some("test-agent".into()),
925 title: None,
926 confidence: None,
927 supersedes: None,
928 entity: None,
929 entity_id: None,
930 structured_fields: None,
931 retrieval_cue: None,
932 };
933 let json = serde_json::to_value(&req).unwrap();
934 let obj = json.as_object().unwrap();
935 assert!(
936 !obj.contains_key("user_id"),
937 "user_id must not be on the wire; got: {:?}",
938 obj.keys().collect::<Vec<_>>()
939 );
940 }
941
942 #[test]
943 fn capture_success_message_is_terse() {
944 let resp = StoreMemoryResponse {
945 source_id: "mem_abc".into(),
946 chunks_created: 3,
947 memory_type: "fact".into(),
948 entity_id: Some("ent_xyz".into()),
949 quality: Some("high".into()),
950 warnings: vec![],
951 extraction_method: "llm".into(),
952 enrichment: String::new(),
953 hint: String::new(),
954 };
955 let msg = format_capture_success(&resp);
956 assert_eq!(msg, "Stored mem_abc");
957 assert!(!msg.contains("chunks"));
958 assert!(!msg.contains("quality"));
959 assert!(!msg.contains("entity"));
960 }
961
962 #[test]
963 fn capture_success_message_surfaces_warnings() {
964 let resp = StoreMemoryResponse {
965 source_id: "mem_abc".into(),
966 chunks_created: 1,
967 memory_type: "decision".into(),
968 entity_id: None,
969 quality: None,
970 warnings: vec!["decision memory missing required 'claim' field".into()],
971 extraction_method: "agent".into(),
972 enrichment: String::new(),
973 hint: String::new(),
974 };
975 let msg = format_capture_success(&resp);
976 assert!(msg.starts_with("Stored mem_abc"));
977 assert!(msg.contains("Warnings:"));
978 assert!(msg.contains("decision memory missing required 'claim' field"));
979 }
980
981 #[test]
982 fn doctor_basic_memory_message_sets_expectations() {
983 let msg = format_doctor_message(&serde_json::json!({
984 "setup_completed": true,
985 "mode": "basic-memory",
986 "anthropic_key_configured": false,
987 "local_model_selected": null,
988 "local_model_loaded": null,
989 "local_model_cached": false
990 }));
991
992 assert!(msg.contains("Mode: Basic Memory"));
993 assert!(msg.contains("On-device model: not selected"));
994 assert!(msg.contains("Background refinement: paused"));
995 assert!(msg.contains("Basic Memory works now: capture, recall, and context are available"));
996 assert!(msg.contains("origin model install"));
997 assert!(msg.contains("origin key set anthropic"));
998 }
999
1000 #[test]
1001 fn doctor_on_device_model_message_shows_loaded_model() {
1002 let msg = format_doctor_message(&serde_json::json!({
1003 "setup_completed": true,
1004 "mode": "local-model",
1005 "anthropic_key_configured": false,
1006 "local_model_selected": "qwen3-1.7b",
1007 "local_model_loaded": "qwen3-1.7b",
1008 "local_model_cached": true
1009 }));
1010
1011 assert!(msg.contains("Mode: On-device Model"), "{msg}");
1012 assert!(
1013 msg.contains("On-device model: qwen3-1.7b (downloaded, loaded)"),
1014 "{msg}"
1015 );
1016 assert!(msg.contains("Background refinement: enabled"), "{msg}");
1017 assert!(!msg.contains("Basic Memory works now"));
1018 }
1019
1020 #[test]
1021 fn doctor_unconfigured_message_names_three_setup_paths() {
1022 let msg = format_doctor_message(&serde_json::json!({
1023 "setup_completed": false,
1024 "mode": "unknown",
1025 "anthropic_key_configured": false,
1026 "local_model_selected": null,
1027 "local_model_loaded": null,
1028 "local_model_cached": false
1029 }));
1030
1031 assert!(msg.contains("Setup: not completed"));
1032 assert!(msg.contains("Run `origin setup`"));
1033 assert!(msg.contains("Basic Memory, On-device Model, or Anthropic Key"));
1034 }
1035
1036 #[test]
1037 fn search_memory_request_serialization_excludes_entity() {
1038 let req = SearchMemoryRequest {
1039 query: "test".into(),
1040 limit: 10,
1041 memory_type: None,
1042 domain: None,
1043 source_agent: None,
1044 };
1045 let json = serde_json::to_value(&req).unwrap();
1046 let obj = json.as_object().unwrap();
1047 assert!(
1048 !obj.contains_key("entity"),
1049 "entity must not be on the wire; got keys: {:?}",
1050 obj.keys().collect::<Vec<_>>()
1051 );
1052 }
1053
1054 #[test]
1055 fn chat_context_request_serialization_includes_domain() {
1056 #[allow(deprecated)]
1057 let req = ChatContextRequest {
1058 query: None,
1059 conversation_id: Some("topic".into()),
1060 max_chunks: 20,
1061 relevance_threshold: None,
1062 include_goals: true,
1063 domain: Some("work".into()),
1064 };
1065 let json = serde_json::to_value(&req).unwrap();
1066 assert_eq!(json["domain"], serde_json::json!("work"));
1067 assert_eq!(json["conversation_id"], serde_json::json!("topic"));
1068 }
1069
1070 #[test]
1071 fn chat_context_response_deserializes_with_profile_and_knowledge() {
1072 let json = r#"{
1073 "context": "user is Lucian, prefers Rust",
1074 "profile": {
1075 "narrative": "n",
1076 "identity": ["rust"],
1077 "preferences": [],
1078 "goals": []
1079 },
1080 "knowledge": {
1081 "pages": [],
1082 "decisions": [],
1083 "relevant_memories": [],
1084 "graph_context": []
1085 },
1086 "took_ms": 42.0,
1087 "token_estimates": {
1088 "tier1_identity": 10,
1089 "tier2_project": 20,
1090 "tier3_relevant": 30,
1091 "total": 60
1092 }
1093 }"#;
1094 let parsed: ChatContextResponse = serde_json::from_str(json).unwrap();
1095 assert_eq!(parsed.context, "user is Lucian, prefers Rust");
1096 assert_eq!(parsed.profile.identity, vec!["rust"]);
1097 assert_eq!(parsed.token_estimates.total, 60);
1098 }
1099
1100 #[test]
1101 fn capture_params_structured_fields_schema_is_object() {
1102 use schemars::schema_for;
1103
1104 let schema = schema_for!(CaptureParams);
1105 let json = serde_json::to_value(&schema).unwrap();
1106 let sf_schema = json
1107 .pointer("/properties/structured_fields")
1108 .expect("structured_fields property in schema");
1109 let type_val = sf_schema
1110 .pointer("/type")
1111 .unwrap_or(&serde_json::Value::Null);
1112 let type_str = match type_val {
1113 serde_json::Value::String(s) => s.clone(),
1114 serde_json::Value::Array(arr) => arr
1115 .iter()
1116 .filter_map(|v| v.as_str())
1117 .collect::<Vec<_>>()
1118 .join(","),
1119 other => panic!(
1120 "structured_fields schema lacks type constraint; got: {:?}",
1121 other
1122 ),
1123 };
1124 assert!(
1125 type_str.contains("object"),
1126 "expected object type, got: {}",
1127 type_str
1128 );
1129 }
1130
1131 #[test]
1134 fn test_forget_params() {
1135 let json = r#"{"memory_id": "mem_abc123"}"#;
1136 let params: ForgetParams = serde_json::from_str(json).unwrap();
1137 assert_eq!(params.memory_id, "mem_abc123");
1138 }
1139
1140 #[test]
1141 fn test_forget_params_missing_id_fails() {
1142 let json = r#"{}"#;
1143 let result = serde_json::from_str::<ForgetParams>(json);
1144 assert!(result.is_err());
1145 }
1146
1147 #[test]
1150 fn test_store_request_includes_new_fields() {
1151 let req = StoreMemoryRequest {
1152 content: "test".into(),
1153 memory_type: Some("decision".into()),
1154 domain: None,
1155 source_agent: Some("claude".into()),
1156 title: None,
1157 confidence: Some(0.9),
1158 supersedes: Some("old_id".into()),
1159 entity: Some("PostgreSQL".into()),
1160 entity_id: None,
1161 structured_fields: None,
1162 retrieval_cue: None,
1163 };
1164 let json = serde_json::to_value(&req).unwrap();
1165 assert_eq!(json["entity"], "PostgreSQL");
1166 assert_eq!(json["supersedes"], "old_id");
1167 assert!(json["confidence"].as_f64().unwrap() > 0.89);
1168 assert_eq!(json["source_agent"], "claude");
1169 assert!(json.get("user_id").is_none());
1170 }
1171
1172 #[test]
1173 fn test_store_request_minimal() {
1174 let req = StoreMemoryRequest {
1175 content: "hello".into(),
1176 memory_type: Some("fact".into()),
1177 domain: None,
1178 source_agent: None,
1179 title: None,
1180 confidence: None,
1181 supersedes: None,
1182 entity: None,
1183 entity_id: None,
1184 structured_fields: None,
1185 retrieval_cue: None,
1186 };
1187 let json = serde_json::to_value(&req).unwrap();
1188 assert_eq!(json["content"], "hello");
1189 assert_eq!(json["memory_type"], "fact");
1190 assert!(json.get("user_id").is_none());
1191 }
1192
1193 #[test]
1196 fn test_store_response_with_new_fields() {
1197 let json = r#"{
1198 "source_id": "mem_xyz",
1199 "chunks_created": 2,
1200 "memory_type": "fact",
1201 "entity_id": "ent_abc",
1202 "quality": "high",
1203 "warnings": ["decision memory missing claim"],
1204 "extraction_method": "agent"
1205 }"#;
1206 let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1207 assert_eq!(resp.source_id, "mem_xyz");
1208 assert_eq!(resp.chunks_created, 2);
1209 assert_eq!(resp.memory_type, "fact");
1210 assert_eq!(resp.entity_id.as_deref(), Some("ent_abc"));
1211 assert_eq!(resp.quality.as_deref(), Some("high"));
1212 assert_eq!(resp.warnings, vec!["decision memory missing claim"]);
1213 assert_eq!(resp.extraction_method, "agent");
1214 }
1215
1216 #[test]
1217 fn test_store_response_backward_compat_no_new_fields() {
1218 let json = r#"{
1220 "source_id": "mem_old",
1221 "chunks_created": 1,
1222 "memory_type": "fact"
1223 }"#;
1224 let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1225 assert_eq!(resp.source_id, "mem_old");
1226 assert_eq!(resp.chunks_created, 1);
1227 assert_eq!(resp.memory_type, "fact");
1228 assert!(resp.entity_id.is_none());
1229 assert!(resp.quality.is_none());
1230 assert!(resp.warnings.is_empty());
1231 assert_eq!(resp.extraction_method, "unknown");
1232 }
1233
1234 #[test]
1235 fn test_store_response_with_warnings_and_extraction_method() {
1236 let json = r#"{
1237 "source_id": "mem_xyz",
1238 "chunks_created": 1,
1239 "memory_type": "decision",
1240 "warnings": ["decision memory missing required 'claim' field"],
1241 "extraction_method": "llm"
1242 }"#;
1243 let resp: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1244 assert_eq!(resp.memory_type, "decision");
1245 assert_eq!(
1246 resp.warnings,
1247 vec!["decision memory missing required 'claim' field"]
1248 );
1249 assert_eq!(resp.extraction_method, "llm");
1250 }
1251
1252 #[test]
1255 fn test_search_result_with_new_fields() {
1256 let json = r#"{
1257 "id": "1",
1258 "content": "We chose Postgres",
1259 "source": "memory",
1260 "source_id": "mem_1",
1261 "title": "DB decision",
1262 "url": null,
1263 "chunk_index": 0,
1264 "last_modified": 1711000000,
1265 "score": 0.95,
1266 "chunk_type": "memory",
1267 "language": "en",
1268 "semantic_unit": "sentence",
1269 "memory_type": "decision",
1270 "domain": "origin",
1271 "source_agent": "claude",
1272 "confidence": 0.9,
1273 "confirmed": true,
1274 "stability": "standard",
1275 "supersedes": "mem_0",
1276 "summary": "DB choice",
1277 "entity_id": "ent_pg",
1278 "entity_name": "PostgreSQL",
1279 "quality": "high",
1280 "is_archived": false,
1281 "is_recap": false,
1282 "source_text": "We chose Postgres",
1283 "raw_score": 0.42
1284 }"#;
1285 let result: SearchResult = serde_json::from_str(json).unwrap();
1286 assert_eq!(result.chunk_type.as_deref(), Some("memory"));
1287 assert_eq!(result.language.as_deref(), Some("en"));
1288 assert_eq!(result.semantic_unit.as_deref(), Some("sentence"));
1289 assert_eq!(result.stability.as_deref(), Some("standard"));
1290 assert_eq!(result.supersedes.as_deref(), Some("mem_0"));
1291 assert_eq!(result.summary.as_deref(), Some("DB choice"));
1292 assert_eq!(result.entity_id.as_deref(), Some("ent_pg"));
1293 assert_eq!(result.entity_name.as_deref(), Some("PostgreSQL"));
1294 assert_eq!(result.quality.as_deref(), Some("high"));
1295 assert!(!result.is_archived);
1296 assert!(!result.is_recap);
1297 assert_eq!(result.source_text.as_deref(), Some("We chose Postgres"));
1298 assert!((result.raw_score - 0.42).abs() < f32::EPSILON);
1299 }
1300
1301 #[test]
1302 fn test_search_result_backward_compat_no_new_fields() {
1303 let json = r#"{
1305 "id": "1",
1306 "content": "test",
1307 "source": "memory",
1308 "source_id": "mem_1",
1309 "title": "test",
1310 "url": null,
1311 "chunk_index": 0,
1312 "last_modified": 1711000000,
1313 "score": 0.8,
1314 "memory_type": "fact",
1315 "domain": null,
1316 "source_agent": null,
1317 "confidence": null,
1318 "confirmed": null
1319 }"#;
1320 let result: SearchResult = serde_json::from_str(json).unwrap();
1321 assert!(result.entity_id.is_none());
1322 assert!(result.entity_name.is_none());
1323 assert!(result.quality.is_none());
1324 assert!(!result.is_archived);
1325 assert!(!result.is_recap);
1326 assert!(result.structured_fields.is_none());
1327 assert!(result.retrieval_cue.is_none());
1328 assert_eq!(result.raw_score, 0.0);
1329 }
1330
1331 #[test]
1332 fn test_search_result_with_structured_fields_and_retrieval_cue() {
1333 let json = r#"{
1334 "id": "1",
1335 "content": "Lucian prefers dark mode",
1336 "source": "memory",
1337 "source_id": "mem_1",
1338 "title": "Dark mode preference",
1339 "url": null,
1340 "chunk_index": 0,
1341 "last_modified": 1711000000,
1342 "score": 0.92,
1343 "memory_type": "preference",
1344 "domain": null,
1345 "source_agent": null,
1346 "confidence": null,
1347 "confirmed": null,
1348 "structured_fields": "{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}",
1349 "retrieval_cue": "What UI theme does Lucian prefer?"
1350 }"#;
1351 let result: SearchResult = serde_json::from_str(json).unwrap();
1352 assert_eq!(
1353 result.structured_fields.as_deref(),
1354 Some("{\"theme\":\"dark\",\"applies_to\":\"all_apps\"}")
1355 );
1356 assert_eq!(
1357 result.retrieval_cue.as_deref(),
1358 Some("What UI theme does Lucian prefer?")
1359 );
1360 assert!(!result.is_archived);
1361 assert!(!result.is_recap);
1362 assert_eq!(result.raw_score, 0.0);
1363 }
1364
1365 #[test]
1366 fn test_search_result_knowledge_graph_source() {
1367 let json = r#"{
1369 "id": "obs_1",
1370 "content": "Prefers Rust over Go",
1371 "source": "knowledge_graph",
1372 "source_id": "ent_lucian",
1373 "title": "Lucian",
1374 "url": null,
1375 "chunk_index": 0,
1376 "last_modified": 1711000000,
1377 "score": 1.14,
1378 "memory_type": null,
1379 "domain": null,
1380 "source_agent": null,
1381 "confidence": null,
1382 "confirmed": null,
1383 "entity_id": "ent_lucian",
1384 "entity_name": "Lucian"
1385 }"#;
1386 let result: SearchResult = serde_json::from_str(json).unwrap();
1387 assert_eq!(result.source, "knowledge_graph");
1388 assert_eq!(result.entity_id.as_deref(), Some("ent_lucian"));
1389 assert_eq!(result.entity_name.as_deref(), Some("Lucian"));
1390 assert!(!result.is_archived);
1391 assert!(!result.is_recap);
1392 assert_eq!(result.raw_score, 0.0);
1393 }
1394
1395 #[tokio::test]
1398 async fn test_forget_blocked_on_http_transport() {
1399 let server = make_server(TransportMode::Http, "agent", None);
1400 let result = server.forget_impl("mem_123").await.unwrap();
1401 let content = &result.content[0];
1403 match content.raw {
1404 rmcp::model::RawContent::Text(ref tc) => {
1405 assert!(tc.text.contains("not available over remote connections"));
1406 }
1407 _ => panic!("expected text content"),
1408 }
1409 }
1410
1411 #[tokio::test]
1412 async fn test_forget_allowed_on_stdio_transport() {
1413 let server = make_server(TransportMode::Stdio, "agent", None);
1418 let result = server.forget_impl("mem_123").await.unwrap();
1419 assert!(
1420 result.is_error.unwrap_or(false),
1421 "should fail with connection error, not transport block"
1422 );
1423 }
1424
1425 #[test]
1428 fn test_context_request_default_limit() {
1429 let params = ContextParams {
1430 topic: Some("test".into()),
1431 limit: None,
1432 domain: None,
1433 };
1434 #[allow(deprecated)]
1435 let req = ChatContextRequest {
1436 query: None,
1437 conversation_id: params.topic,
1438 max_chunks: params.limit.unwrap_or(20),
1439 relevance_threshold: None,
1440 include_goals: true,
1441 domain: params.domain,
1442 };
1443 assert_eq!(req.max_chunks, 20);
1444 }
1445
1446 #[test]
1447 fn test_context_request_custom_limit() {
1448 let params = ContextParams {
1449 topic: None,
1450 limit: Some(5),
1451 domain: Some("work".into()),
1452 };
1453 #[allow(deprecated)]
1454 let req = ChatContextRequest {
1455 query: None,
1456 conversation_id: params.topic,
1457 max_chunks: params.limit.unwrap_or(20),
1458 relevance_threshold: None,
1459 include_goals: true,
1460 domain: params.domain,
1461 };
1462 assert_eq!(req.max_chunks, 5);
1463 assert_eq!(req.domain.as_deref(), Some("work"));
1464 }
1465
1466 #[test]
1467 fn test_context_maps_topic_to_conversation_id() {
1468 let params = ContextParams {
1469 topic: Some("project Origin".into()),
1470 limit: None,
1471 domain: None,
1472 };
1473 #[allow(deprecated)]
1474 let req = ChatContextRequest {
1475 query: None,
1476 conversation_id: params.topic.clone(),
1477 max_chunks: params.limit.unwrap_or(20),
1478 relevance_threshold: None,
1479 include_goals: true,
1480 domain: params.domain,
1481 };
1482 assert_eq!(req.conversation_id.as_deref(), Some("project Origin"));
1483 }
1484
1485 #[test]
1488 fn test_capture_constructs_store_request_with_entity() {
1489 let server = make_server(TransportMode::Stdio, "claude", None);
1490 let params = CaptureParams {
1491 content: "Alice manages the frontend team".into(),
1492 memory_type: Some("fact".into()),
1493 domain: Some("work".into()),
1494 entity: Some("Alice".into()),
1495 confidence: Some(0.9),
1496 supersedes: None,
1497 structured_fields: None,
1498 retrieval_cue: None,
1499 };
1500
1501 let source_agent = server.resolve_source_agent(None);
1503
1504 let req = StoreMemoryRequest {
1505 content: params.content,
1506 memory_type: params.memory_type,
1507 domain: params.domain,
1508 source_agent,
1509 title: None,
1510 confidence: params.confidence,
1511 supersedes: params.supersedes,
1512 entity: params.entity,
1513 entity_id: None,
1514 structured_fields: params.structured_fields.map(serde_json::Value::Object),
1515 retrieval_cue: params.retrieval_cue,
1516 };
1517
1518 let json = serde_json::to_value(&req).unwrap();
1519 assert_eq!(json["content"], "Alice manages the frontend team");
1520 assert_eq!(json["memory_type"], "fact");
1521 assert_eq!(json["domain"], "work");
1522 assert_eq!(json["entity"], "Alice");
1523 assert!(json["confidence"].as_f64().unwrap() > 0.89);
1524 assert_eq!(json["source_agent"], "claude");
1526 }
1527
1528 #[test]
1529 fn test_remember_http_mode_injects_agent() {
1530 let server = make_server(TransportMode::Http, "claude.ai", Some("lucian"));
1531 let source_agent = server.resolve_source_agent(None);
1532
1533 assert_eq!(source_agent, Some("claude.ai".into()));
1534 }
1535
1536 #[test]
1539 fn test_recall_constructs_search_request() {
1540 let params = RecallParams {
1541 query: "database choices".into(),
1542 limit: Some(5),
1543 memory_type: Some("decision".into()),
1544 domain: None,
1545 };
1546
1547 let req = SearchMemoryRequest {
1548 query: params.query,
1549 limit: params.limit.unwrap_or(10),
1550 memory_type: params.memory_type,
1551 domain: params.domain,
1552 source_agent: None,
1553 };
1554
1555 let json = serde_json::to_value(&req).unwrap();
1556 assert_eq!(json["query"], "database choices");
1557 assert_eq!(json["limit"], 5);
1558 assert_eq!(json["memory_type"], "decision");
1559 assert!(json.get("entity").is_none());
1560 assert!(json["domain"].is_null());
1561 assert!(json["source_agent"].is_null());
1562 }
1563
1564 #[test]
1567 fn test_remember_passes_through_all_5_types() {
1568 for t in &["identity", "preference", "fact", "decision", "goal"] {
1569 let params = CaptureParams {
1570 content: "test".into(),
1571 memory_type: Some(t.to_string()),
1572 domain: None,
1573 entity: None,
1574 confidence: None,
1575 supersedes: None,
1576 structured_fields: None,
1577 retrieval_cue: None,
1578 };
1579 assert_eq!(params.memory_type.as_deref(), Some(*t));
1580 }
1581 }
1582
1583 #[test]
1586 fn test_capture_params_with_structured_fields_and_cue() {
1587 let json = r#"{
1588 "content": "Lucian prefers dark mode",
1589 "structured_fields": {"theme":"dark"},
1590 "retrieval_cue": "What theme does Lucian prefer?"
1591 }"#;
1592 let params: CaptureParams = serde_json::from_str(json).unwrap();
1593 let structured_fields = params.structured_fields.expect("structured_fields");
1594 assert_eq!(
1595 structured_fields.get("theme"),
1596 Some(&serde_json::Value::String("dark".into()))
1597 );
1598 assert_eq!(
1599 params.retrieval_cue.as_deref(),
1600 Some("What theme does Lucian prefer?")
1601 );
1602 }
1603
1604 #[test]
1605 fn test_store_request_with_structured_fields() {
1606 let req = StoreMemoryRequest {
1607 content: "test".into(),
1608 memory_type: Some("fact".into()),
1609 domain: None,
1610 source_agent: None,
1611 title: None,
1612 confidence: None,
1613 supersedes: None,
1614 entity: None,
1615 entity_id: None,
1616 structured_fields: Some(serde_json::json!({"key":"val"})),
1617 retrieval_cue: Some("What is the key?".into()),
1618 };
1619 let json = serde_json::to_value(&req).unwrap();
1620 assert_eq!(json["structured_fields"], serde_json::json!({"key":"val"}));
1621 assert_eq!(json["retrieval_cue"], "What is the key?");
1622 }
1623
1624 #[test]
1627 fn test_chat_context_response() {
1628 let json = r#"{
1629 "context": "User prefers dark mode. Works on Origin project.",
1630 "profile": {
1631 "narrative": "narrative",
1632 "identity": [],
1633 "preferences": [],
1634 "goals": []
1635 },
1636 "knowledge": {
1637 "pages": [],
1638 "decisions": [],
1639 "relevant_memories": [],
1640 "graph_context": []
1641 },
1642 "took_ms": 12.5,
1643 "token_estimates": {
1644 "tier1_identity": 1,
1645 "tier2_project": 2,
1646 "tier3_relevant": 3,
1647 "total": 6
1648 }
1649 }"#;
1650 let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
1651 assert!(!resp.context.is_empty());
1652 assert!(resp.profile.identity.is_empty());
1653 assert_eq!(resp.took_ms, 12.5);
1654 assert_eq!(resp.token_estimates.total, 6);
1655 }
1656
1657 #[test]
1658 fn test_chat_context_response_empty() {
1659 let json = r#"{
1660 "context": "",
1661 "profile": {
1662 "narrative": "",
1663 "identity": [],
1664 "preferences": [],
1665 "goals": []
1666 },
1667 "knowledge": {
1668 "pages": [],
1669 "decisions": [],
1670 "relevant_memories": [],
1671 "graph_context": []
1672 },
1673 "took_ms": 1.0,
1674 "token_estimates": {
1675 "tier1_identity": 0,
1676 "tier2_project": 0,
1677 "tier3_relevant": 0,
1678 "total": 0
1679 }
1680 }"#;
1681 let resp: ChatContextResponse = serde_json::from_str(json).unwrap();
1682 assert!(resp.context.is_empty());
1683 }
1684
1685 fn server_instructions() -> String {
1692 let s = make_server(TransportMode::Stdio, "test", None);
1693 s.get_info()
1694 .instructions
1695 .expect("server must ship with_instructions")
1696 }
1697
1698 #[test]
1699 fn instructions_mention_cumulative_knowledge() {
1700 assert!(
1701 server_instructions().contains("cumulative"),
1702 "with_instructions must describe Origin as cumulative"
1703 );
1704 }
1705
1706 #[test]
1707 fn instructions_mention_shared_across_tools() {
1708 assert!(
1709 server_instructions().contains("shared across all"),
1710 "with_instructions must tell agents the store is shared across tools"
1711 );
1712 }
1713
1714 #[test]
1715 fn instructions_mention_how_user_thinks() {
1716 assert!(
1717 server_instructions().contains("how the user thinks"),
1718 "with_instructions must frame context as modeling how the user thinks"
1719 );
1720 }
1721
1722 #[test]
1723 fn instructions_use_proactive_framing() {
1724 assert!(
1725 server_instructions().contains("STORE PROACTIVELY"),
1726 "with_instructions must use STORE PROACTIVELY framing (not passive WHEN TO STORE)"
1727 );
1728 }
1729
1730 #[test]
1731 fn instructions_ban_tool_output_storage() {
1732 assert!(
1733 server_instructions().contains("Tool output or command results"),
1734 "with_instructions must explicitly rule out tool output as storage material"
1735 );
1736 }
1737
1738 #[test]
1739 fn instructions_ban_ghost_inferences() {
1740 assert!(
1741 server_instructions().contains("Your own inferences"),
1742 "with_instructions must rule out storing agent's own inferences user didn't express"
1743 );
1744 }
1745
1746 #[test]
1747 fn instructions_call_out_atomic_memory() {
1748 assert!(
1749 server_instructions().contains("Atomic: one idea per memory"),
1750 "with_instructions must call out the atomic-memory rule explicitly by name"
1751 );
1752 }
1753
1754 #[test]
1755 fn instructions_specify_declarative_writing() {
1756 assert!(
1757 server_instructions().contains("Declarative, not narrative"),
1758 "with_instructions must require declarative (not narrative) writing style"
1759 );
1760 }
1761
1762 #[test]
1763 fn instructions_default_to_omit_memory_type() {
1764 let i = server_instructions();
1765 assert!(
1766 i.contains("omit and trust the backend"),
1767 "with_instructions must default agents to omitting memory_type"
1768 );
1769 assert!(
1770 i.contains("do NOT set memory_type"),
1771 "with_instructions must explicitly say do NOT set memory_type by default"
1772 );
1773 }
1774
1775 #[test]
1776 fn instructions_carve_out_decisions_for_decision_log() {
1777 let i = server_instructions();
1778 assert!(
1779 i.contains("Decision Log"),
1780 "with_instructions must name the Decision Log as the reason for explicit decision typing"
1781 );
1782 assert!(
1783 i.contains("memory_type=\"decision\""),
1784 "with_instructions must tell agents to set memory_type=\"decision\" explicitly for decisions"
1785 );
1786 }
1787
1788 fn tool_descriptions() -> std::collections::HashMap<String, String> {
1791 let server = make_server(TransportMode::Stdio, "test", None);
1792 server
1793 .tool_router
1794 .list_all()
1795 .into_iter()
1796 .filter_map(|t| {
1797 let desc = t.description.as_ref()?.to_string();
1798 Some((t.name.to_string(), desc))
1799 })
1800 .collect()
1801 }
1802
1803 #[test]
1804 fn capture_description_calls_out_atomic() {
1805 let descriptions = tool_descriptions();
1806 let capture = descriptions.get("capture").expect("capture tool exists");
1807 assert!(
1808 capture.contains("Each call is one atomic idea"),
1809 "capture description must call out atomic-per-call explicitly, got: {capture}"
1810 );
1811 }
1812
1813 #[test]
1814 fn context_description_frames_modeling_user() {
1815 let descriptions = tool_descriptions();
1816 let ctx = descriptions.get("context").expect("context tool exists");
1817 assert!(
1818 ctx.contains("how the user thinks"),
1819 "context description must frame the result as modeling how the user thinks, got: {ctx}"
1820 );
1821 }
1822
1823 #[test]
1824 fn doctor_description_mentions_setup_mode() {
1825 let descriptions = tool_descriptions();
1826 let status = descriptions.get("doctor").expect("doctor tool exists");
1827 assert!(
1828 status.contains("Basic Memory"),
1829 "doctor description must mention setup modes, got: {status}"
1830 );
1831 assert!(
1832 status.contains("On-device Model"),
1833 "doctor description must mention on-device setup, got: {status}"
1834 );
1835 assert!(
1836 status.contains("not part of the memory loop"),
1837 "doctor description must frame itself as diagnostic-only, got: {status}"
1838 );
1839 }
1840
1841 #[test]
1842 fn recall_memory_type_param_lists_two_level_filter() {
1843 let params_schema = serde_json::to_string(&schemars::schema_for!(RecallParams))
1844 .expect("RecallParams schema serializes");
1845 assert!(
1846 params_schema.contains("Two-level filter"),
1847 "RecallParams.memory_type must advertise the two-level filter, got schema: {params_schema}"
1848 );
1849 assert!(
1850 params_schema.contains("profile"),
1851 "RecallParams.memory_type must mention profile alias"
1852 );
1853 assert!(
1854 params_schema.contains("knowledge"),
1855 "RecallParams.memory_type must mention knowledge alias"
1856 );
1857 }
1858}