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    /// Source IDs of protected memories now flagged for human revision
45    /// because this capture's topic-match upsert fired against them. Empty
46    /// when no contradictions detected. Skills should surface these inline
47    /// to the user with accept/dismiss verbs (see `accept_revision` /
48    /// `dismiss_revision` MCP tools).
49    #[serde(default, skip_serializing_if = "Vec::is_empty")]
50    pub triggered_revisions: Vec<String>,
51    /// Source IDs of protected memories auto-accepted by the daemon because
52    /// the capture came from a full-trust agent and embedding similarity
53    /// exceeded the auto-supersede threshold. No human action needed.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub auto_superseded: Vec<String>,
56}
57
58fn default_extraction_method() -> String {
59    "unknown".to_string()
60}
61
62#[derive(Debug, Serialize, Deserialize)]
63pub struct SearchMemoryResponse {
64    pub results: Vec<SearchResult>,
65    pub took_ms: f64,
66    /// Distilled pages surfaced by the page channel during reranked search.
67    /// Absent when no page rows were returned (back-compat: old daemons never
68    /// set this field; old consumers that don't read it are unaffected).
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub supplemental_pages: Option<Vec<SearchResult>>,
71}
72
73#[derive(Debug, Serialize, Deserialize)]
74pub struct ListMemoriesResponse {
75    pub memories: Vec<IndexedFileInfo>,
76}
77
78/// Shared wire format for any `deleted: bool` response.
79///
80/// Reused by:
81/// - `DELETE /api/memory/delete/{id}` (server/memory.rs)
82/// - `DELETE /api/documents/{source}/{source_id}` (server/ingest.rs)
83#[derive(Debug, Serialize, Deserialize)]
84pub struct DeleteResponse {
85    pub deleted: bool,
86}
87
88#[derive(Debug, Serialize, Deserialize)]
89pub struct ConfirmResponse {
90    pub confirmed: bool,
91}
92
93#[derive(Debug, Serialize, Deserialize)]
94pub struct ReclassifyMemoryResponse {
95    pub source_id: String,
96    pub memory_type: String,
97}
98
99#[derive(Debug, Serialize, Deserialize)]
100pub struct MemoryStatsResponse {
101    pub stats: MemoryStats,
102}
103
104#[derive(Debug, Serialize, Deserialize)]
105pub struct NurtureCardsResponse {
106    pub cards: Vec<MemoryItem>,
107}
108
109// ===== General search/context =====
110
111#[derive(Debug, Serialize, Deserialize)]
112pub struct HealthResponse {
113    pub status: String,
114    pub db_initialized: bool,
115    pub version: String,
116}
117
118/// Cross-encoder reranker state, surfaced on `/api/status` so operators can see
119/// whether an opt-in reranker is actually wired vs. silently degraded.
120#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
121#[serde(tag = "state", rename_all = "snake_case")]
122pub enum RerankerStatus {
123    /// `ORIGIN_RERANKER_ENABLED` was not `1` — no reranker requested.
124    #[default]
125    Disabled,
126    /// Reranker initialized and wired.
127    Active { model_id: String },
128    /// Reranker was requested but init failed (e.g. model download error);
129    /// search silently falls back to embedding+FTS ordering.
130    Failed { reason: String },
131}
132
133#[derive(Debug, Serialize, Deserialize)]
134pub struct StatusResponse {
135    pub is_running: bool,
136    pub files_indexed: u64,
137    pub files_total: u64,
138    pub sources_connected: Vec<String>,
139    /// Reranker on the DEEP path (`/api/memory/search` with `rerank=true`). Legacy
140    /// field — for `ORIGIN_RERANKER_ENABLED=1` it is the configured model, exactly as before.
141    #[serde(default)]
142    pub reranker: RerankerStatus,
143    /// Reranker on the LIGHT paths — quick (`/api/search`) + context
144    /// (`/api/chat-context`). Populated when `ORIGIN_RERANKER_MODE` is `lite`/`full`.
145    /// Additive: defaults to `Disabled` so older daemons (which omit it) deserialize cleanly.
146    #[serde(default)]
147    pub reranker_light: RerankerStatus,
148    /// Resolved reranker mode: `"off"` | `"lite"` | `"full"`. Empty string for older
149    /// daemons that predate `ORIGIN_RERANKER_MODE`.
150    #[serde(default)]
151    pub reranker_mode: String,
152}
153
154#[derive(Debug, Serialize, Deserialize)]
155pub struct SearchResponse {
156    pub results: Vec<SearchResult>,
157    pub took_ms: f64,
158}
159
160#[doc(hidden)]
161#[derive(Debug, Serialize, Deserialize)]
162pub struct ContextSuggestion {
163    pub content: String,
164    pub score: f32,
165    pub source: String,
166}
167
168#[doc(hidden)]
169#[derive(Debug, Serialize, Deserialize)]
170pub struct ContextResponse {
171    pub suggestions: Vec<ContextSuggestion>,
172    pub took_ms: f64,
173}
174
175#[derive(Debug, Default, Serialize, Deserialize)]
176pub struct TierTokenEstimates {
177    pub tier1_identity: usize,
178    pub tier2_project: usize,
179    pub tier3_relevant: usize,
180    pub total: usize,
181}
182
183#[derive(Debug, Serialize, Deserialize)]
184pub struct ProfileContext {
185    pub narrative: String,
186    pub identity: Vec<String>,
187    pub preferences: Vec<String>,
188    /// Deprecated: goal taxonomy folded into Identity by migration 45 (Phase 0).
189    /// Always empty — daemon does not emit goal-typed memories. Field stays for
190    /// wire backward compat; will be removed in 0.4.
191    #[deprecated(
192        since = "0.3.2",
193        note = "Goal taxonomy folded into Identity by migration 45 (Phase 0). \
194                Always empty. Will be removed in 0.4."
195    )]
196    #[serde(default, skip_serializing_if = "Vec::is_empty")]
197    pub goals: Vec<String>,
198}
199
200#[derive(Debug, Serialize, Deserialize)]
201pub struct KnowledgeContext {
202    #[serde(default, skip_serializing_if = "Vec::is_empty")]
203    pub pages: Vec<String>,
204    #[serde(default, skip_serializing_if = "Vec::is_empty")]
205    pub decisions: Vec<String>,
206    #[serde(default)]
207    pub relevant_memories: Vec<SearchResult>,
208    #[serde(default, skip_serializing_if = "Vec::is_empty")]
209    pub graph_context: Vec<String>,
210}
211
212#[derive(Debug, Serialize, Deserialize)]
213pub struct ChatContextResponse {
214    pub context: String,
215    pub profile: ProfileContext,
216    pub knowledge: KnowledgeContext,
217    pub took_ms: f64,
218    pub token_estimates: TierTokenEstimates,
219}
220
221// ===== Profile & Agents =====
222
223#[derive(Debug, Serialize, Deserialize)]
224pub struct ProfileResponse {
225    pub id: String,
226    pub name: String,
227    pub display_name: Option<String>,
228    pub email: Option<String>,
229    pub bio: Option<String>,
230    pub avatar_path: Option<String>,
231    pub created_at: i64,
232    pub updated_at: i64,
233}
234
235#[derive(Debug, Serialize, Deserialize)]
236pub struct AgentResponse {
237    pub id: String,
238    pub name: String,
239    #[serde(default, skip_serializing_if = "Option::is_none")]
240    pub display_name: Option<String>,
241    pub agent_type: String,
242    pub description: Option<String>,
243    pub enabled: bool,
244    pub trust_level: String,
245    pub last_seen_at: Option<i64>,
246    pub memory_count: i64,
247    pub created_at: i64,
248    pub updated_at: i64,
249}
250
251// ===== Knowledge graph =====
252
253#[derive(Debug, Serialize, Deserialize)]
254pub struct CreateEntityResponse {
255    pub id: String,
256    #[serde(default, skip_serializing_if = "Vec::is_empty")]
257    pub warnings: Vec<String>,
258}
259
260#[doc(hidden)]
261#[derive(Debug, Serialize, Deserialize)]
262pub struct CreateRelationResponse {
263    pub id: String,
264    #[serde(default, skip_serializing_if = "Vec::is_empty")]
265    pub warnings: Vec<String>,
266}
267
268#[derive(Debug, Serialize, Deserialize)]
269pub struct AddObservationResponse {
270    pub id: String,
271    #[serde(default, skip_serializing_if = "Vec::is_empty")]
272    pub warnings: Vec<String>,
273}
274
275#[doc(hidden)]
276#[derive(Debug, Serialize, Deserialize)]
277pub struct CreatePageResponse {
278    pub id: String,
279    #[serde(default, skip_serializing_if = "Vec::is_empty")]
280    pub warnings: Vec<String>,
281}
282
283#[derive(Debug, Serialize, Deserialize)]
284pub struct ListEntitiesResponse {
285    pub entities: Vec<Entity>,
286}
287
288#[derive(Debug, Serialize, Deserialize)]
289pub struct SearchEntitiesResponse {
290    pub results: Vec<EntitySearchResult>,
291}
292
293#[derive(Debug, Serialize, Deserialize)]
294pub struct SearchPagesResponse {
295    pub pages: Vec<Page>,
296}
297
298/// Wikilink graph centered on a single page. Outbound = labels parsed
299/// out of this page's body; `target_page_id` is `None` for orphans.
300/// Inbound = active pages whose body cites this title.
301#[derive(Debug, Serialize, Deserialize)]
302pub struct PageLinksResponse {
303    pub outbound: Vec<PageLinkOutbound>,
304    pub inbound: Vec<PageLinkInbound>,
305}
306
307#[derive(Debug, Serialize, Deserialize)]
308pub struct PageLinkOutbound {
309    pub label: String,
310    /// `None` when the resolver couldn't find a matching active page —
311    /// surfaces in the orphan-by-count feed via /api/pages/orphan-links.
312    pub target_page_id: Option<String>,
313}
314
315#[derive(Debug, Serialize, Deserialize)]
316pub struct PageLinkInbound {
317    pub source_page_id: String,
318    pub label: String,
319}
320
321// ===== Import =====
322
323#[derive(Debug, Serialize, Deserialize)]
324pub struct ImportMemoriesResponse {
325    pub imported: usize,
326    pub skipped: usize,
327    pub breakdown: HashMap<String, usize>,
328    pub entities_created: usize,
329    pub observations_added: usize,
330    pub relations_created: usize,
331    pub batch_id: String,
332}
333
334// ===== Steep =====
335
336/// How loud Origin should be about a phase's output.
337#[doc(hidden)]
338#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
339pub enum Nudge {
340    Silent,
341    Ambient,
342    Notable,
343    Wow,
344}
345
346/// Result of a single phase within a steep cycle.
347#[doc(hidden)]
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct PhaseResult {
350    pub name: String,
351    pub duration_ms: u64,
352    pub items_processed: usize,
353    pub error: Option<String>,
354    pub nudge: Nudge,
355    pub headline: Option<String>,
356}
357
358#[doc(hidden)]
359#[derive(Debug, Serialize, Deserialize)]
360pub struct SteepResponse {
361    pub memories_decayed: u64,
362    pub recaps_generated: u32,
363    pub distilled: u32,
364    pub pending_remaining: u32,
365    pub phases: Vec<PhaseResult>,
366}
367
368// ===== Config =====
369
370#[derive(Debug, Serialize, Deserialize)]
371pub struct ConfigResponse {
372    pub skip_apps: Vec<String>,
373    pub skip_title_patterns: Vec<String>,
374    pub private_browsing_detection: bool,
375    pub setup_completed: bool,
376    pub clipboard_enabled: bool,
377    pub screen_capture_enabled: bool,
378    pub remote_access_enabled: bool,
379    /// Anthropic model used for fast/routine tasks (e.g. classification, tagging).
380    #[serde(default, skip_serializing_if = "Option::is_none")]
381    pub routine_model: Option<String>,
382    /// Anthropic model used for synthesis tasks (e.g. distillation, narrative).
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub synthesis_model: Option<String>,
385    /// Base URL for an OpenAI-compatible external LLM endpoint.
386    #[serde(default, skip_serializing_if = "Option::is_none")]
387    pub external_llm_endpoint: Option<String>,
388    /// Model identifier to use with the external LLM endpoint.
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub external_llm_model: Option<String>,
391}
392
393// ===== Indexed files / chunks =====
394
395#[derive(Debug, Serialize, Deserialize)]
396pub struct IndexedFilesResponse {
397    pub files: Vec<IndexedFileInfo>,
398}
399
400#[derive(Debug, Serialize, Deserialize)]
401pub struct DeleteCountResponse {
402    pub deleted: usize,
403}
404
405// ===== Entity / Observation =====
406
407#[derive(Debug, Serialize, Deserialize)]
408pub struct SuccessResponse {
409    pub ok: bool,
410}
411
412// ===== Memory detail =====
413
414#[derive(Debug, Serialize, Deserialize)]
415pub struct MemoryDetailResponse {
416    pub memory: Option<MemoryItem>,
417}
418
419/// Detailed chunk-level view of a stored memory, returned by `/api/chunks/{source_id}`.
420#[derive(Debug, Clone, Serialize, Deserialize)]
421pub struct MemoryDetail {
422    pub id: String,
423    pub content: String,
424    pub title: String,
425    pub source_id: String,
426    pub chunk_index: i32,
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub chunk_type: Option<String>,
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub language: Option<String>,
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub semantic_unit: Option<String>,
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub byte_start: Option<i64>,
435    #[serde(skip_serializing_if = "Option::is_none")]
436    pub byte_end: Option<i64>,
437    #[serde(skip_serializing_if = "Option::is_none")]
438    pub summary: Option<String>,
439}
440
441/// A pending revision waiting for human approval (Protected tier supersede).
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct PendingRevision {
444    pub source_id: String,
445    pub content: String,
446    pub source_agent: Option<String>,
447}
448
449#[derive(Debug, Serialize, Deserialize)]
450pub struct VersionChainResponse {
451    pub versions: Vec<crate::memory::MemoryVersionItem>,
452}
453
454// ===== Tags =====
455
456#[derive(Debug, Serialize, Deserialize)]
457pub struct TagsResponse {
458    pub tags: Vec<String>,
459}
460
461// ===== Activity =====
462
463#[derive(Debug, Serialize, Deserialize)]
464pub struct ActivityResponse {
465    pub activities: Vec<crate::memory::AgentActivityRow>,
466}
467
468// ===== Decisions =====
469
470#[derive(Debug, Serialize, Deserialize)]
471pub struct DecisionsResponse {
472    pub decisions: Vec<MemoryItem>,
473}
474
475#[derive(Debug, Serialize, Deserialize)]
476pub struct DecisionDomainsResponse {
477    /// Kept as `domains` for one-release back-compat with callers of
478    /// `/api/decisions/domains`; rename to `spaces` in PR-A+1.
479    pub domains: Vec<String>,
480}
481
482// ===== Pinned =====
483
484#[derive(Debug, Serialize, Deserialize)]
485pub struct PinnedMemoriesResponse {
486    pub memories: Vec<MemoryItem>,
487}
488
489// ===== Ingest =====
490
491#[derive(Debug, Serialize, Deserialize)]
492pub struct IngestResponse {
493    pub chunks_created: usize,
494    pub document_id: String,
495}
496
497// Note: ingest's `DELETE /api/documents/{source}/{source_id}` reuses the
498// `DeleteResponse { deleted: bool }` defined above — same wire format.
499
500// ===== Concept Export =====
501
502/// Statistics from a bulk page export operation (POST /api/pages/export).
503#[derive(Debug, Default, Serialize, Deserialize)]
504pub struct ExportStats {
505    pub exported: usize,
506    pub skipped: usize,
507    pub failed: usize,
508}
509
510#[derive(Debug, Deserialize, Serialize)]
511pub struct ExportPageResponse {
512    pub path: String,
513}
514
515// ===== Knowledge Directory =====
516
517#[derive(Debug, Deserialize, Serialize)]
518pub struct KnowledgePathResponse {
519    pub path: String,
520}
521
522#[derive(Debug, Deserialize, Serialize)]
523pub struct KnowledgeCountResponse {
524    pub count: u64,
525}
526
527// ===== Revision history =====
528
529/// One entry in a memory's supersede chain, returned by `/api/memory/{id}/revisions`.
530///
531/// `depth = 0` is the current (most-recent) memory; higher depths are older
532/// predecessors. `delta_summary` is `None` for the deepest entry (no predecessor
533/// to diff against) and computed heuristically for all shallower entries.
534#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct MemoryRevisionEntry {
536    pub source_id: String,
537    pub depth: i64,
538    pub title: String,
539    pub content_preview: String,
540    pub last_modified: i64,
541    #[serde(skip_serializing_if = "Option::is_none")]
542    pub source_agent: Option<String>,
543    #[serde(skip_serializing_if = "Option::is_none")]
544    pub supersede_mode: Option<String>,
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub delta_summary: Option<String>,
547}
548
549/// Response envelope for `/api/memory/{id}/revisions`.
550#[derive(Debug, Clone, Serialize, Deserialize)]
551pub struct ListMemoryRevisionsResponse {
552    pub current_source_id: String,
553    pub chain_depth: i64,
554    pub entries: Vec<MemoryRevisionEntry>,
555}
556
557/// One entry in a page's version changelog, returned by `/api/pages/{id}/revisions`.
558#[derive(Debug, Clone, Serialize, Deserialize)]
559pub struct PageChangelogEntry {
560    pub version: i64,
561    pub at: i64,
562    pub edited_by: String,
563    #[serde(skip_serializing_if = "Option::is_none")]
564    pub delta_summary: Option<String>,
565    #[serde(skip_serializing_if = "Option::is_none")]
566    pub incoming_source_ids: Option<Vec<String>>,
567}
568
569/// Response envelope for `/api/pages/{id}/revisions`.
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct ListPageRevisionsResponse {
572    pub page_id: String,
573    pub current_version: i64,
574    pub user_edited: bool,
575    #[serde(skip_serializing_if = "Option::is_none")]
576    pub stale_reason: Option<String>,
577    pub entries: Vec<PageChangelogEntry>,
578}
579
580// ===== Sources =====
581
582#[doc(hidden)]
583#[derive(Debug, Clone, Serialize, Deserialize)]
584pub struct SyncStatsResponse {
585    pub files_found: usize,
586    pub ingested: usize,
587    pub skipped: usize,
588    pub errors: usize,
589}
590
591// ===== Refinement proposals =====
592
593/// The action type for a background-refinery proposal.
594///
595/// Used as the `action` tag in [`RefinementPayload`].
596#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
597#[serde(rename_all = "snake_case")]
598pub enum ProposalAction {
599    EntityMerge,
600    RelationConflict,
601    DetectContradiction,
602    SuggestEntity,
603    DedupMerge,
604}
605
606/// Tagged-union payload emitted by the background refinery.
607///
608/// Each variant carries exactly the fields needed for that action type.
609/// Decoded at the route boundary so downstream consumers (MCP wrappers,
610/// agent skills) can pattern-match instead of inspecting raw JSON strings.
611#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
612#[serde(tag = "action", rename_all = "snake_case")]
613pub enum RefinementPayload {
614    EntityMerge {
615        existing_id: String,
616        new_id: String,
617        similarity: f64,
618    },
619    RelationConflict {
620        existing_id: String,
621        new_id: String,
622        from: String,
623        to: String,
624        old_type: String,
625        new_type: String,
626    },
627    DetectContradiction,
628    SuggestEntity {
629        #[serde(default, skip_serializing_if = "Option::is_none")]
630        name_hint: Option<String>,
631    },
632    DedupMerge,
633}
634
635#[derive(Debug, Serialize, Deserialize, Clone)]
636pub struct RefinementProposalSummary {
637    pub id: String,
638    pub action: ProposalAction,
639    pub source_ids: Vec<String>,
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub payload: Option<RefinementPayload>,
642    pub confidence: f64,
643    pub created_at: String,
644}
645
646#[derive(Debug, Serialize, Deserialize, Clone, Default)]
647pub struct ListRefinementsResponse {
648    pub proposals: Vec<RefinementProposalSummary>,
649}
650
651#[derive(Debug, Serialize, Deserialize, Clone)]
652pub struct RejectRefinementResponse {
653    pub id: String,
654}
655
656#[derive(Debug, Clone, Serialize, Deserialize)]
657pub struct AcceptRefinementResponse {
658    pub id: String,
659    pub action_applied: String,
660}
661
662#[cfg(test)]
663mod refinement_wire_tests {
664    use super::*;
665
666    #[test]
667    fn proposal_action_serde_round_trip() {
668        let cases = [
669            ("\"entity_merge\"", ProposalAction::EntityMerge),
670            ("\"relation_conflict\"", ProposalAction::RelationConflict),
671            (
672                "\"detect_contradiction\"",
673                ProposalAction::DetectContradiction,
674            ),
675            ("\"suggest_entity\"", ProposalAction::SuggestEntity),
676            ("\"dedup_merge\"", ProposalAction::DedupMerge),
677        ];
678        for (json, expected) in cases {
679            let parsed: ProposalAction = serde_json::from_str(json).unwrap();
680            assert_eq!(parsed, expected, "deserialize {json}");
681            let back = serde_json::to_string(&expected).unwrap();
682            assert_eq!(back, json, "serialize {expected:?}");
683        }
684    }
685
686    #[test]
687    fn refinement_payload_entity_merge_round_trip() {
688        let json =
689            r#"{"action":"entity_merge","existing_id":"e1","new_id":"e2","similarity":0.87}"#;
690        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
691        match parsed {
692            RefinementPayload::EntityMerge {
693                ref existing_id,
694                ref new_id,
695                similarity,
696            } => {
697                assert_eq!(existing_id, "e1");
698                assert_eq!(new_id, "e2");
699                assert!((similarity - 0.87).abs() < 1e-9);
700            }
701            _ => panic!("expected EntityMerge variant"),
702        }
703        let back = serde_json::to_value(&parsed).unwrap();
704        assert_eq!(back["action"], "entity_merge");
705        assert_eq!(back["existing_id"], "e1");
706    }
707
708    #[test]
709    fn refinement_payload_dedup_merge_no_fields() {
710        let json = r#"{"action":"dedup_merge"}"#;
711        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
712        assert!(matches!(parsed, RefinementPayload::DedupMerge));
713    }
714
715    #[test]
716    fn refinement_payload_relation_conflict_round_trip() {
717        let json = r#"{"action":"relation_conflict","existing_id":"r1","new_id":"r2","from":"e_a","to":"e_b","old_type":"works_at","new_type":"founded"}"#;
718        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
719        match parsed {
720            RefinementPayload::RelationConflict {
721                ref existing_id,
722                ref new_id,
723                ref from,
724                ref to,
725                ref old_type,
726                ref new_type,
727            } => {
728                assert_eq!(existing_id, "r1");
729                assert_eq!(new_id, "r2");
730                assert_eq!(from, "e_a");
731                assert_eq!(to, "e_b");
732                assert_eq!(old_type, "works_at");
733                assert_eq!(new_type, "founded");
734            }
735            _ => panic!("expected RelationConflict"),
736        }
737        let back = serde_json::to_value(&parsed).unwrap();
738        assert_eq!(back["from"], "e_a");
739        assert_eq!(back["to"], "e_b");
740    }
741
742    #[test]
743    fn refinement_payload_detect_contradiction_unit_variant() {
744        let json = r#"{"action":"detect_contradiction"}"#;
745        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
746        assert!(matches!(parsed, RefinementPayload::DetectContradiction));
747    }
748
749    #[test]
750    fn refinement_payload_suggest_entity_with_name_hint() {
751        let json = r#"{"action":"suggest_entity","name_hint":"PostgreSQL"}"#;
752        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
753        match parsed {
754            RefinementPayload::SuggestEntity { ref name_hint } => {
755                assert_eq!(name_hint.as_deref(), Some("PostgreSQL"));
756            }
757            _ => panic!("expected SuggestEntity"),
758        }
759    }
760
761    #[test]
762    fn refinement_payload_suggest_entity_without_name_hint() {
763        let json = r#"{"action":"suggest_entity"}"#;
764        let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
765        assert!(matches!(
766            parsed,
767            RefinementPayload::SuggestEntity { name_hint: None }
768        ));
769    }
770
771    #[test]
772    fn list_refinements_response_round_trip() {
773        let resp = ListRefinementsResponse {
774            proposals: vec![RefinementProposalSummary {
775                id: "ref_1".into(),
776                action: ProposalAction::EntityMerge,
777                source_ids: vec!["a".into(), "b".into()],
778                payload: Some(RefinementPayload::EntityMerge {
779                    existing_id: "a".into(),
780                    new_id: "b".into(),
781                    similarity: 0.86,
782                }),
783                confidence: 0.86,
784                created_at: "2026-05-12T00:00:00Z".into(),
785            }],
786        };
787        let json = serde_json::to_string(&resp).unwrap();
788        let parsed: ListRefinementsResponse = serde_json::from_str(&json).unwrap();
789        assert_eq!(parsed.proposals.len(), 1);
790        assert_eq!(parsed.proposals[0].id, "ref_1");
791        assert!(matches!(
792            parsed.proposals[0].action,
793            ProposalAction::EntityMerge
794        ));
795    }
796
797    #[test]
798    fn reject_refinement_response_round_trip() {
799        let resp = RejectRefinementResponse { id: "ref_x".into() };
800        let json = serde_json::to_string(&resp).unwrap();
801        let parsed: RejectRefinementResponse = serde_json::from_str(&json).unwrap();
802        assert_eq!(parsed.id, "ref_x");
803    }
804}
805
806/// One orphaned page link label aggregated across sources.
807///
808/// `count` is how many distinct source pages reference this label
809/// without a matching target page existing.
810#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
811pub struct OrphanLink {
812    pub label: String,
813    pub count: i64,
814}
815
816/// Response for `GET /api/pages/orphan-links`.
817#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
818pub struct OrphanLinksResponse {
819    pub min_count: usize,
820    pub orphan_labels: Vec<OrphanLink>,
821}
822
823/// One pending revision awaiting human accept/dismiss.
824///
825/// `target_source_id` is the memory being revised; pass it to
826/// `accept_pending_revision` or `dismiss_pending_revision`.
827/// `revision_source_id` is the staged revision row itself, exposed
828/// for diagnostics and round-tripping (not for the action call).
829#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
830pub struct PendingRevisionItem {
831    pub target_source_id: String,
832    pub revision_source_id: String,
833    pub revision_content: String,
834    pub source_agent: Option<String>,
835    pub last_modified: i64,
836}
837
838/// Response returned by `POST /api/memory/revision/{id}/accept`.
839/// Carries the now-consumed revision row id so agents can correlate with
840/// their `list_pending_revisions` cache.
841#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
842pub struct RevisionAcceptResponse {
843    pub target_source_id: String,
844    pub revision_source_id: String,
845    pub wrote: bool,
846}
847
848/// Response returned by `POST /api/memory/revision/{id}/dismiss`.
849/// `wrote: true` always (404 on missing).
850#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
851pub struct RevisionDismissResponse {
852    pub target_source_id: String,
853    pub wrote: bool,
854}
855
856/// Response returned by `POST /api/memory/contradiction/{source_id}/dismiss`.
857/// `wrote: true` is best-effort: the daemon's underlying DB method silently
858/// no-ops when no rows match. Wrapper cannot distinguish dismiss-of-existing
859/// from dismiss-of-nothing without an extra SELECT (out of scope).
860#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
861pub struct ContradictionDismissResponse {
862    pub source_id: String,
863    pub wrote: bool,
864}
865
866#[cfg(test)]
867mod mutation_response_tests {
868    use super::*;
869
870    #[test]
871    fn revision_accept_response_serializes_byte_identical() {
872        let r = RevisionAcceptResponse {
873            target_source_id: "mem_target".into(),
874            revision_source_id: "mem_rev".into(),
875            wrote: true,
876        };
877        assert_eq!(
878            serde_json::to_string(&r).unwrap(),
879            r#"{"target_source_id":"mem_target","revision_source_id":"mem_rev","wrote":true}"#
880        );
881    }
882
883    #[test]
884    fn revision_dismiss_response_serializes_byte_identical() {
885        let r = RevisionDismissResponse {
886            target_source_id: "mem_target".into(),
887            wrote: true,
888        };
889        assert_eq!(
890            serde_json::to_string(&r).unwrap(),
891            r#"{"target_source_id":"mem_target","wrote":true}"#
892        );
893    }
894
895    #[test]
896    fn contradiction_dismiss_response_serializes_byte_identical() {
897        let r = ContradictionDismissResponse {
898            source_id: "mem_abc".into(),
899            wrote: true,
900        };
901        assert_eq!(
902            serde_json::to_string(&r).unwrap(),
903            r#"{"source_id":"mem_abc","wrote":true}"#
904        );
905    }
906}
907
908#[cfg(test)]
909mod tests {
910    use super::*;
911
912    #[test]
913    fn store_memory_response_deserializes_without_extraction_method() {
914        // Forward-compat: older server responses (pre-D9) omit extraction_method entirely.
915        let json = r#"{
916            "source_id": "mem_abc",
917            "chunks_created": 3,
918            "memory_type": "fact"
919        }"#;
920        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
921        assert_eq!(parsed.source_id, "mem_abc");
922        assert_eq!(parsed.chunks_created, 3);
923        assert_eq!(parsed.memory_type, "fact");
924        assert_eq!(parsed.extraction_method, "unknown");
925        assert!(parsed.warnings.is_empty());
926    }
927
928    #[test]
929    fn store_memory_response_deserializes_with_all_fields() {
930        let json = r#"{
931            "source_id": "mem_abc",
932            "chunks_created": 3,
933            "memory_type": "fact",
934            "warnings": ["decision memory missing claim"],
935            "extraction_method": "llm"
936        }"#;
937        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
938        assert_eq!(parsed.warnings.len(), 1);
939        assert_eq!(parsed.extraction_method, "llm");
940    }
941
942    #[test]
943    fn store_memory_response_exposes_enrichment_and_hint() {
944        // Post-async-refactor shape: the daemon returns immediately after
945        // upsert and reports deferred enrichment via `enrichment` + `hint`.
946        let json = r#"{
947            "source_id": "mem_xyz",
948            "chunks_created": 1,
949            "memory_type": "fact",
950            "warnings": [],
951            "extraction_method": "unknown",
952            "enrichment": "pending",
953            "hint": "Stored. Origin is compiling classification + concept links in the background (~2s). Recall will surface the enriched form shortly."
954        }"#;
955        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
956        assert_eq!(parsed.enrichment, "pending");
957        assert!(parsed.hint.contains("compiling"));
958    }
959
960    #[test]
961    fn store_memory_response_defaults_enrichment_for_older_responses() {
962        // Backward-compat: existing clients (origin-mcp, Tauri app) that
963        // deserialize pre-async-refactor responses must keep working.
964        let json = r#"{
965            "source_id": "mem_old",
966            "chunks_created": 1,
967            "memory_type": "fact"
968        }"#;
969        let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
970        assert_eq!(parsed.enrichment, ""); // default
971        assert_eq!(parsed.hint, ""); // default
972    }
973
974    #[test]
975    fn store_memory_response_roundtrips_not_needed_state() {
976        // Daemon reports `not_needed` when no LLM is available. Hint is empty
977        // (skip_serializing_if) so the JSON shrinks accordingly.
978        let response = StoreMemoryResponse {
979            source_id: "mem_no_llm".into(),
980            chunks_created: 1,
981            memory_type: "fact".into(),
982            entity_id: None,
983            quality: None,
984            warnings: vec![],
985            extraction_method: "none".into(),
986            enrichment: "not_needed".into(),
987            hint: String::new(),
988            triggered_revisions: vec![],
989            auto_superseded: vec![],
990        };
991        let json = serde_json::to_string(&response).unwrap();
992        assert!(json.contains("\"enrichment\":\"not_needed\""));
993        assert!(
994            !json.contains("\"hint\""),
995            "empty hint must be skipped on the wire, got: {json}"
996        );
997        let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
998        assert_eq!(parsed.enrichment, "not_needed");
999        assert_eq!(parsed.hint, "");
1000    }
1001
1002    #[test]
1003    fn store_memory_response_triggered_revisions_serializes_when_non_empty() {
1004        let r = StoreMemoryResponse {
1005            source_id: "mem_new".into(),
1006            chunks_created: 1,
1007            memory_type: "fact".into(),
1008            entity_id: None,
1009            quality: None,
1010            warnings: vec![],
1011            extraction_method: "none".into(),
1012            enrichment: "not_needed".into(),
1013            hint: String::new(),
1014            triggered_revisions: vec!["mem_target_abc".to_string()],
1015            auto_superseded: vec![],
1016        };
1017        let json = serde_json::to_string(&r).unwrap();
1018        assert!(
1019            json.contains("\"triggered_revisions\":[\"mem_target_abc\"]"),
1020            "triggered_revisions must appear in JSON when non-empty, got: {json}"
1021        );
1022    }
1023
1024    #[test]
1025    fn store_memory_response_triggered_revisions_skips_when_empty() {
1026        let r = StoreMemoryResponse {
1027            source_id: "mem_new".into(),
1028            chunks_created: 1,
1029            memory_type: "fact".into(),
1030            entity_id: None,
1031            quality: None,
1032            warnings: vec![],
1033            extraction_method: "none".into(),
1034            enrichment: "not_needed".into(),
1035            hint: String::new(),
1036            triggered_revisions: vec![],
1037            auto_superseded: vec![],
1038        };
1039        let json = serde_json::to_string(&r).unwrap();
1040        assert!(
1041            !json.contains("triggered_revisions"),
1042            "triggered_revisions must be absent from JSON when empty, got: {json}"
1043        );
1044    }
1045
1046    #[test]
1047    fn store_memory_response_auto_superseded_serializes_when_non_empty() {
1048        let r = StoreMemoryResponse {
1049            source_id: "mem_new".into(),
1050            chunks_created: 1,
1051            memory_type: "fact".into(),
1052            entity_id: None,
1053            quality: None,
1054            warnings: vec![],
1055            extraction_method: "none".into(),
1056            enrichment: "not_needed".into(),
1057            hint: String::new(),
1058            triggered_revisions: vec![],
1059            auto_superseded: vec!["mem_old_abc".to_string()],
1060        };
1061        let json = serde_json::to_string(&r).unwrap();
1062        assert!(
1063            json.contains("\"auto_superseded\":[\"mem_old_abc\"]"),
1064            "auto_superseded must appear in JSON when non-empty, got: {json}"
1065        );
1066    }
1067
1068    #[test]
1069    fn store_memory_response_auto_superseded_skips_when_empty() {
1070        let r = StoreMemoryResponse {
1071            source_id: "mem_new".into(),
1072            chunks_created: 1,
1073            memory_type: "fact".into(),
1074            entity_id: None,
1075            quality: None,
1076            warnings: vec![],
1077            extraction_method: "none".into(),
1078            enrichment: "not_needed".into(),
1079            hint: String::new(),
1080            triggered_revisions: vec![],
1081            auto_superseded: vec![],
1082        };
1083        let json = serde_json::to_string(&r).unwrap();
1084        assert!(
1085            !json.contains("auto_superseded"),
1086            "auto_superseded must be absent from JSON when empty, got: {json}"
1087        );
1088    }
1089
1090    #[test]
1091    fn chat_context_response_roundtrips_with_empty_knowledge_sections() {
1092        // ProfileContext.goals is deprecated; constructing it directly here
1093        // for wire roundtrip coverage until 0.4 drops the field entirely.
1094        #[allow(deprecated)]
1095        let profile = ProfileContext {
1096            narrative: "n".into(),
1097            identity: vec![],
1098            preferences: vec![],
1099            goals: vec![],
1100        };
1101        let response = ChatContextResponse {
1102            context: "context".into(),
1103            profile,
1104            knowledge: KnowledgeContext {
1105                pages: vec![],
1106                decisions: vec![],
1107                relevant_memories: vec![],
1108                graph_context: vec![],
1109            },
1110            took_ms: 1.0,
1111            token_estimates: TierTokenEstimates {
1112                tier1_identity: 1,
1113                tier2_project: 2,
1114                tier3_relevant: 3,
1115                total: 6,
1116            },
1117        };
1118
1119        let json = serde_json::to_string(&response).unwrap();
1120        let parsed: ChatContextResponse = serde_json::from_str(&json).unwrap();
1121        assert!(parsed.knowledge.pages.is_empty());
1122        assert!(parsed.knowledge.decisions.is_empty());
1123        assert!(parsed.knowledge.relevant_memories.is_empty());
1124        assert!(parsed.knowledge.graph_context.is_empty());
1125    }
1126
1127    #[test]
1128    fn orphan_links_response_golden_string() {
1129        let resp = OrphanLinksResponse {
1130            min_count: 2,
1131            orphan_labels: vec![OrphanLink {
1132                label: "Rust".to_string(),
1133                count: 3,
1134            }],
1135        };
1136        let s = serde_json::to_string(&resp).unwrap();
1137        assert_eq!(
1138            s,
1139            r#"{"min_count":2,"orphan_labels":[{"label":"Rust","count":3}]}"#
1140        );
1141    }
1142
1143    #[test]
1144    fn orphan_links_response_empty_round_trip() {
1145        let resp = OrphanLinksResponse {
1146            min_count: 1,
1147            orphan_labels: vec![],
1148        };
1149        let decoded: OrphanLinksResponse =
1150            serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
1151        assert_eq!(decoded, resp);
1152    }
1153
1154    #[test]
1155    fn pending_revision_item_round_trip() {
1156        let item = PendingRevisionItem {
1157            target_source_id: "mem_target".into(),
1158            revision_source_id: "mem_rev".into(),
1159            revision_content: "new body".into(),
1160            source_agent: Some("claude-code".into()),
1161            last_modified: 1_715_000_000,
1162        };
1163        let json = serde_json::to_value(&item).unwrap();
1164        assert_eq!(json["target_source_id"], "mem_target");
1165        assert_eq!(json["revision_source_id"], "mem_rev");
1166        assert_eq!(json["revision_content"], "new body");
1167        let decoded: PendingRevisionItem = serde_json::from_value(json).unwrap();
1168        assert_eq!(decoded, item);
1169    }
1170}
1171
1172#[cfg(test)]
1173mod reranker_status_tests {
1174    use super::*;
1175
1176    #[test]
1177    fn status_response_defaults_reranker_to_disabled() {
1178        // Old daemons omit reranker AND the newer reranker_light/reranker_mode fields
1179        // entirely — all three must default cleanly (additive wire change, no break).
1180        let json =
1181            r#"{"is_running":true,"files_indexed":0,"files_total":0,"sources_connected":[]}"#;
1182        let parsed: StatusResponse = serde_json::from_str(json).unwrap();
1183        assert_eq!(parsed.reranker, RerankerStatus::Disabled);
1184        assert_eq!(parsed.reranker_light, RerankerStatus::Disabled);
1185        assert_eq!(parsed.reranker_mode, "");
1186    }
1187
1188    #[test]
1189    fn status_response_roundtrips_per_path_reranker() {
1190        let s = StatusResponse {
1191            is_running: true,
1192            files_indexed: 0,
1193            files_total: 0,
1194            sources_connected: vec![],
1195            reranker: RerankerStatus::Active {
1196                model_id: "BGERerankerBase".into(),
1197            },
1198            reranker_light: RerankerStatus::Active {
1199                model_id: "JINARerankerV1TurboEn".into(),
1200            },
1201            reranker_mode: "full".into(),
1202        };
1203        let json = serde_json::to_string(&s).unwrap();
1204        let parsed: StatusResponse = serde_json::from_str(&json).unwrap();
1205        assert_eq!(parsed.reranker, s.reranker);
1206        assert_eq!(parsed.reranker_light, s.reranker_light);
1207        assert_eq!(parsed.reranker_mode, "full");
1208    }
1209
1210    #[test]
1211    fn reranker_status_active_roundtrips() {
1212        let s = RerankerStatus::Active {
1213            model_id: "BGERerankerBase".into(),
1214        };
1215        let json = serde_json::to_string(&s).unwrap();
1216        assert_eq!(serde_json::from_str::<RerankerStatus>(&json).unwrap(), s);
1217        assert!(json.contains("\"state\":\"active\""));
1218    }
1219}
1220
1221#[cfg(test)]
1222mod search_memory_response_tests {
1223    use super::SearchMemoryResponse;
1224
1225    /// Old daemon responses (no `supplemental_pages` key) must deserialize
1226    /// successfully with `supplemental_pages == None`.  This locks in the
1227    /// back-compat guarantee: clients talking to an older daemon never see a
1228    /// deserialization error.
1229    #[test]
1230    fn back_compat_missing_supplemental_pages_is_none() {
1231        let json = r#"{"results":[],"took_ms":1.0}"#;
1232        let resp: SearchMemoryResponse = serde_json::from_str(json).expect("should deserialize");
1233        assert!(
1234            resp.supplemental_pages.is_none(),
1235            "should be None when key absent"
1236        );
1237        assert_eq!(resp.took_ms, 1.0);
1238    }
1239
1240    /// `supplemental_pages` absent means the field is omitted on the wire
1241    /// (skip_serializing_if = "Option::is_none").
1242    #[test]
1243    fn none_supplemental_pages_not_serialized() {
1244        let resp = SearchMemoryResponse {
1245            results: vec![],
1246            took_ms: 2.0,
1247            supplemental_pages: None,
1248        };
1249        let json = serde_json::to_string(&resp).expect("serialize");
1250        assert!(
1251            !json.contains("supplemental_pages"),
1252            "None field must be omitted from wire: {}",
1253            json
1254        );
1255    }
1256
1257    /// When pages are present they round-trip correctly.
1258    #[test]
1259    fn some_supplemental_pages_round_trips() {
1260        let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1261        let resp: SearchMemoryResponse = serde_json::from_str(json).expect("deserialize");
1262        assert!(
1263            resp.supplemental_pages.is_some(),
1264            "supplemental_pages should be Some"
1265        );
1266        assert!(
1267            resp.supplemental_pages.unwrap().is_empty(),
1268            "empty array should deserialize to empty vec"
1269        );
1270    }
1271}