Skip to main content

wenlan_types/
memory.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Core memory data types — search results, items, stats, profiles, agents, spaces.
3
4use serde::{Deserialize, Serialize};
5
6/// A search result from hybrid (vector + FTS) search.
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct SearchResult {
9    pub id: String,
10    pub content: String,
11    pub source: String,
12    pub source_id: String,
13    pub title: String,
14    pub url: Option<String>,
15    pub chunk_index: i32,
16    pub last_modified: i64,
17    pub score: f32,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub chunk_type: Option<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub language: Option<String>,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub semantic_unit: Option<String>,
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub memory_type: Option<String>,
26    #[serde(default, alias = "domain", skip_serializing_if = "Option::is_none")]
27    pub space: Option<String>,
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub source_agent: Option<String>,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub confidence: Option<f32>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub confirmed: Option<bool>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub stability: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub supersedes: Option<String>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub summary: Option<String>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub entity_id: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub entity_name: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub quality: Option<String>,
46    /// T8 salience prior: per-memory importance 1-10 (NULL = unrated). Read into
47    /// the ranking formula only when `WENLAN_ENABLE_SALIENCE_PRIOR` is on.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub importance: Option<u8>,
50    /// Event timestamp (unix seconds) the memory describes, when extractable.
51    /// Read into the temporal SOFT boost only when `WENLAN_ENABLE_TEMPORAL_SOFT_BOOST`
52    /// is on; NULL (undated) rows stay neutral.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub event_date: Option<i64>,
55    #[serde(default)]
56    pub is_archived: bool,
57    #[serde(default)]
58    pub is_recap: bool,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub structured_fields: Option<String>,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    pub retrieval_cue: Option<String>,
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub source_text: Option<String>,
65    /// Raw RRF score before normalization -- for absolute relevance gating.
66    #[serde(default)]
67    pub raw_score: f32,
68    #[serde(default)]
69    pub version: i64,
70    #[serde(default)]
71    pub pending_revision: bool,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub merged_from: Option<Vec<String>>,
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub last_delta_summary: Option<String>,
76}
77
78/// A full memory item with all metadata.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct MemoryItem {
81    pub source_id: String,
82    pub title: String,
83    pub content: String,
84    pub summary: Option<String>,
85    pub memory_type: Option<String>,
86    #[serde(default, alias = "domain")]
87    pub space: Option<String>,
88    pub source_agent: Option<String>,
89    pub confidence: Option<f32>,
90    pub confirmed: bool,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub stability: Option<String>,
93    pub pinned: bool,
94    pub supersedes: Option<String>,
95    pub last_modified: i64,
96    pub chunk_count: u64,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub entity_id: Option<String>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    pub quality: Option<String>,
101    #[serde(default)]
102    pub is_recap: bool,
103    pub enrichment_status: String,
104    pub supersede_mode: String,
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub structured_fields: Option<String>,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub retrieval_cue: Option<String>,
109    pub access_count: u64,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub source_text: Option<String>,
112    #[serde(default = "default_version")]
113    pub version: i64,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub changelog: Option<String>,
116    #[serde(default)]
117    pub pending_revision: bool,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub merged_from: Option<Vec<String>>,
120}
121
122fn default_version() -> i64 {
123    1
124}
125
126/// Per-step enrichment outcome for diagnostics.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct EnrichmentStepStatus {
129    pub step: String,
130    pub status: String,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub error: Option<String>,
133    pub attempts: u32,
134}
135
136/// Response for GET /api/memory/{id}/enrichment-status.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct EnrichmentStatusResponse {
139    pub source_id: String,
140    pub summary: String,
141    pub steps: Vec<EnrichmentStepStatus>,
142}
143
144/// A single item in a version chain.
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct MemoryVersionItem {
147    pub source_id: String,
148    pub title: String,
149    pub content: String,
150    pub memory_type: Option<String>,
151    pub confirmed: bool,
152    pub supersedes: Option<String>,
153    pub last_modified: i64,
154}
155
156/// Aggregate memory statistics.
157#[derive(Debug, Clone, Serialize, Deserialize)]
158pub struct MemoryStats {
159    pub total: u64,
160    pub new_today: u64,
161    pub confirmed: u64,
162    pub domains: Vec<DomainInfo>,
163    #[serde(default)]
164    pub by_type: Vec<TypeBreakdown>,
165    #[serde(default)]
166    pub entity_linked: u64,
167    #[serde(default)]
168    pub enrichment_pending: u64,
169}
170
171/// Count breakdown by memory type.
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct TypeBreakdown {
174    pub memory_type: String,
175    pub count: u64,
176}
177
178/// Count breakdown by space.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct DomainInfo {
181    pub name: String,
182    pub count: u64,
183}
184
185/// File/document info as shown in list views.
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct IndexedFileInfo {
188    pub source_id: String,
189    pub title: String,
190    pub source: String,
191    pub url: Option<String>,
192    pub chunk_count: u64,
193    pub last_modified: i64,
194    pub summary: Option<String>,
195    #[serde(default)]
196    pub processing: bool,
197    pub memory_type: Option<String>,
198    #[serde(default, alias = "domain")]
199    pub space: Option<String>,
200    pub source_agent: Option<String>,
201    pub confidence: Option<f32>,
202    pub confirmed: Option<bool>,
203    pub stability: Option<String>,
204    pub pinned: bool,
205    /// Unix timestamp (seconds) when the memory was first created.
206    /// Populated from the `memories.created_at` column (migration 21).
207    /// Defaults to 0 for rows from before migration 21.
208    #[serde(default)]
209    pub created_at: i64,
210    /// The full memory content. Populated by `list_filtered_confirmed` for
211    /// unconfirmed-review surfaces. Empty string when the producer did not
212    /// include it (e.g. aggregate file-list queries).
213    #[serde(default)]
214    pub content: String,
215}
216
217/// Home dashboard statistics.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct HomeStats {
220    pub total: u64,
221    pub new_today: u64,
222    pub confirmed: u64,
223    pub total_ingested: u64,
224    pub active_insights: u64,
225    pub distilled_today: u64,
226    pub distilled_all: u64,
227    pub sources_archived: u64,
228    pub times_served_today: u64,
229    pub words_saved_today: u64,
230    pub times_served_week: u64,
231    pub words_saved_week: u64,
232    pub times_served_all: u64,
233    pub words_saved_all: u64,
234    pub corrections_active: u64,
235    pub top_memories: Vec<TopMemory>,
236}
237
238/// A top-accessed memory for the home dashboard.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct TopMemory {
241    pub source_id: String,
242    pub content: String,
243    pub memory_type: Option<String>,
244    #[serde(default, alias = "domain")]
245    pub space: Option<String>,
246    pub times_retrieved: u64,
247}
248
249/// A session snapshot — a compact summary of a contiguous working session.
250///
251/// Wire format for `GET /api/snapshots`. Mirrors `SessionSnapshotRow` in
252/// wenlan-core/db.rs but lives here so wenlan-types can stay the single
253/// source of truth for HTTP boundary shapes (and because capture_count is
254/// serialized as a JSON number rather than a Rust `usize`).
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct SessionSnapshot {
257    pub id: String,
258    pub activity_id: String,
259    pub started_at: i64,
260    pub ended_at: i64,
261    pub primary_apps: Vec<String>,
262    pub summary: String,
263    pub tags: Vec<String>,
264    pub capture_count: u64,
265}
266
267/// A single capture belonging to a snapshot.
268///
269/// Wire format for `GET /api/snapshots/{id}/captures`.
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct SnapshotCapture {
272    pub source_id: String,
273    pub app_name: String,
274    pub window_title: String,
275    pub timestamp: i64,
276    pub source: String,
277}
278
279/// A snapshot capture enriched with full chunk content + LLM summary.
280///
281/// Wire format for `GET /api/snapshots/{id}/captures-with-content`. The
282/// frontend uses this to render the snapshot detail panel.
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct SnapshotCaptureWithContent {
285    pub source_id: String,
286    pub app_name: String,
287    pub window_title: String,
288    pub timestamp: i64,
289    pub source: String,
290    pub content: String,
291    pub summary: Option<String>,
292}
293
294/// User profile.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct Profile {
297    pub id: String,
298    pub name: String,
299    pub display_name: Option<String>,
300    pub email: Option<String>,
301    pub bio: Option<String>,
302    pub avatar_path: Option<String>,
303    pub created_at: i64,
304    pub updated_at: i64,
305}
306
307/// An agent connection record.
308#[derive(Debug, Clone, Serialize, Deserialize)]
309pub struct AgentConnection {
310    pub id: String,
311    /// Canonical technical identifier (lowercase, hyphen-case). Matches the
312    /// `x-agent-name` HTTP header sent by the client. This is the value used
313    /// for attribution and filtering — treat it as the primary key.
314    pub name: String,
315    /// Human-readable name shown in UI. If None, the frontend falls back to
316    /// `KNOWN_CLIENT_DISPLAY_NAMES[name]` and then to `name` itself.
317    #[serde(default)]
318    pub display_name: Option<String>,
319    pub agent_type: String,
320    pub description: Option<String>,
321    pub enabled: bool,
322    pub trust_level: String,
323    pub last_seen_at: Option<i64>,
324    pub memory_count: i64,
325    pub created_at: i64,
326    pub updated_at: i64,
327}
328
329/// An agent activity log entry.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct AgentActivityRow {
332    pub id: i64,
333    pub timestamp: i64,
334    pub agent_name: String,
335    pub action: String,
336    pub memory_ids: Option<String>,
337    pub query: Option<String>,
338    pub detail: Option<String>,
339    pub memory_titles: Vec<String>,
340}
341
342/// A space (domain grouping).
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub struct Space {
345    pub id: String,
346    pub name: String,
347    pub description: Option<String>,
348    pub suggested: bool,
349    pub starred: bool,
350    pub sort_order: i64,
351    pub memory_count: u64,
352    pub entity_count: u64,
353    pub created_at: f64,
354    pub updated_at: f64,
355}
356
357/// A rejected memory entry for quality gate diagnostics.
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct RejectionRecord {
360    pub id: String,
361    pub content: String,
362    pub source_agent: Option<String>,
363    pub rejection_reason: String,
364    pub rejection_detail: Option<String>,
365    pub similarity_score: Option<f64>,
366    pub similar_to_source_id: Option<String>,
367    pub created_at: i64,
368}
369
370/// An event describing when an agent retrieved pages/memories from Wenlan.
371///
372/// Backs Zone 4 of the home page ("Where Claude leaned on you") — a proof
373/// surface showing which pages were pulled into an agent's context and
374/// when, giving the user evidence their curated knowledge is in use.
375#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
376pub struct RetrievalEvent {
377    pub timestamp_ms: i64,
378    pub agent_name: String,
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub query: Option<String>,
381    #[serde(default)]
382    pub page_titles: Vec<String>,
383    /// Stable page IDs corresponding 1:1 with `page_titles`.
384    /// Used by the UI to navigate directly by ID rather than doing a
385    /// fragile title lookup. Empty on legacy events recorded before this
386    /// field was added; the UI falls back to the title-match path in that case.
387    #[serde(default)]
388    pub page_ids: Vec<String>,
389    #[serde(default)]
390    pub memory_snippets: Vec<String>,
391}
392
393/// The kind of change that happened to a page.
394#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
395#[serde(rename_all = "snake_case")]
396pub enum PageChangeKind {
397    Created,
398    Revised,
399    Merged,
400}
401
402/// A change event for a page — feeds the home page delta zones.
403///
404/// Backs Zones 1 and 3 of the home page: surfacing newly created, revised,
405/// or merged pages so the user sees their knowledge base evolving.
406#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
407pub struct PageChange {
408    pub page_id: String,
409    pub title: String,
410    pub change_kind: PageChangeKind,
411    pub changed_at_ms: i64,
412}
413
414/// The kind of item in a recent-activity feed entry.
415#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
416#[serde(rename_all = "snake_case")]
417pub enum ActivityKind {
418    Page,
419    Memory,
420}
421
422/// A badge summarising what changed since the user last saw an item.
423#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
424#[serde(tag = "kind", rename_all = "snake_case")]
425pub enum ActivityBadge {
426    New,
427    Revised,
428    Refined,
429    Growing { added: u32 },
430    NeedsReview,
431    None,
432}
433
434/// A single entry in the home-page recent-activity feed.
435#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
436pub struct RecentActivityItem {
437    pub kind: ActivityKind,
438    pub id: String,
439    pub title: String,
440    pub snippet: Option<String>,
441    pub timestamp_ms: u64,
442    pub badge: ActivityBadge,
443}
444
445#[cfg(test)]
446mod indexed_file_info_created_at_test {
447    use super::*;
448
449    fn make_info(created_at: i64) -> IndexedFileInfo {
450        IndexedFileInfo {
451            source_id: "mem_abc".into(),
452            title: "Title".into(),
453            source: "memory".into(),
454            url: None,
455            chunk_count: 1,
456            last_modified: 1000,
457            summary: None,
458            processing: false,
459            memory_type: None,
460            space: None,
461            source_agent: None,
462            confidence: None,
463            confirmed: None,
464            stability: None,
465            pinned: false,
466            created_at,
467            content: String::new(),
468        }
469    }
470
471    #[test]
472    fn created_at_serializes_in_json() {
473        let info = make_info(1234);
474        let s = serde_json::to_string(&info).unwrap();
475        assert!(s.contains("\"created_at\":1234"), "got: {s}");
476    }
477
478    #[test]
479    fn created_at_defaults_to_zero_when_missing() {
480        let json = r#"{"source_id":"x","title":"T","source":"memory","url":null,
481            "chunk_count":1,"last_modified":1000,"processing":false,
482            "memory_type":null,"space":null,"source_agent":null,
483            "confidence":null,"confirmed":null,"stability":null,"pinned":false}"#;
484        let info: IndexedFileInfo = serde_json::from_str(json).unwrap();
485        assert_eq!(info.created_at, 0);
486    }
487
488    #[test]
489    fn content_round_trips() {
490        let mut info = make_info(9999);
491        info.content = "memory body".to_string();
492        let json = serde_json::to_string(&info).unwrap();
493        let back: IndexedFileInfo = serde_json::from_str(&json).unwrap();
494        assert_eq!(back.content, "memory body");
495    }
496
497    #[test]
498    fn content_defaults_to_empty_when_missing() {
499        let json = r#"{"source_id":"x","title":"T","source":"memory","url":null,
500            "chunk_count":1,"last_modified":1000,"processing":false,
501            "memory_type":null,"space":null,"source_agent":null,
502            "confidence":null,"confirmed":null,"stability":null,"pinned":false}"#;
503        let info: IndexedFileInfo = serde_json::from_str(json).unwrap();
504        assert_eq!(info.content, "");
505    }
506
507    #[test]
508    fn legacy_domain_alias_deserializes_to_space() {
509        let json = r#"{"source_id":"x","title":"T","source":"memory","url":null,
510            "chunk_count":1,"last_modified":1000,"processing":false,
511            "memory_type":null,"domain":"work","source_agent":null,
512            "confidence":null,"confirmed":null,"stability":null,"pinned":false}"#;
513        let info: IndexedFileInfo =
514            serde_json::from_str(json).expect("legacy domain key should deserialize");
515        assert_eq!(
516            info.space.as_deref(),
517            Some("work"),
518            "alias should map domain -> space"
519        );
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    #[test]
526    fn activity_badge_serializes_as_tagged_enum() {
527        use super::ActivityBadge;
528        assert_eq!(
529            serde_json::to_string(&ActivityBadge::New).unwrap(),
530            r#"{"kind":"new"}"#
531        );
532        assert_eq!(
533            serde_json::to_string(&ActivityBadge::Refined).unwrap(),
534            r#"{"kind":"refined"}"#
535        );
536        assert_eq!(
537            serde_json::to_string(&ActivityBadge::Revised).unwrap(),
538            r#"{"kind":"revised"}"#
539        );
540        assert_eq!(
541            serde_json::to_string(&ActivityBadge::NeedsReview).unwrap(),
542            r#"{"kind":"needs_review"}"#
543        );
544        assert_eq!(
545            serde_json::to_string(&ActivityBadge::None).unwrap(),
546            r#"{"kind":"none"}"#
547        );
548        assert_eq!(
549            serde_json::to_string(&ActivityBadge::Growing { added: 3 }).unwrap(),
550            r#"{"kind":"growing","added":3}"#
551        );
552    }
553
554    #[test]
555    fn recent_activity_item_round_trips() {
556        use super::{ActivityBadge, ActivityKind, RecentActivityItem};
557        let item = RecentActivityItem {
558            kind: ActivityKind::Memory,
559            id: "mem_abc".into(),
560            title: "A memory".into(),
561            snippet: Some("Snippet text".into()),
562            timestamp_ms: 1_776_000_000_000,
563            badge: ActivityBadge::New,
564        };
565        let s = serde_json::to_string(&item).unwrap();
566        let back: RecentActivityItem = serde_json::from_str(&s).unwrap();
567        assert_eq!(back.id, "mem_abc");
568        assert!(matches!(back.kind, ActivityKind::Memory));
569        assert!(matches!(back.badge, ActivityBadge::New));
570    }
571
572    #[test]
573    fn retrieval_event_includes_memory_snippets() {
574        use super::RetrievalEvent;
575        let evt = RetrievalEvent {
576            timestamp_ms: 1,
577            agent_name: "claude-code".into(),
578            query: None,
579            page_titles: vec![],
580            page_ids: vec![],
581            memory_snippets: vec!["The first line of the memory".into()],
582        };
583        let s = serde_json::to_string(&evt).unwrap();
584        assert!(s.contains("memory_snippets"));
585    }
586}