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    /// Deprecated: goal taxonomy folded into Identity by migration 45 (Phase 0).
143    /// Always empty — daemon does not emit goal-typed memories. Field stays for
144    /// wire backward compat; will be removed in 0.4.
145    #[deprecated(
146        since = "0.3.2",
147        note = "Goal taxonomy folded into Identity by migration 45 (Phase 0). \
148                Always empty. Will be removed in 0.4."
149    )]
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub goals: Vec<String>,
152}
153
154#[derive(Debug, Serialize, Deserialize)]
155pub struct KnowledgeContext {
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub pages: Vec<String>,
158    #[serde(default, skip_serializing_if = "Vec::is_empty")]
159    pub decisions: Vec<String>,
160    #[serde(default)]
161    pub relevant_memories: Vec<SearchResult>,
162    #[serde(default, skip_serializing_if = "Vec::is_empty")]
163    pub graph_context: Vec<String>,
164}
165
166#[derive(Debug, Serialize, Deserialize)]
167pub struct ChatContextResponse {
168    pub context: String,
169    pub profile: ProfileContext,
170    pub knowledge: KnowledgeContext,
171    pub took_ms: f64,
172    pub token_estimates: TierTokenEstimates,
173}
174
175// ===== Profile & Agents =====
176
177#[derive(Debug, Serialize, Deserialize)]
178pub struct ProfileResponse {
179    pub id: String,
180    pub name: String,
181    pub display_name: Option<String>,
182    pub email: Option<String>,
183    pub bio: Option<String>,
184    pub avatar_path: Option<String>,
185    pub created_at: i64,
186    pub updated_at: i64,
187}
188
189#[derive(Debug, Serialize, Deserialize)]
190pub struct AgentResponse {
191    pub id: String,
192    pub name: String,
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub display_name: Option<String>,
195    pub agent_type: String,
196    pub description: Option<String>,
197    pub enabled: bool,
198    pub trust_level: String,
199    pub last_seen_at: Option<i64>,
200    pub memory_count: i64,
201    pub created_at: i64,
202    pub updated_at: i64,
203}
204
205// ===== Knowledge graph =====
206
207#[derive(Debug, Serialize, Deserialize)]
208pub struct CreateEntityResponse {
209    pub id: String,
210}
211
212#[doc(hidden)]
213#[derive(Debug, Serialize, Deserialize)]
214pub struct CreateRelationResponse {
215    pub id: String,
216}
217
218#[derive(Debug, Serialize, Deserialize)]
219pub struct AddObservationResponse {
220    pub id: String,
221}
222
223#[derive(Debug, Serialize, Deserialize)]
224pub struct ListEntitiesResponse {
225    pub entities: Vec<Entity>,
226}
227
228#[derive(Debug, Serialize, Deserialize)]
229pub struct SearchEntitiesResponse {
230    pub results: Vec<EntitySearchResult>,
231}
232
233// ===== Import =====
234
235#[derive(Debug, Serialize, Deserialize)]
236pub struct ImportMemoriesResponse {
237    pub imported: usize,
238    pub skipped: usize,
239    pub breakdown: HashMap<String, usize>,
240    pub entities_created: usize,
241    pub observations_added: usize,
242    pub relations_created: usize,
243    pub batch_id: String,
244}
245
246// ===== Steep =====
247
248/// How loud Origin should be about a phase's output.
249#[doc(hidden)]
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
251pub enum Nudge {
252    Silent,
253    Ambient,
254    Notable,
255    Wow,
256}
257
258/// Result of a single phase within a steep cycle.
259#[doc(hidden)]
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct PhaseResult {
262    pub name: String,
263    pub duration_ms: u64,
264    pub items_processed: usize,
265    pub error: Option<String>,
266    pub nudge: Nudge,
267    pub headline: Option<String>,
268}
269
270#[doc(hidden)]
271#[derive(Debug, Serialize, Deserialize)]
272pub struct SteepResponse {
273    pub memories_decayed: u64,
274    pub recaps_generated: u32,
275    pub distilled: u32,
276    pub pending_remaining: u32,
277    pub phases: Vec<PhaseResult>,
278}
279
280// ===== Config =====
281
282#[derive(Debug, Serialize, Deserialize)]
283pub struct ConfigResponse {
284    pub skip_apps: Vec<String>,
285    pub skip_title_patterns: Vec<String>,
286    pub private_browsing_detection: bool,
287    pub setup_completed: bool,
288    pub clipboard_enabled: bool,
289    pub screen_capture_enabled: bool,
290    pub remote_access_enabled: bool,
291    /// Anthropic model used for fast/routine tasks (e.g. classification, tagging).
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub routine_model: Option<String>,
294    /// Anthropic model used for synthesis tasks (e.g. distillation, narrative).
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub synthesis_model: Option<String>,
297    /// Base URL for an OpenAI-compatible external LLM endpoint.
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub external_llm_endpoint: Option<String>,
300    /// Model identifier to use with the external LLM endpoint.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub external_llm_model: Option<String>,
303}
304
305// ===== Indexed files / chunks =====
306
307#[derive(Debug, Serialize, Deserialize)]
308pub struct IndexedFilesResponse {
309    pub files: Vec<IndexedFileInfo>,
310}
311
312#[derive(Debug, Serialize, Deserialize)]
313pub struct DeleteCountResponse {
314    pub deleted: usize,
315}
316
317// ===== Entity / Observation =====
318
319#[derive(Debug, Serialize, Deserialize)]
320pub struct SuccessResponse {
321    pub ok: bool,
322}
323
324// ===== Memory detail =====
325
326#[derive(Debug, Serialize, Deserialize)]
327pub struct MemoryDetailResponse {
328    pub memory: Option<MemoryItem>,
329}
330
331/// Detailed chunk-level view of a stored memory, returned by `/api/chunks/{source_id}`.
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct MemoryDetail {
334    pub id: String,
335    pub content: String,
336    pub title: String,
337    pub source_id: String,
338    pub chunk_index: i32,
339    #[serde(skip_serializing_if = "Option::is_none")]
340    pub chunk_type: Option<String>,
341    #[serde(skip_serializing_if = "Option::is_none")]
342    pub language: Option<String>,
343    #[serde(skip_serializing_if = "Option::is_none")]
344    pub semantic_unit: Option<String>,
345    #[serde(skip_serializing_if = "Option::is_none")]
346    pub byte_start: Option<i64>,
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub byte_end: Option<i64>,
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub summary: Option<String>,
351}
352
353/// A pending revision waiting for human approval (Protected tier supersede).
354#[derive(Debug, Clone, Serialize, Deserialize)]
355pub struct PendingRevision {
356    pub source_id: String,
357    pub content: String,
358    pub source_agent: Option<String>,
359}
360
361#[derive(Debug, Serialize, Deserialize)]
362pub struct VersionChainResponse {
363    pub versions: Vec<crate::memory::MemoryVersionItem>,
364}
365
366// ===== Tags =====
367
368#[derive(Debug, Serialize, Deserialize)]
369pub struct TagsResponse {
370    pub tags: Vec<String>,
371}
372
373// ===== Activity =====
374
375#[derive(Debug, Serialize, Deserialize)]
376pub struct ActivityResponse {
377    pub activities: Vec<crate::memory::AgentActivityRow>,
378}
379
380// ===== Decisions =====
381
382#[derive(Debug, Serialize, Deserialize)]
383pub struct DecisionsResponse {
384    pub decisions: Vec<MemoryItem>,
385}
386
387#[derive(Debug, Serialize, Deserialize)]
388pub struct DecisionDomainsResponse {
389    pub domains: Vec<String>,
390}
391
392// ===== Pinned =====
393
394#[derive(Debug, Serialize, Deserialize)]
395pub struct PinnedMemoriesResponse {
396    pub memories: Vec<MemoryItem>,
397}
398
399// ===== Ingest =====
400
401#[derive(Debug, Serialize, Deserialize)]
402pub struct IngestResponse {
403    pub chunks_created: usize,
404    pub document_id: String,
405}
406
407// Note: ingest's `DELETE /api/documents/{source}/{source_id}` reuses the
408// `DeleteResponse { deleted: bool }` defined above — same wire format.
409
410// ===== Concept Export =====
411
412/// Statistics from a bulk page export operation (POST /api/pages/export).
413#[derive(Debug, Default, Serialize, Deserialize)]
414pub struct ExportStats {
415    pub exported: usize,
416    pub skipped: usize,
417    pub failed: usize,
418}
419
420#[derive(Debug, Deserialize, Serialize)]
421pub struct ExportPageResponse {
422    pub path: String,
423}
424
425// ===== Knowledge Directory =====
426
427#[derive(Debug, Deserialize, Serialize)]
428pub struct KnowledgePathResponse {
429    pub path: String,
430}
431
432#[derive(Debug, Deserialize, Serialize)]
433pub struct KnowledgeCountResponse {
434    pub count: u64,
435}
436
437// ===== Sources =====
438
439#[doc(hidden)]
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct SyncStatsResponse {
442    pub files_found: usize,
443    pub ingested: usize,
444    pub skipped: usize,
445    pub errors: usize,
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    #[test]
453    fn store_memory_response_deserializes_without_extraction_method() {
454        // Forward-compat: older server responses (pre-D9) omit extraction_method entirely.
455        let json = r#"{
456            "source_id": "mem_abc",
457            "chunks_created": 3,
458            "memory_type": "fact"
459        }"#;
460        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
461        assert_eq!(parsed.source_id, "mem_abc");
462        assert_eq!(parsed.chunks_created, 3);
463        assert_eq!(parsed.memory_type, "fact");
464        assert_eq!(parsed.extraction_method, "unknown");
465        assert!(parsed.warnings.is_empty());
466    }
467
468    #[test]
469    fn store_memory_response_deserializes_with_all_fields() {
470        let json = r#"{
471            "source_id": "mem_abc",
472            "chunks_created": 3,
473            "memory_type": "fact",
474            "warnings": ["decision memory missing claim"],
475            "extraction_method": "llm"
476        }"#;
477        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
478        assert_eq!(parsed.warnings.len(), 1);
479        assert_eq!(parsed.extraction_method, "llm");
480    }
481
482    #[test]
483    fn store_memory_response_exposes_enrichment_and_hint() {
484        // Post-async-refactor shape: the daemon returns immediately after
485        // upsert and reports deferred enrichment via `enrichment` + `hint`.
486        let json = r#"{
487            "source_id": "mem_xyz",
488            "chunks_created": 1,
489            "memory_type": "fact",
490            "warnings": [],
491            "extraction_method": "unknown",
492            "enrichment": "pending",
493            "hint": "Stored. Origin is compiling classification + concept links in the background (~2s). Recall will surface the enriched form shortly."
494        }"#;
495        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
496        assert_eq!(parsed.enrichment, "pending");
497        assert!(parsed.hint.contains("compiling"));
498    }
499
500    #[test]
501    fn store_memory_response_defaults_enrichment_for_older_responses() {
502        // Backward-compat: existing clients (origin-mcp, Tauri app) that
503        // deserialize pre-async-refactor responses must keep working.
504        let json = r#"{
505            "source_id": "mem_old",
506            "chunks_created": 1,
507            "memory_type": "fact"
508        }"#;
509        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
510        assert_eq!(parsed.enrichment, ""); // default
511        assert_eq!(parsed.hint, ""); // default
512    }
513
514    #[test]
515    fn store_memory_response_roundtrips_not_needed_state() {
516        // Daemon reports `not_needed` when no LLM is available. Hint is empty
517        // (skip_serializing_if) so the JSON shrinks accordingly.
518        let response = StoreMemoryResponse {
519            source_id: "mem_no_llm".into(),
520            chunks_created: 1,
521            memory_type: "fact".into(),
522            entity_id: None,
523            quality: None,
524            warnings: vec![],
525            extraction_method: "none".into(),
526            enrichment: "not_needed".into(),
527            hint: String::new(),
528        };
529        let json = serde_json::to_string(&response).unwrap();
530        assert!(json.contains("\"enrichment\":\"not_needed\""));
531        assert!(
532            !json.contains("\"hint\""),
533            "empty hint must be skipped on the wire, got: {json}"
534        );
535        let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
536        assert_eq!(parsed.enrichment, "not_needed");
537        assert_eq!(parsed.hint, "");
538    }
539
540    #[test]
541    fn chat_context_response_roundtrips_with_empty_knowledge_sections() {
542        // ProfileContext.goals is deprecated; constructing it directly here
543        // for wire roundtrip coverage until 0.4 drops the field entirely.
544        #[allow(deprecated)]
545        let profile = ProfileContext {
546            narrative: "n".into(),
547            identity: vec![],
548            preferences: vec![],
549            goals: vec![],
550        };
551        let response = ChatContextResponse {
552            context: "context".into(),
553            profile,
554            knowledge: KnowledgeContext {
555                pages: vec![],
556                decisions: vec![],
557                relevant_memories: vec![],
558                graph_context: vec![],
559            },
560            took_ms: 1.0,
561            token_estimates: TierTokenEstimates {
562                tier1_identity: 1,
563                tier2_project: 2,
564                tier3_relevant: 3,
565                total: 6,
566            },
567        };
568
569        let json = serde_json::to_string(&response).unwrap();
570        let parsed: ChatContextResponse = serde_json::from_str(&json).unwrap();
571        assert!(parsed.knowledge.pages.is_empty());
572        assert!(parsed.knowledge.decisions.is_empty());
573        assert!(parsed.knowledge.relevant_memories.is_empty());
574        assert!(parsed.knowledge.graph_context.is_empty());
575    }
576}