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