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 pages: 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 remote_access_enabled: bool,
282    /// Anthropic model used for fast/routine tasks (e.g. classification, tagging).
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub routine_model: Option<String>,
285    /// Anthropic model used for synthesis tasks (e.g. distillation, narrative).
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub synthesis_model: Option<String>,
288    /// Base URL for an OpenAI-compatible external LLM endpoint.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub external_llm_endpoint: Option<String>,
291    /// Model identifier to use with the external LLM endpoint.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub external_llm_model: Option<String>,
294}
295
296// ===== Indexed files / chunks =====
297
298#[derive(Debug, Serialize, Deserialize)]
299pub struct IndexedFilesResponse {
300    pub files: Vec<IndexedFileInfo>,
301}
302
303#[derive(Debug, Serialize, Deserialize)]
304pub struct DeleteCountResponse {
305    pub deleted: usize,
306}
307
308// ===== Entity / Observation =====
309
310#[derive(Debug, Serialize, Deserialize)]
311pub struct SuccessResponse {
312    pub ok: bool,
313}
314
315// ===== Memory detail =====
316
317#[derive(Debug, Serialize, Deserialize)]
318pub struct MemoryDetailResponse {
319    pub memory: Option<MemoryItem>,
320}
321
322/// Detailed chunk-level view of a stored memory, returned by `/api/chunks/{source_id}`.
323#[derive(Debug, Clone, Serialize, Deserialize)]
324pub struct MemoryDetail {
325    pub id: String,
326    pub content: String,
327    pub title: String,
328    pub source_id: String,
329    pub chunk_index: i32,
330    #[serde(skip_serializing_if = "Option::is_none")]
331    pub chunk_type: Option<String>,
332    #[serde(skip_serializing_if = "Option::is_none")]
333    pub language: Option<String>,
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub semantic_unit: Option<String>,
336    #[serde(skip_serializing_if = "Option::is_none")]
337    pub byte_start: Option<i64>,
338    #[serde(skip_serializing_if = "Option::is_none")]
339    pub byte_end: Option<i64>,
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub summary: Option<String>,
342}
343
344/// A pending revision waiting for human approval (Protected tier supersede).
345#[derive(Debug, Clone, Serialize, Deserialize)]
346pub struct PendingRevision {
347    pub source_id: String,
348    pub content: String,
349    pub source_agent: Option<String>,
350}
351
352#[derive(Debug, Serialize, Deserialize)]
353pub struct VersionChainResponse {
354    pub versions: Vec<crate::memory::MemoryVersionItem>,
355}
356
357// ===== Tags =====
358
359#[derive(Debug, Serialize, Deserialize)]
360pub struct TagsResponse {
361    pub tags: Vec<String>,
362}
363
364// ===== Activity =====
365
366#[derive(Debug, Serialize, Deserialize)]
367pub struct ActivityResponse {
368    pub activities: Vec<crate::memory::AgentActivityRow>,
369}
370
371// ===== Decisions =====
372
373#[derive(Debug, Serialize, Deserialize)]
374pub struct DecisionsResponse {
375    pub decisions: Vec<MemoryItem>,
376}
377
378#[derive(Debug, Serialize, Deserialize)]
379pub struct DecisionDomainsResponse {
380    pub domains: Vec<String>,
381}
382
383// ===== Pinned =====
384
385#[derive(Debug, Serialize, Deserialize)]
386pub struct PinnedMemoriesResponse {
387    pub memories: Vec<MemoryItem>,
388}
389
390// ===== Ingest =====
391
392#[derive(Debug, Serialize, Deserialize)]
393pub struct IngestResponse {
394    pub chunks_created: usize,
395    pub document_id: String,
396}
397
398// Note: ingest's `DELETE /api/documents/{source}/{source_id}` reuses the
399// `DeleteResponse { deleted: bool }` defined above — same wire format.
400
401// ===== Concept Export =====
402
403/// Statistics from a bulk page export operation (POST /api/pages/export).
404#[derive(Debug, Default, Serialize, Deserialize)]
405pub struct ExportStats {
406    pub exported: usize,
407    pub skipped: usize,
408    pub failed: usize,
409}
410
411#[derive(Debug, Deserialize, Serialize)]
412pub struct ExportPageResponse {
413    pub path: String,
414}
415
416// ===== Knowledge Directory =====
417
418#[derive(Debug, Deserialize, Serialize)]
419pub struct KnowledgePathResponse {
420    pub path: String,
421}
422
423#[derive(Debug, Deserialize, Serialize)]
424pub struct KnowledgeCountResponse {
425    pub count: u64,
426}
427
428// ===== Sources =====
429
430#[doc(hidden)]
431#[derive(Debug, Clone, Serialize, Deserialize)]
432pub struct SyncStatsResponse {
433    pub files_found: usize,
434    pub ingested: usize,
435    pub skipped: usize,
436    pub errors: usize,
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn store_memory_response_deserializes_without_extraction_method() {
445        // Forward-compat: older server responses (pre-D9) omit extraction_method entirely.
446        let json = r#"{
447            "source_id": "mem_abc",
448            "chunks_created": 3,
449            "memory_type": "fact"
450        }"#;
451        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
452        assert_eq!(parsed.source_id, "mem_abc");
453        assert_eq!(parsed.chunks_created, 3);
454        assert_eq!(parsed.memory_type, "fact");
455        assert_eq!(parsed.extraction_method, "unknown");
456        assert!(parsed.warnings.is_empty());
457    }
458
459    #[test]
460    fn store_memory_response_deserializes_with_all_fields() {
461        let json = r#"{
462            "source_id": "mem_abc",
463            "chunks_created": 3,
464            "memory_type": "fact",
465            "warnings": ["decision memory missing claim"],
466            "extraction_method": "llm"
467        }"#;
468        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
469        assert_eq!(parsed.warnings.len(), 1);
470        assert_eq!(parsed.extraction_method, "llm");
471    }
472
473    #[test]
474    fn store_memory_response_exposes_enrichment_and_hint() {
475        // Post-async-refactor shape: the daemon returns immediately after
476        // upsert and reports deferred enrichment via `enrichment` + `hint`.
477        let json = r#"{
478            "source_id": "mem_xyz",
479            "chunks_created": 1,
480            "memory_type": "fact",
481            "warnings": [],
482            "extraction_method": "unknown",
483            "enrichment": "pending",
484            "hint": "Stored. Origin is compiling classification + concept links in the background (~2s). Recall will surface the enriched form shortly."
485        }"#;
486        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
487        assert_eq!(parsed.enrichment, "pending");
488        assert!(parsed.hint.contains("compiling"));
489    }
490
491    #[test]
492    fn store_memory_response_defaults_enrichment_for_older_responses() {
493        // Backward-compat: existing clients (origin-mcp, Tauri app) that
494        // deserialize pre-async-refactor responses must keep working.
495        let json = r#"{
496            "source_id": "mem_old",
497            "chunks_created": 1,
498            "memory_type": "fact"
499        }"#;
500        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
501        assert_eq!(parsed.enrichment, ""); // default
502        assert_eq!(parsed.hint, ""); // default
503    }
504
505    #[test]
506    fn store_memory_response_roundtrips_not_needed_state() {
507        // Daemon reports `not_needed` when no LLM is available. Hint is empty
508        // (skip_serializing_if) so the JSON shrinks accordingly.
509        let response = StoreMemoryResponse {
510            source_id: "mem_no_llm".into(),
511            chunks_created: 1,
512            memory_type: "fact".into(),
513            entity_id: None,
514            quality: None,
515            warnings: vec![],
516            extraction_method: "none".into(),
517            enrichment: "not_needed".into(),
518            hint: String::new(),
519        };
520        let json = serde_json::to_string(&response).unwrap();
521        assert!(json.contains("\"enrichment\":\"not_needed\""));
522        assert!(
523            !json.contains("\"hint\""),
524            "empty hint must be skipped on the wire, got: {json}"
525        );
526        let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
527        assert_eq!(parsed.enrichment, "not_needed");
528        assert_eq!(parsed.hint, "");
529    }
530
531    #[test]
532    fn chat_context_response_roundtrips_with_empty_knowledge_sections() {
533        let response = ChatContextResponse {
534            context: "context".into(),
535            profile: ProfileContext {
536                narrative: "n".into(),
537                identity: vec![],
538                preferences: vec![],
539                goals: vec![],
540            },
541            knowledge: KnowledgeContext {
542                pages: vec![],
543                decisions: vec![],
544                relevant_memories: vec![],
545                graph_context: vec![],
546            },
547            took_ms: 1.0,
548            token_estimates: TierTokenEstimates {
549                tier1_identity: 1,
550                tier2_project: 2,
551                tier3_relevant: 3,
552                total: 6,
553            },
554        };
555
556        let json = serde_json::to_string(&response).unwrap();
557        let parsed: ChatContextResponse = serde_json::from_str(&json).unwrap();
558        assert!(parsed.knowledge.pages.is_empty());
559        assert!(parsed.knowledge.decisions.is_empty());
560        assert!(parsed.knowledge.relevant_memories.is_empty());
561        assert!(parsed.knowledge.graph_context.is_empty());
562    }
563}