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