Skip to main content

origin_types/
responses.rs

1// SPDX-License-Identifier: Apache-2.0
2//! API response types for all HTTP endpoints.
3
4use crate::entities::{Entity, EntitySearchResult};
5use crate::memory::{IndexedFileInfo, MemoryItem, MemoryStats, SearchResult};
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8
9// ===== Memory CRUD =====
10
11#[derive(Debug, Serialize, Deserialize)]
12pub struct StoreMemoryResponse {
13    pub source_id: String,
14    pub chunks_created: usize,
15    /// Memory type at the moment of persistence. If caller did not supply
16    /// one and enrichment is pending, this is a placeholder (`"fact"`) —
17    /// check `enrichment` field to know whether to expect it to change.
18    pub memory_type: String,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub entity_id: Option<String>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub quality: Option<String>,
23    /// Schema-validation issues — actionable by the agent.
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub warnings: Vec<String>,
26    /// How structured fields were populated. "agent" | "llm" | "none" | "unknown" (forward-compat default).
27    #[serde(default = "default_extraction_method")]
28    pub extraction_method: String,
29    /// Enrichment state for the memory. `"pending"` when background
30    /// classification + entity extraction + concept linking will run;
31    /// `"not_needed"` when no LLM is available and the memory stays as
32    /// caller-supplied. Machine-readable — Tauri app uses this to drive
33    /// polling / live-update UI, MCP callers can choose to relay state.
34    /// Defaulted for backward compatibility with pre-async-enrichment clients.
35    #[serde(default)]
36    pub enrichment: String,
37    /// Prose cue for caller agents — safe to relay to the user verbatim.
38    /// Communicates that Origin is compiling the memory into reusable
39    /// context in the background, so callers don't treat `None` enriched
40    /// fields as failure. Empty when the store completed fully sync.
41    #[serde(default, skip_serializing_if = "String::is_empty")]
42    pub hint: String,
43}
44
45fn default_extraction_method() -> String {
46    "unknown".to_string()
47}
48
49#[derive(Debug, Serialize, Deserialize)]
50pub struct SearchMemoryResponse {
51    pub results: Vec<SearchResult>,
52    pub took_ms: f64,
53}
54
55#[derive(Debug, Serialize, Deserialize)]
56pub struct ListMemoriesResponse {
57    pub memories: Vec<IndexedFileInfo>,
58}
59
60/// Shared wire format for any `deleted: bool` response.
61///
62/// Reused by:
63/// - `DELETE /api/memory/delete/{id}` (server/memory.rs)
64/// - `DELETE /api/documents/{source}/{source_id}` (server/ingest.rs)
65#[derive(Debug, Serialize, Deserialize)]
66pub struct DeleteResponse {
67    pub deleted: bool,
68}
69
70#[derive(Debug, Serialize, Deserialize)]
71pub struct ConfirmResponse {
72    pub confirmed: bool,
73}
74
75#[derive(Debug, Serialize, Deserialize)]
76pub struct ReclassifyMemoryResponse {
77    pub source_id: String,
78    pub memory_type: String,
79}
80
81#[derive(Debug, Serialize, Deserialize)]
82pub struct MemoryStatsResponse {
83    pub stats: MemoryStats,
84}
85
86#[derive(Debug, Serialize, Deserialize)]
87pub struct NurtureCardsResponse {
88    pub cards: Vec<MemoryItem>,
89}
90
91// ===== General search/context =====
92
93#[derive(Debug, Serialize, Deserialize)]
94pub struct HealthResponse {
95    pub status: String,
96    pub db_initialized: bool,
97    pub version: String,
98}
99
100#[derive(Debug, Serialize, Deserialize)]
101pub struct StatusResponse {
102    pub is_running: bool,
103    pub files_indexed: u64,
104    pub files_total: u64,
105    pub sources_connected: Vec<String>,
106}
107
108#[derive(Debug, Serialize, Deserialize)]
109pub struct SearchResponse {
110    pub results: Vec<SearchResult>,
111    pub took_ms: f64,
112}
113
114#[doc(hidden)]
115#[derive(Debug, Serialize, Deserialize)]
116pub struct ContextSuggestion {
117    pub content: String,
118    pub score: f32,
119    pub source: String,
120}
121
122#[doc(hidden)]
123#[derive(Debug, Serialize, Deserialize)]
124pub struct ContextResponse {
125    pub suggestions: Vec<ContextSuggestion>,
126    pub took_ms: f64,
127}
128
129#[derive(Debug, Default, Serialize, Deserialize)]
130pub struct TierTokenEstimates {
131    pub tier1_identity: usize,
132    pub tier2_project: usize,
133    pub tier3_relevant: usize,
134    pub total: usize,
135}
136
137#[derive(Debug, Serialize, Deserialize)]
138pub struct ProfileContext {
139    pub narrative: String,
140    pub identity: Vec<String>,
141    pub preferences: Vec<String>,
142    pub goals: Vec<String>,
143}
144
145#[derive(Debug, Serialize, Deserialize)]
146pub struct KnowledgeContext {
147    #[serde(default, skip_serializing_if = "Vec::is_empty")]
148    pub concepts: Vec<String>,
149    #[serde(default, skip_serializing_if = "Vec::is_empty")]
150    pub decisions: Vec<String>,
151    #[serde(default)]
152    pub relevant_memories: Vec<SearchResult>,
153    #[serde(default, skip_serializing_if = "Vec::is_empty")]
154    pub graph_context: Vec<String>,
155}
156
157#[derive(Debug, Serialize, Deserialize)]
158pub struct ChatContextResponse {
159    pub context: String,
160    pub profile: ProfileContext,
161    pub knowledge: KnowledgeContext,
162    pub took_ms: f64,
163    pub token_estimates: TierTokenEstimates,
164}
165
166// ===== Profile & Agents =====
167
168#[derive(Debug, Serialize, Deserialize)]
169pub struct ProfileResponse {
170    pub id: String,
171    pub name: String,
172    pub display_name: Option<String>,
173    pub email: Option<String>,
174    pub bio: Option<String>,
175    pub avatar_path: Option<String>,
176    pub created_at: i64,
177    pub updated_at: i64,
178}
179
180#[derive(Debug, Serialize, Deserialize)]
181pub struct AgentResponse {
182    pub id: String,
183    pub name: String,
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub display_name: Option<String>,
186    pub agent_type: String,
187    pub description: Option<String>,
188    pub enabled: bool,
189    pub trust_level: String,
190    pub last_seen_at: Option<i64>,
191    pub memory_count: i64,
192    pub created_at: i64,
193    pub updated_at: i64,
194}
195
196// ===== Knowledge graph =====
197
198#[derive(Debug, Serialize, Deserialize)]
199pub struct CreateEntityResponse {
200    pub id: String,
201}
202
203#[doc(hidden)]
204#[derive(Debug, Serialize, Deserialize)]
205pub struct CreateRelationResponse {
206    pub id: String,
207}
208
209#[derive(Debug, Serialize, Deserialize)]
210pub struct AddObservationResponse {
211    pub id: String,
212}
213
214#[derive(Debug, Serialize, Deserialize)]
215pub struct ListEntitiesResponse {
216    pub entities: Vec<Entity>,
217}
218
219#[derive(Debug, Serialize, Deserialize)]
220pub struct SearchEntitiesResponse {
221    pub results: Vec<EntitySearchResult>,
222}
223
224// ===== Import =====
225
226#[derive(Debug, Serialize, Deserialize)]
227pub struct ImportMemoriesResponse {
228    pub imported: usize,
229    pub skipped: usize,
230    pub breakdown: HashMap<String, usize>,
231    pub entities_created: usize,
232    pub observations_added: usize,
233    pub relations_created: usize,
234    pub batch_id: String,
235}
236
237// ===== Steep =====
238
239/// How loud Origin should be about a phase's output.
240#[doc(hidden)]
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
242pub enum Nudge {
243    Silent,
244    Ambient,
245    Notable,
246    Wow,
247}
248
249/// Result of a single phase within a steep cycle.
250#[doc(hidden)]
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct PhaseResult {
253    pub name: String,
254    pub duration_ms: u64,
255    pub items_processed: usize,
256    pub error: Option<String>,
257    pub nudge: Nudge,
258    pub headline: Option<String>,
259}
260
261#[doc(hidden)]
262#[derive(Debug, Serialize, Deserialize)]
263pub struct SteepResponse {
264    pub memories_decayed: u64,
265    pub recaps_generated: u32,
266    pub distilled: u32,
267    pub pending_remaining: u32,
268    pub phases: Vec<PhaseResult>,
269}
270
271// ===== Config =====
272
273#[derive(Debug, Serialize, Deserialize)]
274pub struct ConfigResponse {
275    pub skip_apps: Vec<String>,
276    pub skip_title_patterns: Vec<String>,
277    pub private_browsing_detection: bool,
278    pub setup_completed: bool,
279    pub clipboard_enabled: bool,
280    pub screen_capture_enabled: bool,
281    pub selection_capture_enabled: bool,
282    pub remote_access_enabled: bool,
283}
284
285// ===== Indexed files / chunks =====
286
287#[derive(Debug, Serialize, Deserialize)]
288pub struct IndexedFilesResponse {
289    pub files: Vec<IndexedFileInfo>,
290}
291
292#[derive(Debug, Serialize, Deserialize)]
293pub struct DeleteCountResponse {
294    pub deleted: usize,
295}
296
297// ===== Entity / Observation =====
298
299#[derive(Debug, Serialize, Deserialize)]
300pub struct SuccessResponse {
301    pub ok: bool,
302}
303
304// ===== Memory detail =====
305
306#[derive(Debug, Serialize, Deserialize)]
307pub struct MemoryDetailResponse {
308    pub memory: Option<MemoryItem>,
309}
310
311#[derive(Debug, Serialize, Deserialize)]
312pub struct VersionChainResponse {
313    pub versions: Vec<crate::memory::MemoryVersionItem>,
314}
315
316// ===== Tags =====
317
318#[derive(Debug, Serialize, Deserialize)]
319pub struct TagsResponse {
320    pub tags: Vec<String>,
321}
322
323// ===== Activity =====
324
325#[derive(Debug, Serialize, Deserialize)]
326pub struct ActivityResponse {
327    pub activities: Vec<crate::memory::AgentActivityRow>,
328}
329
330// ===== Decisions =====
331
332#[derive(Debug, Serialize, Deserialize)]
333pub struct DecisionsResponse {
334    pub decisions: Vec<MemoryItem>,
335}
336
337#[derive(Debug, Serialize, Deserialize)]
338pub struct DecisionDomainsResponse {
339    pub domains: Vec<String>,
340}
341
342// ===== Pinned =====
343
344#[derive(Debug, Serialize, Deserialize)]
345pub struct PinnedMemoriesResponse {
346    pub memories: Vec<MemoryItem>,
347}
348
349// ===== Ingest =====
350
351#[derive(Debug, Serialize, Deserialize)]
352pub struct IngestResponse {
353    pub chunks_created: usize,
354    pub document_id: String,
355}
356
357// Note: ingest's `DELETE /api/documents/{source}/{source_id}` reuses the
358// `DeleteResponse { deleted: bool }` defined above — same wire format.
359
360// ===== Concept Export =====
361
362#[derive(Debug, Deserialize, Serialize)]
363pub struct ExportConceptResponse {
364    pub path: String,
365}
366
367// ===== Knowledge Directory =====
368
369#[derive(Debug, Deserialize, Serialize)]
370pub struct KnowledgePathResponse {
371    pub path: String,
372}
373
374#[derive(Debug, Deserialize, Serialize)]
375pub struct KnowledgeCountResponse {
376    pub count: u64,
377}
378
379// ===== Sources =====
380
381#[doc(hidden)]
382#[derive(Debug, Clone, Serialize, Deserialize)]
383pub struct SyncStatsResponse {
384    pub files_found: usize,
385    pub ingested: usize,
386    pub skipped: usize,
387    pub errors: usize,
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn store_memory_response_deserializes_without_extraction_method() {
396        // Forward-compat: older server responses (pre-D9) omit extraction_method entirely.
397        let json = r#"{
398            "source_id": "mem_abc",
399            "chunks_created": 3,
400            "memory_type": "fact"
401        }"#;
402        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
403        assert_eq!(parsed.source_id, "mem_abc");
404        assert_eq!(parsed.chunks_created, 3);
405        assert_eq!(parsed.memory_type, "fact");
406        assert_eq!(parsed.extraction_method, "unknown");
407        assert!(parsed.warnings.is_empty());
408    }
409
410    #[test]
411    fn store_memory_response_deserializes_with_all_fields() {
412        let json = r#"{
413            "source_id": "mem_abc",
414            "chunks_created": 3,
415            "memory_type": "fact",
416            "warnings": ["decision memory missing claim"],
417            "extraction_method": "llm"
418        }"#;
419        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
420        assert_eq!(parsed.warnings.len(), 1);
421        assert_eq!(parsed.extraction_method, "llm");
422    }
423
424    #[test]
425    fn store_memory_response_exposes_enrichment_and_hint() {
426        // Post-async-refactor shape: the daemon returns immediately after
427        // upsert and reports deferred enrichment via `enrichment` + `hint`.
428        let json = r#"{
429            "source_id": "mem_xyz",
430            "chunks_created": 1,
431            "memory_type": "fact",
432            "warnings": [],
433            "extraction_method": "unknown",
434            "enrichment": "pending",
435            "hint": "Stored. Origin is compiling classification + concept links in the background (~2s). Recall will surface the enriched form shortly."
436        }"#;
437        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
438        assert_eq!(parsed.enrichment, "pending");
439        assert!(parsed.hint.contains("compiling"));
440    }
441
442    #[test]
443    fn store_memory_response_defaults_enrichment_for_older_responses() {
444        // Backward-compat: existing clients (origin-mcp, Tauri app) that
445        // deserialize pre-async-refactor responses must keep working.
446        let json = r#"{
447            "source_id": "mem_old",
448            "chunks_created": 1,
449            "memory_type": "fact"
450        }"#;
451        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
452        assert_eq!(parsed.enrichment, ""); // default
453        assert_eq!(parsed.hint, ""); // default
454    }
455
456    #[test]
457    fn store_memory_response_roundtrips_not_needed_state() {
458        // Daemon reports `not_needed` when no LLM is available. Hint is empty
459        // (skip_serializing_if) so the JSON shrinks accordingly.
460        let response = StoreMemoryResponse {
461            source_id: "mem_no_llm".into(),
462            chunks_created: 1,
463            memory_type: "fact".into(),
464            entity_id: None,
465            quality: None,
466            warnings: vec![],
467            extraction_method: "none".into(),
468            enrichment: "not_needed".into(),
469            hint: String::new(),
470        };
471        let json = serde_json::to_string(&response).unwrap();
472        assert!(json.contains("\"enrichment\":\"not_needed\""));
473        assert!(
474            !json.contains("\"hint\""),
475            "empty hint must be skipped on the wire, got: {json}"
476        );
477        let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
478        assert_eq!(parsed.enrichment, "not_needed");
479        assert_eq!(parsed.hint, "");
480    }
481
482    #[test]
483    fn chat_context_response_roundtrips_with_empty_knowledge_sections() {
484        let response = ChatContextResponse {
485            context: "context".into(),
486            profile: ProfileContext {
487                narrative: "n".into(),
488                identity: vec![],
489                preferences: vec![],
490                goals: vec![],
491            },
492            knowledge: KnowledgeContext {
493                concepts: vec![],
494                decisions: vec![],
495                relevant_memories: vec![],
496                graph_context: vec![],
497            },
498            took_ms: 1.0,
499            token_estimates: TierTokenEstimates {
500                tier1_identity: 1,
501                tier2_project: 2,
502                tier3_relevant: 3,
503                total: 6,
504            },
505        };
506
507        let json = serde_json::to_string(&response).unwrap();
508        let parsed: ChatContextResponse = serde_json::from_str(&json).unwrap();
509        assert!(parsed.knowledge.concepts.is_empty());
510        assert!(parsed.knowledge.decisions.is_empty());
511        assert!(parsed.knowledge.relevant_memories.is_empty());
512        assert!(parsed.knowledge.graph_context.is_empty());
513    }
514}