1use crate::entities::{Entity, EntitySearchResult};
5use crate::memory::{IndexedFileInfo, MemoryItem, MemoryStats, SearchResult};
6use crate::pages::Page;
7use crate::repair::RepairDigest;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Serialize, Deserialize)]
14pub struct StoreMemoryResponse {
15 pub source_id: String,
16 pub chunks_created: usize,
17 pub memory_type: String,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub entity_id: Option<String>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub quality: Option<String>,
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub warnings: Vec<String>,
28 #[serde(default = "default_extraction_method")]
30 pub extraction_method: String,
31 #[serde(default)]
38 pub enrichment: String,
39 #[serde(default, skip_serializing_if = "String::is_empty")]
44 pub hint: String,
45}
46
47fn default_extraction_method() -> String {
48 "unknown".to_string()
49}
50
51#[derive(Debug, Serialize, Deserialize)]
52pub struct SearchMemoryResponse {
53 pub results: Vec<SearchResult>,
54 pub took_ms: f64,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub supplemental_pages: Option<Vec<SearchResult>>,
60}
61
62#[derive(Debug, Serialize, Deserialize)]
63pub struct ListMemoriesResponse {
64 pub memories: Vec<IndexedFileInfo>,
65}
66
67#[derive(Debug, Serialize, Deserialize)]
73pub struct DeleteResponse {
74 pub deleted: bool,
75}
76
77#[derive(Debug, Serialize, Deserialize)]
78pub struct ConfirmResponse {
79 pub confirmed: bool,
80}
81
82#[derive(Debug, Serialize, Deserialize)]
83pub struct ReclassifyMemoryResponse {
84 pub source_id: String,
85 pub memory_type: String,
86}
87
88#[derive(Debug, Serialize, Deserialize)]
89pub struct MemoryStatsResponse {
90 pub stats: MemoryStats,
91}
92
93#[derive(Debug, Serialize, Deserialize)]
94pub struct NurtureCardsResponse {
95 pub cards: Vec<MemoryItem>,
96}
97
98#[derive(Debug, Serialize, Deserialize)]
101pub struct HealthResponse {
102 pub status: String,
103 pub db_initialized: bool,
104 pub version: String,
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
110#[serde(tag = "state", rename_all = "snake_case")]
111pub enum RerankerStatus {
112 #[default]
114 Disabled,
115 Active { model_id: String },
117 Failed { reason: String },
120}
121
122#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
127#[serde(tag = "state", rename_all = "snake_case")]
128pub enum QueueStatus {
129 #[default]
131 Idle,
132 Active { pending: u64 },
134 Paused {
139 reason: String,
140 pending: u64,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
142 next_retry_at: Option<i64>,
143 },
144}
145
146#[derive(Debug, Serialize, Deserialize)]
147pub struct StatusResponse {
148 pub is_running: bool,
149 pub files_indexed: u64,
150 pub files_total: u64,
151 pub sources_connected: Vec<String>,
152 #[serde(default)]
155 pub queue: QueueStatus,
156 #[serde(default)]
162 pub compile_queue: QueueStatus,
163 #[serde(default)]
166 pub reranker: RerankerStatus,
167 #[serde(default)]
171 pub reranker_light: RerankerStatus,
172 #[serde(default)]
175 pub reranker_mode: String,
176}
177
178#[derive(Debug, Serialize, Deserialize)]
179pub struct SearchResponse {
180 pub results: Vec<SearchResult>,
181 pub took_ms: f64,
182 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub supplemental_pages: Option<Vec<SearchResult>>,
188}
189
190#[doc(hidden)]
191#[derive(Debug, Serialize, Deserialize)]
192pub struct ContextSuggestion {
193 pub content: String,
194 pub score: f32,
195 pub source: String,
196}
197
198#[doc(hidden)]
199#[derive(Debug, Serialize, Deserialize)]
200pub struct ContextResponse {
201 pub suggestions: Vec<ContextSuggestion>,
202 pub took_ms: f64,
203}
204
205#[derive(Debug, Default, Serialize, Deserialize)]
206pub struct TierTokenEstimates {
207 pub tier1_identity: usize,
208 pub tier2_project: usize,
209 pub tier3_relevant: usize,
210 pub total: usize,
211}
212
213#[derive(Debug, Serialize, Deserialize)]
214pub struct ProfileContext {
215 pub narrative: String,
216 pub identity: Vec<String>,
217 pub preferences: Vec<String>,
218 #[deprecated(
222 since = "0.3.2",
223 note = "Goal taxonomy folded into Identity by migration 45 (Phase 0). \
224 Always empty. Will be removed in 0.4."
225 )]
226 #[serde(default, skip_serializing_if = "Vec::is_empty")]
227 pub goals: Vec<String>,
228}
229
230#[derive(Debug, Serialize, Deserialize)]
231pub struct KnowledgeContext {
232 #[serde(default, skip_serializing_if = "Vec::is_empty")]
233 pub pages: Vec<String>,
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
235 pub decisions: Vec<String>,
236 #[serde(default)]
237 pub relevant_memories: Vec<SearchResult>,
238 #[serde(default, skip_serializing_if = "Vec::is_empty")]
239 pub graph_context: Vec<String>,
240}
241
242#[derive(Debug, Serialize, Deserialize)]
243pub struct ChatContextResponse {
244 pub context: String,
245 pub profile: ProfileContext,
246 pub knowledge: KnowledgeContext,
247 pub took_ms: f64,
248 pub token_estimates: TierTokenEstimates,
249}
250
251#[derive(Debug, Serialize, Deserialize)]
254pub struct ProfileResponse {
255 pub id: String,
256 pub name: String,
257 pub display_name: Option<String>,
258 pub email: Option<String>,
259 pub bio: Option<String>,
260 pub avatar_path: Option<String>,
261 pub created_at: i64,
262 pub updated_at: i64,
263}
264
265#[derive(Debug, Serialize, Deserialize)]
266pub struct AgentResponse {
267 pub id: String,
268 pub name: String,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub display_name: Option<String>,
271 pub agent_type: String,
272 pub description: Option<String>,
273 pub enabled: bool,
274 pub trust_level: String,
275 pub last_seen_at: Option<i64>,
276 pub memory_count: i64,
277 pub created_at: i64,
278 pub updated_at: i64,
279}
280
281#[derive(Debug, Serialize, Deserialize)]
284pub struct CreateEntityResponse {
285 pub id: String,
286 #[serde(default, skip_serializing_if = "Vec::is_empty")]
287 pub warnings: Vec<String>,
288}
289
290#[doc(hidden)]
291#[derive(Debug, Serialize, Deserialize)]
292pub struct CreateRelationResponse {
293 pub id: String,
294 #[serde(default, skip_serializing_if = "Vec::is_empty")]
295 pub warnings: Vec<String>,
296}
297
298#[derive(Debug, Serialize, Deserialize)]
299pub struct AddObservationResponse {
300 pub id: String,
301 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 pub warnings: Vec<String>,
303}
304
305#[doc(hidden)]
306#[derive(Debug, Serialize, Deserialize)]
307pub struct CreatePageResponse {
308 pub id: String,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub attached_to: Option<String>,
311 #[serde(default, skip_serializing_if = "Vec::is_empty")]
312 pub warnings: Vec<String>,
313}
314
315#[derive(Debug, Serialize, Deserialize)]
316pub struct ListEntitiesResponse {
317 pub entities: Vec<Entity>,
318}
319
320#[derive(Debug, Serialize, Deserialize)]
321pub struct SearchEntitiesResponse {
322 pub results: Vec<EntitySearchResult>,
323}
324
325#[derive(Debug, Serialize, Deserialize)]
326pub struct SearchPagesResponse {
327 pub pages: Vec<Page>,
328}
329
330#[derive(Debug, Serialize, Deserialize)]
334pub struct PageLinksResponse {
335 pub outbound: Vec<PageLinkOutbound>,
336 pub inbound: Vec<PageLinkInbound>,
337}
338
339#[derive(Debug, Serialize, Deserialize)]
340pub struct PageLinkOutbound {
341 pub label: String,
342 pub target_page_id: Option<String>,
345}
346
347#[derive(Debug, Serialize, Deserialize)]
348pub struct PageLinkInbound {
349 pub source_page_id: String,
350 pub label: String,
351}
352
353#[derive(Debug, Serialize, Deserialize)]
356pub struct ImportMemoriesResponse {
357 pub imported: usize,
358 pub skipped: usize,
359 pub breakdown: HashMap<String, usize>,
360 pub entities_created: usize,
361 pub observations_added: usize,
362 pub relations_created: usize,
363 pub batch_id: String,
364}
365
366#[doc(hidden)]
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
371pub enum Nudge {
372 Silent,
373 Ambient,
374 Notable,
375 Wow,
376}
377
378#[doc(hidden)]
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct PhaseResult {
382 pub name: String,
383 pub duration_ms: u64,
384 pub items_processed: usize,
385 pub error: Option<String>,
386 pub nudge: Nudge,
387 pub headline: Option<String>,
388}
389
390#[doc(hidden)]
391#[derive(Debug, Serialize, Deserialize)]
392pub struct SteepResponse {
393 pub memories_decayed: u64,
394 pub recaps_generated: u32,
395 pub distilled: u32,
396 pub pending_remaining: u32,
397 pub phases: Vec<PhaseResult>,
398}
399
400#[derive(Debug, Serialize, Deserialize)]
403pub struct ConfigResponse {
404 pub skip_apps: Vec<String>,
405 pub skip_title_patterns: Vec<String>,
406 pub private_browsing_detection: bool,
407 pub setup_completed: bool,
408 pub clipboard_enabled: bool,
409 pub screen_capture_enabled: bool,
410 pub remote_access_enabled: bool,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub routine_model: Option<String>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub synthesis_model: Option<String>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub external_llm_endpoint: Option<String>,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub external_llm_model: Option<String>,
423 #[serde(default)]
426 pub external_llm_api_key_configured: bool,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub everyday_source: Option<String>,
432 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub synthesis_source: Option<String>,
436 #[serde(default)]
438 pub page_map_auto_suggest: bool,
439}
440
441#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
444pub struct OnDeviceModelEntry {
445 pub id: String,
446 pub display_name: String,
447 pub param_count: String,
448 pub ram_required_gb: f64,
449 pub file_size_gb: f64,
450 pub cached: bool,
451}
452
453#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
455pub struct OnDeviceModelResponse {
456 pub loaded: Option<String>,
458 pub selected: Option<String>,
461 pub models: Vec<OnDeviceModelEntry>,
463}
464
465#[derive(Debug, Serialize, Deserialize)]
468pub struct IndexedFilesResponse {
469 pub files: Vec<IndexedFileInfo>,
470}
471
472#[derive(Debug, Serialize, Deserialize)]
473pub struct DeleteCountResponse {
474 pub deleted: usize,
475}
476
477#[derive(Debug, Serialize, Deserialize)]
480pub struct SuccessResponse {
481 pub ok: bool,
482}
483
484fn is_false(value: &bool) -> bool {
485 !*value
486}
487
488#[derive(Debug, Serialize, Deserialize)]
489pub struct PageWriteResponse {
490 pub ok: bool,
491 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub revision_card_id: Option<String>,
493 #[serde(default, skip_serializing_if = "is_false")]
494 pub gated: bool,
495}
496
497#[derive(Debug, Clone, Serialize, Deserialize)]
499pub struct PageDraftResponse {
500 pub page: Page,
501}
502
503#[derive(Debug, Serialize, Deserialize)]
506pub struct MemoryDetailResponse {
507 pub memory: Option<MemoryItem>,
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct MemoryDetail {
513 pub id: String,
514 pub content: String,
515 pub title: String,
516 pub source_id: String,
517 pub chunk_index: i32,
518 #[serde(skip_serializing_if = "Option::is_none")]
519 pub chunk_type: Option<String>,
520 #[serde(skip_serializing_if = "Option::is_none")]
521 pub language: Option<String>,
522 #[serde(skip_serializing_if = "Option::is_none")]
523 pub semantic_unit: Option<String>,
524 #[serde(skip_serializing_if = "Option::is_none")]
525 pub byte_start: Option<i64>,
526 #[serde(skip_serializing_if = "Option::is_none")]
527 pub byte_end: Option<i64>,
528 #[serde(skip_serializing_if = "Option::is_none")]
529 pub summary: Option<String>,
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize)]
534pub struct PendingRevision {
535 pub source_id: String,
536 pub content: String,
537 pub source_agent: Option<String>,
538}
539
540#[derive(Debug, Serialize, Deserialize)]
541pub struct VersionChainResponse {
542 pub versions: Vec<crate::memory::MemoryVersionItem>,
543}
544
545#[derive(Debug, Serialize, Deserialize)]
548pub struct TagsResponse {
549 pub tags: Vec<String>,
550 #[serde(default)]
551 pub document_tags: HashMap<String, Vec<String>>,
552}
553
554#[derive(Debug, Serialize, Deserialize)]
557pub struct ActivityResponse {
558 pub activities: Vec<crate::memory::AgentActivityRow>,
559}
560
561#[derive(Debug, Serialize, Deserialize)]
564pub struct DecisionsResponse {
565 pub decisions: Vec<MemoryItem>,
566}
567
568#[derive(Debug, Serialize, Deserialize)]
569pub struct DecisionDomainsResponse {
570 pub domains: Vec<String>,
573}
574
575#[derive(Debug, Serialize, Deserialize)]
578pub struct PinnedMemoriesResponse {
579 pub memories: Vec<MemoryItem>,
580}
581
582#[derive(Debug, Serialize, Deserialize)]
585pub struct IngestResponse {
586 pub chunks_created: usize,
587 pub document_id: String,
588}
589
590#[derive(Debug, Default, Serialize, Deserialize)]
597pub struct ExportStats {
598 pub exported: usize,
599 pub skipped: usize,
600 pub failed: usize,
601}
602
603#[derive(Debug, Deserialize, Serialize)]
604pub struct ExportPageResponse {
605 pub path: String,
606}
607
608#[derive(Debug, Deserialize, Serialize)]
611pub struct KnowledgePathResponse {
612 pub path: String,
613}
614
615#[derive(Debug, Deserialize, Serialize)]
616pub struct KnowledgeCountResponse {
617 pub count: u64,
618}
619
620#[derive(Debug, Clone, Serialize, Deserialize)]
628pub struct MemoryRevisionEntry {
629 pub source_id: String,
630 pub depth: i64,
631 pub title: String,
632 pub content_preview: String,
633 pub last_modified: i64,
634 #[serde(skip_serializing_if = "Option::is_none")]
635 pub source_agent: Option<String>,
636 #[serde(skip_serializing_if = "Option::is_none")]
637 pub supersede_mode: Option<String>,
638 #[serde(skip_serializing_if = "Option::is_none")]
639 pub delta_summary: Option<String>,
640}
641
642#[derive(Debug, Clone, Serialize, Deserialize)]
644pub struct ListMemoryRevisionsResponse {
645 pub current_source_id: String,
646 pub chain_depth: i64,
647 pub entries: Vec<MemoryRevisionEntry>,
648}
649
650#[derive(Debug, Clone, Serialize, Deserialize)]
652pub struct PageChangelogEntry {
653 pub version: i64,
654 pub at: i64,
655 pub edited_by: String,
656 #[serde(skip_serializing_if = "Option::is_none")]
657 pub delta_summary: Option<String>,
658 #[serde(skip_serializing_if = "Option::is_none")]
659 pub incoming_source_ids: Option<Vec<String>>,
660 #[serde(default, skip_serializing_if = "Option::is_none")]
664 pub citations_summary: Option<String>,
665}
666
667#[derive(Debug, Clone, Serialize, Deserialize)]
669pub struct ListPageRevisionsResponse {
670 pub page_id: String,
671 pub current_version: i64,
672 pub user_edited: bool,
673 #[serde(skip_serializing_if = "Option::is_none")]
674 pub stale_reason: Option<String>,
675 pub entries: Vec<PageChangelogEntry>,
676}
677
678#[doc(hidden)]
681#[derive(Debug, Clone, Serialize, Deserialize)]
682pub struct SyncStatsResponse {
683 pub files_found: usize,
684 pub ingested: usize,
685 pub skipped: usize,
686 pub errors: usize,
687}
688
689#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
695#[serde(rename_all = "snake_case")]
696pub enum ProposalAction {
697 EntityMerge,
698 RelationConflict,
699 DetectContradiction,
700 SuggestEntity,
701 DedupMerge,
702 PageMerge,
703 CrossSpaceDiscovery,
704 PageKeepOrArchive,
705 LintRepairReview,
706 VocabPromote,
707 #[serde(other)]
712 Unknown,
713}
714
715#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
716#[serde(rename_all = "snake_case")]
717pub enum RefinementCardAction {
718 Accept,
719 Dismiss,
720 PickSpace,
721}
722
723#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
729#[serde(tag = "action", rename_all = "snake_case")]
730pub enum RefinementPayload {
731 EntityMerge {
732 existing_id: String,
733 new_id: String,
734 similarity: f64,
735 },
736 RelationConflict {
737 existing_id: String,
738 new_id: String,
739 from: String,
740 to: String,
741 old_type: String,
742 new_type: String,
743 },
744 DetectContradiction,
745 SuggestEntity {
746 #[serde(default, skip_serializing_if = "Option::is_none")]
747 name_hint: Option<String>,
748 },
749 DedupMerge,
750 PageMerge {
751 left_page_id: String,
752 right_page_id: String,
753 #[serde(default, skip_serializing_if = "Option::is_none")]
754 similarity: Option<f64>,
755 source_overlap: usize,
756 source_overlap_ratio: f64,
757 },
758 CrossSpaceDiscovery {
759 memory_count: usize,
760 spaces: Vec<String>,
761 allowed_actions: Vec<RefinementCardAction>,
762 },
763 PageKeepOrArchive {
764 page_id: String,
765 source_count: usize,
766 allowed_actions: Vec<RefinementCardAction>,
767 },
768 LintRepairReview {
769 check_id: String,
770 occurrence_digest: RepairDigest,
771 owner_binding_digest: RepairDigest,
772 issue: String,
773 choices: Vec<String>,
774 suggested_research_queries: Vec<String>,
775 },
776 VocabPromote {
777 kind: String,
778 old_value: String,
779 #[serde(default, skip_serializing_if = "Option::is_none")]
780 category: Option<String>,
781 },
782}
783
784#[derive(Debug, Serialize, Deserialize, Clone)]
785pub struct RefinementProposalSummary {
786 pub id: String,
787 pub action: ProposalAction,
788 pub source_ids: Vec<String>,
789 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub payload: Option<RefinementPayload>,
791 pub confidence: f64,
792 pub created_at: String,
793}
794
795#[derive(Debug, Serialize, Deserialize, Clone, Default)]
796pub struct ListRefinementsResponse {
797 pub proposals: Vec<RefinementProposalSummary>,
798}
799
800#[derive(Debug, Serialize, Deserialize, Clone)]
801pub struct RejectRefinementResponse {
802 pub id: String,
803}
804
805#[derive(Debug, Clone, Serialize, Deserialize)]
806pub struct AcceptRefinementResponse {
807 pub id: String,
808 pub action_applied: String,
809}
810
811#[cfg(test)]
812mod refinement_wire_tests {
813 use super::*;
814
815 #[test]
816 fn proposal_action_serde_round_trip() {
817 let cases = [
818 ("\"entity_merge\"", ProposalAction::EntityMerge),
819 ("\"relation_conflict\"", ProposalAction::RelationConflict),
820 (
821 "\"detect_contradiction\"",
822 ProposalAction::DetectContradiction,
823 ),
824 ("\"suggest_entity\"", ProposalAction::SuggestEntity),
825 ("\"dedup_merge\"", ProposalAction::DedupMerge),
826 (
827 "\"cross_space_discovery\"",
828 ProposalAction::CrossSpaceDiscovery,
829 ),
830 (
831 "\"page_keep_or_archive\"",
832 ProposalAction::PageKeepOrArchive,
833 ),
834 ("\"vocab_promote\"", ProposalAction::VocabPromote),
835 ];
836 for (json, expected) in cases {
837 let parsed: ProposalAction = serde_json::from_str(json).unwrap();
838 assert_eq!(parsed, expected, "deserialize {json}");
839 let back = serde_json::to_string(&expected).unwrap();
840 assert_eq!(back, json, "serialize {expected:?}");
841 }
842 }
843
844 #[test]
845 fn vocab_promote_payload_round_trips() {
846 let p = RefinementPayload::VocabPromote {
847 kind: "relation".into(),
848 old_value: "design_inspiration".into(),
849 category: None,
850 };
851 let json = serde_json::to_string(&p).unwrap();
852 let back: RefinementPayload = serde_json::from_str(&json).unwrap();
853 assert_eq!(p, back);
854 assert!(json.contains("\"action\":\"vocab_promote\""));
855 }
856
857 #[test]
858 fn proposal_action_unknown_future_variant_deserializes() {
859 let parsed: ProposalAction = serde_json::from_str("\"future_unshipped_action\"").unwrap();
862 assert_eq!(parsed, ProposalAction::Unknown);
863 let parsed2: ProposalAction = serde_json::from_str("\"totally_new_action\"").unwrap();
864 assert_eq!(parsed2, ProposalAction::Unknown);
865 }
866
867 #[test]
868 fn refinement_payload_entity_merge_round_trip() {
869 let json =
870 r#"{"action":"entity_merge","existing_id":"e1","new_id":"e2","similarity":0.87}"#;
871 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
872 match parsed {
873 RefinementPayload::EntityMerge {
874 ref existing_id,
875 ref new_id,
876 similarity,
877 } => {
878 assert_eq!(existing_id, "e1");
879 assert_eq!(new_id, "e2");
880 assert!((similarity - 0.87).abs() < 1e-9);
881 }
882 _ => panic!("expected EntityMerge variant"),
883 }
884 let back = serde_json::to_value(&parsed).unwrap();
885 assert_eq!(back["action"], "entity_merge");
886 assert_eq!(back["existing_id"], "e1");
887 }
888
889 #[test]
890 fn refinement_payload_dedup_merge_no_fields() {
891 let json = r#"{"action":"dedup_merge"}"#;
892 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
893 assert!(matches!(parsed, RefinementPayload::DedupMerge));
894 }
895
896 #[test]
897 fn refinement_payload_cross_space_discovery_round_trip() {
898 let json = r#"{"action":"cross_space_discovery","memory_count":3,"spaces":["personal","work"],"allowed_actions":["dismiss","pick_space"]}"#;
899 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
900 match parsed {
901 RefinementPayload::CrossSpaceDiscovery {
902 memory_count,
903 ref spaces,
904 ref allowed_actions,
905 } => {
906 assert_eq!(memory_count, 3);
907 assert_eq!(spaces, &vec!["personal".to_string(), "work".to_string()]);
908 assert_eq!(
909 allowed_actions,
910 &vec![
911 RefinementCardAction::Dismiss,
912 RefinementCardAction::PickSpace
913 ]
914 );
915 }
916 _ => panic!("expected CrossSpaceDiscovery"),
917 }
918 let back = serde_json::to_value(&parsed).unwrap();
919 assert_eq!(back["action"], "cross_space_discovery");
920 assert_eq!(back["memory_count"], 3);
921 }
922
923 #[test]
924 fn refinement_payload_page_keep_or_archive_round_trip() {
925 let json = r#"{"action":"page_keep_or_archive","page_id":"page_stub","source_count":1,"allowed_actions":["dismiss","accept"]}"#;
926 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
927 match parsed {
928 RefinementPayload::PageKeepOrArchive {
929 ref page_id,
930 source_count,
931 ref allowed_actions,
932 } => {
933 assert_eq!(page_id, "page_stub");
934 assert_eq!(source_count, 1);
935 assert_eq!(
936 allowed_actions,
937 &vec![RefinementCardAction::Dismiss, RefinementCardAction::Accept]
938 );
939 }
940 _ => panic!("expected PageKeepOrArchive"),
941 }
942 let back = serde_json::to_value(&parsed).unwrap();
943 assert_eq!(back["action"], "page_keep_or_archive");
944 assert_eq!(back["source_count"], 1);
945 }
946
947 #[test]
948 fn refinement_payload_relation_conflict_round_trip() {
949 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"}"#;
950 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
951 match parsed {
952 RefinementPayload::RelationConflict {
953 ref existing_id,
954 ref new_id,
955 ref from,
956 ref to,
957 ref old_type,
958 ref new_type,
959 } => {
960 assert_eq!(existing_id, "r1");
961 assert_eq!(new_id, "r2");
962 assert_eq!(from, "e_a");
963 assert_eq!(to, "e_b");
964 assert_eq!(old_type, "works_at");
965 assert_eq!(new_type, "founded");
966 }
967 _ => panic!("expected RelationConflict"),
968 }
969 let back = serde_json::to_value(&parsed).unwrap();
970 assert_eq!(back["from"], "e_a");
971 assert_eq!(back["to"], "e_b");
972 }
973
974 #[test]
975 fn refinement_payload_detect_contradiction_unit_variant() {
976 let json = r#"{"action":"detect_contradiction"}"#;
977 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
978 assert!(matches!(parsed, RefinementPayload::DetectContradiction));
979 }
980
981 #[test]
982 fn refinement_payload_suggest_entity_with_name_hint() {
983 let json = r#"{"action":"suggest_entity","name_hint":"PostgreSQL"}"#;
984 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
985 match parsed {
986 RefinementPayload::SuggestEntity { ref name_hint } => {
987 assert_eq!(name_hint.as_deref(), Some("PostgreSQL"));
988 }
989 _ => panic!("expected SuggestEntity"),
990 }
991 }
992
993 #[test]
994 fn refinement_payload_suggest_entity_without_name_hint() {
995 let json = r#"{"action":"suggest_entity"}"#;
996 let parsed: RefinementPayload = serde_json::from_str(json).unwrap();
997 assert!(matches!(
998 parsed,
999 RefinementPayload::SuggestEntity { name_hint: None }
1000 ));
1001 }
1002
1003 #[test]
1004 fn list_refinements_response_round_trip() {
1005 let resp = ListRefinementsResponse {
1006 proposals: vec![RefinementProposalSummary {
1007 id: "ref_1".into(),
1008 action: ProposalAction::EntityMerge,
1009 source_ids: vec!["a".into(), "b".into()],
1010 payload: Some(RefinementPayload::EntityMerge {
1011 existing_id: "a".into(),
1012 new_id: "b".into(),
1013 similarity: 0.86,
1014 }),
1015 confidence: 0.86,
1016 created_at: "2026-05-12T00:00:00Z".into(),
1017 }],
1018 };
1019 let json = serde_json::to_string(&resp).unwrap();
1020 let parsed: ListRefinementsResponse = serde_json::from_str(&json).unwrap();
1021 assert_eq!(parsed.proposals.len(), 1);
1022 assert_eq!(parsed.proposals[0].id, "ref_1");
1023 assert!(matches!(
1024 parsed.proposals[0].action,
1025 ProposalAction::EntityMerge
1026 ));
1027 }
1028
1029 #[test]
1030 fn reject_refinement_response_round_trip() {
1031 let resp = RejectRefinementResponse { id: "ref_x".into() };
1032 let json = serde_json::to_string(&resp).unwrap();
1033 let parsed: RejectRefinementResponse = serde_json::from_str(&json).unwrap();
1034 assert_eq!(parsed.id, "ref_x");
1035 }
1036}
1037
1038#[cfg(test)]
1039mod on_device_model_response_tests {
1040 use super::*;
1041
1042 #[test]
1043 fn on_device_model_response_preserves_selected_loaded_and_models() {
1044 let response = OnDeviceModelResponse {
1045 loaded: Some("qwen3-4b".to_string()),
1046 selected: Some("qwen3-4b".to_string()),
1047 models: vec![OnDeviceModelEntry {
1048 id: "qwen3-4b".to_string(),
1049 display_name: "Qwen3 4B".to_string(),
1050 param_count: "4B".to_string(),
1051 ram_required_gb: 6.0,
1052 file_size_gb: 2.7,
1053 cached: true,
1054 }],
1055 };
1056
1057 let value = serde_json::to_value(&response).unwrap();
1058
1059 assert_eq!(value["loaded"], "qwen3-4b");
1060 assert_eq!(value["selected"], "qwen3-4b");
1061 assert_eq!(value["models"][0]["id"], "qwen3-4b");
1062 assert_eq!(value["models"][0]["cached"], true);
1063
1064 let parsed: OnDeviceModelResponse = serde_json::from_value(value).unwrap();
1065 assert_eq!(parsed.loaded.as_deref(), Some("qwen3-4b"));
1066 assert_eq!(parsed.selected.as_deref(), Some("qwen3-4b"));
1067 assert_eq!(parsed.models.len(), 1);
1068 assert!(parsed.models[0].cached);
1069 }
1070
1071 #[test]
1072 fn on_device_model_response_allows_null_loaded_and_selected() {
1073 let parsed: OnDeviceModelResponse =
1074 serde_json::from_str(r#"{"loaded":null,"selected":null,"models":[]}"#).unwrap();
1075
1076 assert!(parsed.loaded.is_none());
1077 assert!(parsed.selected.is_none());
1078 assert!(parsed.models.is_empty());
1079 }
1080}
1081
1082#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1087pub struct OrphanLink {
1088 pub label: String,
1089 pub count: i64,
1090}
1091
1092#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1094pub struct OrphanLinksResponse {
1095 pub min_count: usize,
1096 pub orphan_labels: Vec<OrphanLink>,
1097}
1098
1099#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1106pub struct PendingRevisionItem {
1107 pub target_source_id: String,
1108 pub revision_source_id: String,
1109 pub revision_content: String,
1110 pub source_agent: Option<String>,
1111 pub last_modified: i64,
1112 #[serde(default, skip_serializing_if = "Option::is_none")]
1115 pub grounded_in: Option<String>,
1116}
1117
1118#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1122pub struct RevisionAcceptResponse {
1123 pub target_source_id: String,
1124 pub revision_source_id: String,
1125 pub wrote: bool,
1126}
1127
1128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1131pub struct RevisionDismissResponse {
1132 pub target_source_id: String,
1133 pub wrote: bool,
1134}
1135
1136#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1141pub struct ContradictionDismissResponse {
1142 pub source_id: String,
1143 pub wrote: bool,
1144}
1145
1146#[cfg(test)]
1147mod mutation_response_tests {
1148 use super::*;
1149
1150 #[test]
1151 fn revision_accept_response_serializes_byte_identical() {
1152 let r = RevisionAcceptResponse {
1153 target_source_id: "mem_target".into(),
1154 revision_source_id: "mem_rev".into(),
1155 wrote: true,
1156 };
1157 assert_eq!(
1158 serde_json::to_string(&r).unwrap(),
1159 r#"{"target_source_id":"mem_target","revision_source_id":"mem_rev","wrote":true}"#
1160 );
1161 }
1162
1163 #[test]
1164 fn revision_dismiss_response_serializes_byte_identical() {
1165 let r = RevisionDismissResponse {
1166 target_source_id: "mem_target".into(),
1167 wrote: true,
1168 };
1169 assert_eq!(
1170 serde_json::to_string(&r).unwrap(),
1171 r#"{"target_source_id":"mem_target","wrote":true}"#
1172 );
1173 }
1174
1175 #[test]
1176 fn contradiction_dismiss_response_serializes_byte_identical() {
1177 let r = ContradictionDismissResponse {
1178 source_id: "mem_abc".into(),
1179 wrote: true,
1180 };
1181 assert_eq!(
1182 serde_json::to_string(&r).unwrap(),
1183 r#"{"source_id":"mem_abc","wrote":true}"#
1184 );
1185 }
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190 use super::*;
1191
1192 #[test]
1193 fn store_memory_response_deserializes_without_extraction_method() {
1194 let json = r#"{
1196 "source_id": "mem_abc",
1197 "chunks_created": 3,
1198 "memory_type": "fact"
1199 }"#;
1200 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1201 assert_eq!(parsed.source_id, "mem_abc");
1202 assert_eq!(parsed.chunks_created, 3);
1203 assert_eq!(parsed.memory_type, "fact");
1204 assert_eq!(parsed.extraction_method, "unknown");
1205 assert!(parsed.warnings.is_empty());
1206 }
1207
1208 #[test]
1209 fn store_memory_response_deserializes_with_all_fields() {
1210 let json = r#"{
1211 "source_id": "mem_abc",
1212 "chunks_created": 3,
1213 "memory_type": "fact",
1214 "warnings": ["decision memory missing claim"],
1215 "extraction_method": "llm"
1216 }"#;
1217 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1218 assert_eq!(parsed.warnings.len(), 1);
1219 assert_eq!(parsed.extraction_method, "llm");
1220 }
1221
1222 #[test]
1223 fn store_memory_response_exposes_enrichment_and_hint() {
1224 let json = r#"{
1227 "source_id": "mem_xyz",
1228 "chunks_created": 1,
1229 "memory_type": "fact",
1230 "warnings": [],
1231 "extraction_method": "unknown",
1232 "enrichment": "pending",
1233 "hint": "Stored. Wenlan is compiling classification + concept links in the background (~2s). Recall will surface the enriched form shortly."
1234 }"#;
1235 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1236 assert_eq!(parsed.enrichment, "pending");
1237 assert!(parsed.hint.contains("compiling"));
1238 }
1239
1240 #[test]
1241 fn store_memory_response_defaults_enrichment_for_older_responses() {
1242 let json = r#"{
1245 "source_id": "mem_old",
1246 "chunks_created": 1,
1247 "memory_type": "fact"
1248 }"#;
1249 let parsed: StoreMemoryResponse = serde_json::from_str(json).unwrap();
1250 assert_eq!(parsed.enrichment, ""); assert_eq!(parsed.hint, ""); }
1253
1254 #[test]
1255 fn tags_response_defaults_document_tags_for_older_responses() {
1256 let json = r#"{"tags":["rust","tauri"]}"#;
1257 let parsed: TagsResponse = serde_json::from_str(json).unwrap();
1258
1259 assert_eq!(parsed.tags, vec!["rust", "tauri"]);
1260 assert!(parsed.document_tags.is_empty());
1261 }
1262
1263 #[test]
1264 fn tags_response_deserializes_document_tag_map() {
1265 let json = r#"{
1266 "tags":["rust","tauri"],
1267 "document_tags":{"memory::mem1":["rust"],"page::page1":["tauri"]}
1268 }"#;
1269 let parsed: TagsResponse = serde_json::from_str(json).unwrap();
1270
1271 assert_eq!(
1272 parsed.document_tags.get("memory::mem1"),
1273 Some(&vec!["rust".to_string()])
1274 );
1275 assert_eq!(
1276 parsed.document_tags.get("page::page1"),
1277 Some(&vec!["tauri".to_string()])
1278 );
1279 }
1280
1281 #[test]
1282 fn store_memory_response_roundtrips_not_needed_state() {
1283 let response = StoreMemoryResponse {
1286 source_id: "mem_no_llm".into(),
1287 chunks_created: 1,
1288 memory_type: "fact".into(),
1289 entity_id: None,
1290 quality: None,
1291 warnings: vec![],
1292 extraction_method: "none".into(),
1293 enrichment: "not_needed".into(),
1294 hint: String::new(),
1295 };
1296 let json = serde_json::to_string(&response).unwrap();
1297 assert!(json.contains("\"enrichment\":\"not_needed\""));
1298 assert!(
1299 !json.contains("\"hint\""),
1300 "empty hint must be skipped on the wire, got: {json}"
1301 );
1302 let parsed: StoreMemoryResponse = serde_json::from_str(&json).unwrap();
1303 assert_eq!(parsed.enrichment, "not_needed");
1304 assert_eq!(parsed.hint, "");
1305 }
1306
1307 #[test]
1308 fn chat_context_response_roundtrips_with_empty_knowledge_sections() {
1309 #[allow(deprecated)]
1312 let profile = ProfileContext {
1313 narrative: "n".into(),
1314 identity: vec![],
1315 preferences: vec![],
1316 goals: vec![],
1317 };
1318 let response = ChatContextResponse {
1319 context: "context".into(),
1320 profile,
1321 knowledge: KnowledgeContext {
1322 pages: vec![],
1323 decisions: vec![],
1324 relevant_memories: vec![],
1325 graph_context: vec![],
1326 },
1327 took_ms: 1.0,
1328 token_estimates: TierTokenEstimates {
1329 tier1_identity: 1,
1330 tier2_project: 2,
1331 tier3_relevant: 3,
1332 total: 6,
1333 },
1334 };
1335
1336 let json = serde_json::to_string(&response).unwrap();
1337 let parsed: ChatContextResponse = serde_json::from_str(&json).unwrap();
1338 assert!(parsed.knowledge.pages.is_empty());
1339 assert!(parsed.knowledge.decisions.is_empty());
1340 assert!(parsed.knowledge.relevant_memories.is_empty());
1341 assert!(parsed.knowledge.graph_context.is_empty());
1342 }
1343
1344 #[test]
1345 fn orphan_links_response_golden_string() {
1346 let resp = OrphanLinksResponse {
1347 min_count: 2,
1348 orphan_labels: vec![OrphanLink {
1349 label: "Rust".to_string(),
1350 count: 3,
1351 }],
1352 };
1353 let s = serde_json::to_string(&resp).unwrap();
1354 assert_eq!(
1355 s,
1356 r#"{"min_count":2,"orphan_labels":[{"label":"Rust","count":3}]}"#
1357 );
1358 }
1359
1360 #[test]
1361 fn orphan_links_response_empty_round_trip() {
1362 let resp = OrphanLinksResponse {
1363 min_count: 1,
1364 orphan_labels: vec![],
1365 };
1366 let decoded: OrphanLinksResponse =
1367 serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
1368 assert_eq!(decoded, resp);
1369 }
1370
1371 #[test]
1372 fn pending_revision_item_round_trip() {
1373 let item = PendingRevisionItem {
1374 target_source_id: "mem_target".into(),
1375 revision_source_id: "mem_rev".into(),
1376 revision_content: "new body".into(),
1377 source_agent: Some("claude-code".into()),
1378 last_modified: 1_715_000_000,
1379 grounded_in: None,
1380 };
1381 let json = serde_json::to_value(&item).unwrap();
1382 assert_eq!(json["target_source_id"], "mem_target");
1383 assert_eq!(json["revision_source_id"], "mem_rev");
1384 assert_eq!(json["revision_content"], "new body");
1385 let decoded: PendingRevisionItem = serde_json::from_value(json).unwrap();
1386 assert_eq!(decoded, item);
1387 }
1388}
1389
1390#[cfg(test)]
1391mod queue_status_tests {
1392 use super::*;
1393
1394 #[test]
1395 fn status_response_defaults_queue_to_idle_when_absent() {
1396 let json =
1399 r#"{"is_running":true,"files_indexed":0,"files_total":0,"sources_connected":[]}"#;
1400 let parsed: StatusResponse = serde_json::from_str(json).unwrap();
1401 assert_eq!(parsed.queue, QueueStatus::Idle);
1402 }
1403
1404 #[test]
1405 fn queue_status_paused_round_trips_with_reason_and_retry() {
1406 let s = QueueStatus::Paused {
1407 reason: "analysis LLM failed".into(),
1408 pending: 2,
1409 next_retry_at: Some(1_712_678_400),
1410 };
1411 let json = serde_json::to_string(&s).unwrap();
1412 assert!(json.contains("\"state\":\"paused\""), "got: {json}");
1413 assert!(
1414 json.contains("\"reason\":\"analysis LLM failed\""),
1415 "got: {json}"
1416 );
1417 assert!(json.contains("\"next_retry_at\":1712678400"), "got: {json}");
1418 assert_eq!(serde_json::from_str::<QueueStatus>(&json).unwrap(), s);
1419 }
1420
1421 #[test]
1422 fn queue_status_active_round_trips() {
1423 let s = QueueStatus::Active { pending: 3 };
1424 let json = serde_json::to_string(&s).unwrap();
1425 assert!(json.contains("\"state\":\"active\""), "got: {json}");
1426 assert!(json.contains("\"pending\":3"), "got: {json}");
1427 assert_eq!(serde_json::from_str::<QueueStatus>(&json).unwrap(), s);
1428 }
1429
1430 #[test]
1431 fn queue_status_idle_serializes_state_only() {
1432 let json = serde_json::to_string(&QueueStatus::Idle).unwrap();
1433 assert_eq!(json, r#"{"state":"idle"}"#);
1434 }
1435}
1436
1437#[cfg(test)]
1438mod reranker_status_tests {
1439 use super::*;
1440
1441 #[test]
1442 fn status_response_defaults_reranker_to_disabled() {
1443 let json =
1446 r#"{"is_running":true,"files_indexed":0,"files_total":0,"sources_connected":[]}"#;
1447 let parsed: StatusResponse = serde_json::from_str(json).unwrap();
1448 assert_eq!(parsed.reranker, RerankerStatus::Disabled);
1449 assert_eq!(parsed.reranker_light, RerankerStatus::Disabled);
1450 assert_eq!(parsed.reranker_mode, "");
1451 }
1452
1453 #[test]
1454 fn status_response_roundtrips_per_path_reranker() {
1455 let s = StatusResponse {
1456 is_running: true,
1457 files_indexed: 0,
1458 files_total: 0,
1459 sources_connected: vec![],
1460 queue: QueueStatus::Idle,
1461 compile_queue: QueueStatus::Idle,
1462 reranker: RerankerStatus::Active {
1463 model_id: "BGERerankerBase".into(),
1464 },
1465 reranker_light: RerankerStatus::Active {
1466 model_id: "JINARerankerV1TurboEn".into(),
1467 },
1468 reranker_mode: "full".into(),
1469 };
1470 let json = serde_json::to_string(&s).unwrap();
1471 let parsed: StatusResponse = serde_json::from_str(&json).unwrap();
1472 assert_eq!(parsed.reranker, s.reranker);
1473 assert_eq!(parsed.reranker_light, s.reranker_light);
1474 assert_eq!(parsed.reranker_mode, "full");
1475 }
1476
1477 #[test]
1478 fn reranker_status_active_roundtrips() {
1479 let s = RerankerStatus::Active {
1480 model_id: "BGERerankerBase".into(),
1481 };
1482 let json = serde_json::to_string(&s).unwrap();
1483 assert_eq!(serde_json::from_str::<RerankerStatus>(&json).unwrap(), s);
1484 assert!(json.contains("\"state\":\"active\""));
1485 }
1486}
1487
1488#[cfg(test)]
1489mod search_memory_response_tests {
1490 use super::SearchMemoryResponse;
1491
1492 #[test]
1497 fn back_compat_missing_supplemental_pages_is_none() {
1498 let json = r#"{"results":[],"took_ms":1.0}"#;
1499 let resp: SearchMemoryResponse = serde_json::from_str(json).expect("should deserialize");
1500 assert!(
1501 resp.supplemental_pages.is_none(),
1502 "should be None when key absent"
1503 );
1504 assert_eq!(resp.took_ms, 1.0);
1505 }
1506
1507 #[test]
1510 fn none_supplemental_pages_not_serialized() {
1511 let resp = SearchMemoryResponse {
1512 results: vec![],
1513 took_ms: 2.0,
1514 supplemental_pages: None,
1515 };
1516 let json = serde_json::to_string(&resp).expect("serialize");
1517 assert!(
1518 !json.contains("supplemental_pages"),
1519 "None field must be omitted from wire: {}",
1520 json
1521 );
1522 }
1523
1524 #[test]
1526 fn some_supplemental_pages_round_trips() {
1527 let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1528 let resp: SearchMemoryResponse = serde_json::from_str(json).expect("deserialize");
1529 assert!(
1530 resp.supplemental_pages.is_some(),
1531 "supplemental_pages should be Some"
1532 );
1533 assert!(
1534 resp.supplemental_pages.unwrap().is_empty(),
1535 "empty array should deserialize to empty vec"
1536 );
1537 }
1538}
1539
1540#[cfg(test)]
1541mod search_response_tests {
1542 use super::SearchResponse;
1543
1544 #[test]
1549 fn back_compat_missing_supplemental_pages_is_none() {
1550 let json = r#"{"results":[],"took_ms":1.0}"#;
1551 let resp: SearchResponse = serde_json::from_str(json).expect("should deserialize");
1552 assert!(
1553 resp.supplemental_pages.is_none(),
1554 "should be None when key absent"
1555 );
1556 assert_eq!(resp.took_ms, 1.0);
1557 }
1558
1559 #[test]
1562 fn none_supplemental_pages_not_serialized() {
1563 let resp = SearchResponse {
1564 results: vec![],
1565 took_ms: 2.0,
1566 supplemental_pages: None,
1567 };
1568 let json = serde_json::to_string(&resp).expect("serialize");
1569 assert!(
1570 !json.contains("supplemental_pages"),
1571 "None field must be omitted from wire: {}",
1572 json
1573 );
1574 }
1575
1576 #[test]
1578 fn some_supplemental_pages_round_trips() {
1579 let json = r#"{"results":[],"took_ms":0.5,"supplemental_pages":[]}"#;
1580 let resp: SearchResponse = serde_json::from_str(json).expect("deserialize");
1581 assert!(
1582 resp.supplemental_pages.is_some(),
1583 "supplemental_pages should be Some"
1584 );
1585 assert!(
1586 resp.supplemental_pages.unwrap().is_empty(),
1587 "empty array should deserialize to empty vec"
1588 );
1589 }
1590}